package httpapi import ( "errors" "log/slog" "net/http" "strconv" "time" "github.com/google/uuid" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/auth" "github.com/syncova/syncova/packages/jobs" "github.com/syncova/syncova/packages/platform/logging" ) // jobHandler bedient die Sicherungsaufträge (SYNCOVA_API.md §9). type jobHandler struct { // store ist die Datenzugriffsschicht der Aufträge. store *jobs.PostgresStore // auditRecorder protokolliert Änderungen an Aufträgen. auditRecorder audit.Recorder // logger protokolliert technische Fehler. logger *slog.Logger } // scheduleRequest beschreibt einen Zeitplan im Anfragerumpf. // // Bewusst eine eigene Struktur statt scheduler.Schedule: Die API-Gestalt darf // sich nicht mitverändern, wenn ein internes Feld umbenannt wird. Der Vertrag // nach außen ist stabiler als der Code dahinter. type scheduleRequest struct { // Type ist die Art des Zeitplans. Type string `json:"type"` // Interval ist der Abstand in Sekunden bei type=interval. IntervalSeconds int64 `json:"interval_seconds,omitempty"` // Time ist die Uhrzeit im Format "HH:MM". // // Eine Zeichenkette statt zweier Zahlen, weil SYNCOVA_API.md §9 sie so // vorgibt und weil "02:00" für einen Anwender lesbar ist. Time string `json:"time,omitempty"` // Weekdays sind die Wochentage (0 = Sonntag). Weekdays []int `json:"weekdays,omitempty"` // MonthDays sind die Tage des Monats; -1 bedeutet Monatsletzter. MonthDays []int `json:"month_days,omitempty"` // CronExpression ist der Ausdruck bei type=cron. CronExpression string `json:"cron_expression,omitempty"` // TimeZone ist die Zeitzone der Uhrzeiten. TimeZone string `json:"time_zone,omitempty"` } // sourceRequest beschreibt eine Quelle im Anfragerumpf. type sourceRequest struct { // Type ist die Art der Quelle. Type string `json:"type"` // ID ist die Kennung innerhalb ihrer Art. ID string `json:"id"` // Name ist die sprechende Bezeichnung. Name string `json:"name,omitempty"` // AgentID ist der ausführende Agent. AgentID *uuid.UUID `json:"agent_id,omitempty"` // IncludePatterns beschränken die Erfassung. IncludePatterns []string `json:"include_patterns,omitempty"` // ExcludePatterns nehmen Pfade aus. ExcludePatterns []string `json:"exclude_patterns,omitempty"` } // jobRequest ist der Rumpf von POST und PATCH auf /jobs. type jobRequest struct { // Name ist die eindeutige Bezeichnung. Name string `json:"name"` // Description erläutert den Zweck. Description string `json:"description,omitempty"` // Priority ist die Dringlichkeit. Priority string `json:"priority,omitempty"` // Schedule ist der Zeitplan. Schedule scheduleRequest `json:"schedule"` // Sources sind die zu sichernden Quellen. Sources []sourceRequest `json:"sources"` // RepositoryID ist das Ziel-Repository. RepositoryID uuid.UUID `json:"repository_id"` // RetentionPolicyID ist die Aufbewahrungsregel. RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"` // DependsOnJobIDs sind vorausgesetzte Aufträge. DependsOnJobIDs []uuid.UUID `json:"depends_on_job_ids,omitempty"` // RecoveryPointSeconds ist der zulässige Datenverlust in Sekunden. RecoveryPointSeconds int64 `json:"rpo_seconds,omitempty"` // RecoveryTimeSeconds ist die zulässige Wiederherstellungsdauer in Sekunden. RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` // BandwidthLimitBytesPerSecond begrenzt den Durchsatz. BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` // MaximumConcurrency begrenzt gleichzeitige Läufe. MaximumConcurrency int `json:"max_concurrency,omitempty"` } // jobResponse ist die Darstellung eines Auftrags nach außen. type jobResponse struct { // ID ist der öffentliche Bezeichner. ID uuid.UUID `json:"id"` // Name ist die Bezeichnung. Name string `json:"name"` // Description erläutert den Zweck. Description string `json:"description,omitempty"` // Status ist der Zustand. Status string `json:"status"` // Priority ist die Dringlichkeit. Priority string `json:"priority"` // Schedule ist der Zeitplan. Schedule scheduleRequest `json:"schedule"` // ScheduleDescription erklärt den Zeitplan in einem Satz. // // Ein Cron-Ausdruck sagt einem Anwender wenig; „täglich um 02:00 Uhr // (Europe/Berlin)" dagegen alles. Die Erklärung entsteht auf dem Server, // damit sie in Oberfläche und Benachrichtigung gleich lautet. ScheduleDescription string `json:"schedule_description"` // Sources sind die Quellen. Sources []sourceRequest `json:"sources"` // RepositoryID ist das Ziel-Repository. RepositoryID uuid.UUID `json:"repository_id"` // RetentionPolicyID ist die Aufbewahrungsregel. RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"` // DependsOnJobIDs sind vorausgesetzte Aufträge. DependsOnJobIDs []uuid.UUID `json:"depends_on_job_ids,omitempty"` // RecoveryPointSeconds ist der zulässige Datenverlust. RecoveryPointSeconds int64 `json:"rpo_seconds,omitempty"` // RecoveryTimeSeconds ist die zulässige Wiederherstellungsdauer. RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"` // BandwidthLimitBytesPerSecond begrenzt den Durchsatz. BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"` // MaximumConcurrency begrenzt gleichzeitige Läufe. MaximumConcurrency int `json:"max_concurrency"` // NextRunAt ist der nächste Zeitpunkt in UTC. NextRunAt *time.Time `json:"next_run_at,omitempty"` // LastRunAt ist der Beginn des letzten Laufs in UTC. LastRunAt *time.Time `json:"last_run_at,omitempty"` // LastOutcome ist der Ausgang des letzten Laufs. LastOutcome string `json:"last_outcome,omitempty"` // PausedAt ist der Zeitpunkt einer Aussetzung in UTC. PausedAt *time.Time `json:"paused_at,omitempty"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` // UpdatedAt ist der Zeitpunkt der letzten Änderung in UTC. UpdatedAt time.Time `json:"updated_at"` } // handleListJobs bedient GET /jobs. func (handler *jobHandler) handleListJobs(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) listFilter := jobs.ListFilter{ Status: jobs.JobStatus(request.URL.Query().Get("status")), SearchTerm: request.URL.Query().Get("search"), Page: parsePositiveInteger(request.URL.Query().Get("page"), 1), PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50), } if repositoryParameter := request.URL.Query().Get("repository"); repositoryParameter != "" { repositoryID, parseError := uuid.Parse(repositoryParameter) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Der Filter 'repository' ist keine gültige Kennung.")) return } listFilter.RepositoryID = &repositoryID } loadedJobs, totalCount, listError := handler.store.ListJobs(request.Context(), listFilter) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } jobResponses := make([]jobResponse, 0, len(loadedJobs)) for jobIndex := range loadedJobs { jobResponses = append(jobResponses, buildJobResponse(&loadedJobs[jobIndex])) } WritePaginatedSuccess(responseWriter, request, jobResponses, PaginationMeta{ Page: listFilter.Page, PageSize: listFilter.PageSize, Total: int64(totalCount), }) } // handleCreateJob bedient POST /jobs. func (handler *jobHandler) handleCreateJob(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var jobPayload jobRequest if decodeError := decodeJSONBody(request, &jobPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } newJob, conversionError := buildJobFromRequest(jobPayload, actingUser.ID) if conversionError != nil { WriteError(responseWriter, request, requestLogger, conversionError) return } // Der nächste Zeitpunkt wird sofort berechnet und mitgespeichert. Ohne ihn // stünde der Auftrag als aktiv da, ohne je zu laufen — bis jemand ihn von // Hand anstößt. if nextRun, nextError := newJob.Schedule.NextRun(time.Now().UTC()); nextError == nil { newJob.NextRunAt = &nextRun } createdJobID, createError := handler.store.CreateJob(request.Context(), newJob) if createError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(createError)) return } handler.recordAudit(request, actingUser, audit.ActionBackupJobCreated, createdJobID, map[string]any{ "name": newJob.Name, "schedule": newJob.Schedule.Describe(), "source_count": len(newJob.Sources), "repository_id": newJob.RepositoryID.String(), }) createdJob, readError := handler.store.GetJob(request.Context(), createdJobID) if readError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(readError)) return } WriteSuccess(responseWriter, request, http.StatusCreated, buildJobResponse(createdJob)) } // handleGetJob bedient GET /jobs/{id}. func (handler *jobHandler) handleGetJob(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) jobIdentifier, parseError := parseJobIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } loadedJob, readError := handler.store.GetJob(request.Context(), jobIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(readError)) return } WriteSuccess(responseWriter, request, http.StatusOK, buildJobResponse(loadedJob)) } // handleDeleteJob bedient DELETE /jobs/{id}. // // Die Löschung ist weich: Die Läufe eines gelöschten Auftrags bleiben als // Nachweis erhalten. Sie wird immer auditiert (PROMPT.md §140: destruktive // Aktionen niemals still). func (handler *jobHandler) handleDeleteJob(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) jobIdentifier, parseError := parseJobIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } // Der Auftrag wird vor der Löschung gelesen, damit das Auditprotokoll // festhält, was verschwunden ist. Danach wäre es nicht mehr feststellbar. existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(readError)) return } if deleteError := handler.store.SoftDeleteJob(request.Context(), jobIdentifier); deleteError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(deleteError)) return } handler.recordAudit(request, actingUser, audit.ActionBackupJobDeleted, jobIdentifier, map[string]any{ "name": existingJob.Name, "schedule": existingJob.Schedule.Describe(), "source_count": len(existingJob.Sources), }) WriteSuccess(responseWriter, request, http.StatusOK, map[string]string{ "status": "deleted", "message": "Der Auftrag wurde gelöscht. Seine bisherigen Läufe bleiben als Nachweis erhalten.", }) } // handlePauseJob bedient POST /jobs/{id}/pause. func (handler *jobHandler) handlePauseJob(responseWriter http.ResponseWriter, request *http.Request) { handler.changeJobStatus(responseWriter, request, jobs.JobStatusPaused, audit.ActionBackupJobPaused) } // handleResumeJob bedient POST /jobs/{id}/resume. func (handler *jobHandler) handleResumeJob(responseWriter http.ResponseWriter, request *http.Request) { handler.changeJobStatus(responseWriter, request, jobs.JobStatusActive, audit.ActionBackupJobResumed) } // changeJobStatus setzt den Zustand eines Auftrags und auditiert die Änderung. // // Das Aussetzen einer Sicherung ist sicherheitsrelevant: Es lässt den Schutz // still auslaufen, ohne dass etwas kaputtgeht. Deshalb wird es protokolliert // wie eine Löschung. func (handler *jobHandler) changeJobStatus(responseWriter http.ResponseWriter, request *http.Request, newStatus jobs.JobStatus, auditAction audit.Action) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) jobIdentifier, parseError := parseJobIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(readError)) return } if statusError := handler.store.SetJobStatus(request.Context(), jobIdentifier, newStatus, &actingUser.ID); statusError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(statusError)) return } // Beim Fortsetzen wird der nächste Zeitpunkt neu berechnet. Ohne diesen // Schritt bliebe der alte stehen: Ein Auftrag, der eine Woche ausgesetzt // war, liefe sofort los und danach zur falschen Zeit weiter. if newStatus == jobs.JobStatusActive { if nextRun, nextError := existingJob.Schedule.NextRun(time.Now().UTC()); nextError == nil { if updateError := handler.store.SetNextRun(request.Context(), jobIdentifier, &nextRun); updateError != nil { requestLogger.Warn("der nächste zeitpunkt konnte nicht gesetzt werden", slog.String("job_id", jobIdentifier.String()), slog.String("grund", updateError.Error())) } } } handler.recordAudit(request, actingUser, auditAction, jobIdentifier, map[string]any{ "name": existingJob.Name, "von_status": string(existingJob.Status), "nach_status": string(newStatus), }) updatedJob, updateReadError := handler.store.GetJob(request.Context(), jobIdentifier) if updateReadError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(updateReadError)) return } WriteSuccess(responseWriter, request, http.StatusOK, buildJobResponse(updatedJob)) } // runResponse ist die Darstellung eines Laufs nach außen. type runResponse struct { // ID ist der öffentliche Bezeichner. ID uuid.UUID `json:"id"` // JobID ist der ausgeführte Auftrag. JobID uuid.UUID `json:"job_id"` // Status ist der Zustand. Status string `json:"status"` // Trigger benennt den Auslöser. Trigger string `json:"trigger"` // AttemptNumber ist die Nummer des Versuchs. AttemptNumber int `json:"attempt_number"` // ScheduledFor ist der geplante Zeitpunkt in UTC. ScheduledFor *time.Time `json:"scheduled_for,omitempty"` // StartedAt ist der Beginn in UTC. StartedAt *time.Time `json:"started_at,omitempty"` // CompletedAt ist das Ende in UTC. CompletedAt *time.Time `json:"completed_at,omitempty"` // DurationSeconds ist die Dauer in Sekunden. DurationSeconds float64 `json:"duration_seconds,omitempty"` // DelaySeconds ist die Verspätung gegenüber dem geplanten Zeitpunkt. // // Die Abweichung ist die eigentliche Auskunft: Ein Lauf, der regelmäßig // eine Stunde zu spät beginnt, hat ein Problem, das man ohne diesen // Vergleich nicht sieht. DelaySeconds float64 `json:"delay_seconds,omitempty"` // BytesProcessed ist die gelesene Datenmenge. BytesProcessed int64 `json:"bytes_processed"` // BytesWritten ist die abgelegte Datenmenge. BytesWritten int64 `json:"bytes_written"` // FilesProcessed ist die Zahl bearbeiteter Objekte. FilesProcessed int64 `json:"files_processed"` // FilesSkipped ist die Zahl übergangener Objekte. FilesSkipped int64 `json:"files_skipped"` // ErrorCode ist die Fehlerkennung. ErrorCode string `json:"error_code,omitempty"` // ErrorMessage ist die verständliche Fehlermeldung. ErrorMessage string `json:"error_message,omitempty"` // FailureClass ordnet den Fehler ein. FailureClass string `json:"failure_class,omitempty"` // CorrelationID verbindet den Lauf mit seinen Protokollzeilen. CorrelationID uuid.UUID `json:"correlation_id"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` } // buildRunResponse wandelt einen Lauf in seine Darstellung. func buildRunResponse(sourceRun *jobs.Run) runResponse { builtResponse := runResponse{ ID: sourceRun.ID, JobID: sourceRun.JobID, Status: string(sourceRun.Status), Trigger: string(sourceRun.Trigger), AttemptNumber: sourceRun.AttemptNumber, ScheduledFor: sourceRun.ScheduledFor, StartedAt: sourceRun.StartedAt, CompletedAt: sourceRun.CompletedAt, BytesProcessed: sourceRun.BytesProcessed, BytesWritten: sourceRun.BytesWritten, FilesProcessed: sourceRun.FilesProcessed, FilesSkipped: sourceRun.FilesSkipped, ErrorCode: sourceRun.ErrorCode, ErrorMessage: sourceRun.ErrorMessage, FailureClass: string(sourceRun.FailureClass), CorrelationID: sourceRun.CorrelationID, CreatedAt: sourceRun.CreatedAt, } builtResponse.DurationSeconds = sourceRun.Duration().Seconds() if sourceRun.ScheduledFor != nil && sourceRun.StartedAt != nil { builtResponse.DelaySeconds = sourceRun.StartedAt.Sub(*sourceRun.ScheduledFor).Seconds() } return builtResponse } // handleRunJob bedient POST /jobs/{id}/run. // // Der Lauf wird eingereiht, nicht ausgeführt: Die Ausführungsschleife holt ihn // im nächsten Durchgang. Der Endpunkt antwortet deshalb mit 202 statt 201 — // die Sicherung hat noch nicht begonnen. func (handler *jobHandler) handleRunJob(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) jobIdentifier, parseError := parseJobIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(readError)) return } // Ein ausgesetzter Auftrag wird nicht heimlich reaktiviert. Wer ihn // ausführen will, setzt ihn zuerst fort — sonst liefe er einmal und // schwiege danach wieder, ohne dass es jemandem auffiele. if existingJob.Status == jobs.JobStatusPaused { WriteError(responseWriter, request, requestLogger, NewValidationError( "Der Auftrag ist ausgesetzt. Setzen Sie ihn zuerst fort, bevor Sie ihn ausführen.")) return } createdRun, createError := handler.store.CreateManualRun(request.Context(), jobIdentifier, &actingUser.ID) if createError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(createError)) return } handler.recordAudit(request, actingUser, audit.ActionBackupJobRunRequested, jobIdentifier, map[string]any{ "name": existingJob.Name, "run_id": createdRun.ID.String(), }) WriteSuccess(responseWriter, request, http.StatusAccepted, buildRunResponse(createdRun)) } // handleListJobRuns bedient GET /jobs/{id}/runs. func (handler *jobHandler) handleListJobRuns(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) jobIdentifier, parseError := parseJobIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } if _, readError := handler.store.GetJob(request.Context(), jobIdentifier); readError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(readError)) return } requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1) requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50) loadedRuns, totalCount, listError := handler.store.ListRuns(request.Context(), jobIdentifier, requestedPage, requestedPageSize) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } runResponses := make([]runResponse, 0, len(loadedRuns)) for runIndex := range loadedRuns { runResponses = append(runResponses, buildRunResponse(&loadedRuns[runIndex])) } WritePaginatedSuccess(responseWriter, request, runResponses, PaginationMeta{ Page: requestedPage, PageSize: requestedPageSize, Total: int64(totalCount), }) } // handleCancelRun bedient POST /backup-runs/{id}/cancel. func (handler *jobHandler) handleCancelRun(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) runIdentifier, uuidError := uuid.Parse(request.PathValue("id")) if uuidError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Laufkennung ist keine gültige UUID.")) return } existingRun, readError := handler.store.GetRun(request.Context(), runIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(readError)) return } if cancelError := handler.store.CancelRun(request.Context(), runIdentifier, "Der Lauf wurde von "+actingUser.Username+" abgebrochen."); cancelError != nil { WriteError(responseWriter, request, requestLogger, translateJobError(cancelError)) return } handler.recordAudit(request, actingUser, audit.ActionBackupRunCancelled, existingRun.JobID, map[string]any{ "run_id": runIdentifier.String(), }) cancelledRun, refreshError := handler.store.GetRun(request.Context(), runIdentifier) if refreshError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(refreshError)) return } // Der Abbruch wirkt nicht sofort: Die Ausführungsschleife beendet den // laufenden Vorgang beim nächsten Durchgang. Das wird gesagt, statt einen // bereits beendeten Lauf vorzutäuschen. WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "run": buildRunResponse(cancelledRun), "message": "Der Abbruch wurde vermerkt. Ein bereits laufender Vorgang wird in Kürze beendet.", }) } // recordAudit schreibt ein Auditereignis. // // Ein Fehler beim Protokollieren darf die Antwort nicht verändern — die // Handlung ist bereits geschehen. Er wird aber deutlich protokolliert: Ein // stiller Verlust von Auditereignissen wäre ein Sicherheitsmangel. func (handler *jobHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, jobIdentifier uuid.UUID, auditDetails map[string]any) { if handler.auditRecorder == nil { return } correlationID, _ := logging.CorrelationIDFromContext(request.Context()) recordError := handler.auditRecorder.Record(request.Context(), audit.Event{ UserID: &actingUser.ID, ActorUsername: actingUser.Username, Action: auditAction, EntityType: "backup_job", EntityID: &jobIdentifier, Result: audit.ResultSuccess, IPAddress: clientIPAddress(request), UserAgent: request.UserAgent(), CorrelationID: correlationID, Details: auditDetails, }) if recordError != nil { logging.WithContext(request.Context(), handler.logger).Error("das auditereignis konnte nicht geschrieben werden", slog.String("aktion", string(auditAction)), slog.String("job_id", jobIdentifier.String()), slog.String("grund", recordError.Error())) } } // parseJobIdentifier liest die Auftragskennung aus dem Pfad. func parseJobIdentifier(request *http.Request) (uuid.UUID, *APIError) { jobIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { return uuid.Nil, NewBadRequestError("Die Auftragskennung ist keine gültige UUID.") } return jobIdentifier, nil } // parsePositiveInteger liest eine positive Zahl mit Standardwert. func parsePositiveInteger(parameterValue string, defaultValue int) int { parsedValue, parseError := strconv.Atoi(parameterValue) if parseError != nil || parsedValue < 1 { return defaultValue } return parsedValue } // translateJobError bildet Fehler der Fachschicht auf API-Fehler ab. // // Ein durchgereichter interner Fehler verriete Aufbau und Tabellennamen der // Datenbank. Bekannte Fälle bekommen deshalb eine eigene, verständliche // Meldung; alles Übrige wird zu einem allgemeinen Serverfehler. func translateJobError(occurredError error) *APIError { switch { case errors.Is(occurredError, jobs.ErrJobNotFound): return NewNotFoundError("Der Sicherungsauftrag wurde nicht gefunden.") case errors.Is(occurredError, jobs.ErrRunNotFound): return NewNotFoundError("Der Sicherungslauf wurde nicht gefunden oder ist bereits beendet.") case errors.Is(occurredError, jobs.ErrRunAlreadyActive): // 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, der // Auftrag läuft nur bereits. Ein Serverfehler schickte ihn auf die // Suche nach einem Defekt, den es nicht gibt. activeError := NewValidationError( "Für diesen Auftrag läuft bereits ein Sicherungslauf. Warten Sie dessen Ende ab.") activeError.Code = ErrorCodeConflict activeError.StatusCode = http.StatusConflict return activeError case errors.Is(occurredError, jobs.ErrJobNameTaken): conflictError := NewValidationError("Ein Sicherungsauftrag dieses Namens besteht bereits.") conflictError.Code = ErrorCodeConflict conflictError.StatusCode = http.StatusConflict return conflictError case errors.Is(occurredError, jobs.ErrInvalidJob): // Die Meldung der Fachschicht ist bereits verständlich formuliert und // enthält keine Interna — sie wird deshalb weitergereicht. return NewValidationError(occurredError.Error()) default: return NewInternalError(occurredError) } } // repositoryResponse ist die Darstellung eines Repositorys nach außen. type repositoryResponse struct { // ID ist der öffentliche Bezeichner. ID uuid.UUID `json:"id"` // Name ist die sprechende Bezeichnung. Name string `json:"name"` // RepositoryType benennt die Ablageart. RepositoryType string `json:"repository_type"` // Location ist der Pfad oder die Adresse der Ablage. // // Der Pfad ist keine Zugangsinformation und kein Geheimnis; ohne ihn liesse // sich in der Oberfläche nicht unterscheiden, welches von zwei gleich // benannten Zielen gemeint ist. Location string `json:"location"` // Status ist der Betriebszustand. Status string `json:"status"` // AcceptsBackups meldet, ob dieses Ziel Sicherungen annimmt. // // Der Zustand allein genügt der Oberfläche nicht: Sie müsste sonst wissen, // welche Zustände schreibend sind. Diese Regel gehört auf den Server. AcceptsBackups bool `json:"accepts_backups"` // Hardened meldet den gehärteten Modus. Hardened bool `json:"hardened"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` } // handleListRepositories bedient GET /repositories. // // Rein lesend und ohne Pagination: Ein Betrieb hat eine Handvoll Repositories, // nicht tausende. Das Anlegen geschieht weiterhin über syncova-repo — ein // Repository entsteht auf einem Datenträger, nicht in einer Datenbankzeile. func (handler *jobHandler) handleListRepositories(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) loadedRepositories, listError := handler.store.ListRepositories(request.Context()) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } repositoryResponses := make([]repositoryResponse, 0, len(loadedRepositories)) for _, loadedRepository := range loadedRepositories { repositoryResponses = append(repositoryResponses, repositoryResponse{ ID: loadedRepository.ID, Name: loadedRepository.Name, RepositoryType: loadedRepository.RepositoryType, Location: loadedRepository.Location, Status: string(loadedRepository.Status), AcceptsBackups: loadedRepository.Status.AcceptsWrites(), Hardened: loadedRepository.Hardened, CreatedAt: loadedRepository.CreatedAt, }) } WriteSuccess(responseWriter, request, http.StatusOK, repositoryResponses) } // handleListBackups bedient GET /backups (Wiederherstellungspunkte). // // Die Seite „Recovery Points" ist die zentrale Auskunft der Anlage: Welche // Punkte gibt es, und kann man sich auf sie verlassen? Die Antwort trägt // deshalb Einstufung, Bewertung und Schutzlage je Zeile — sonst müsste die // Oberfläche je Punkt drei weitere Anfragen stellen. func (handler *jobHandler) handleListBackups(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) listFilter := jobs.BackupListFilter{ Status: request.URL.Query().Get("status"), Classification: request.URL.Query().Get("classification"), IncludeDeleted: request.URL.Query().Get("include_deleted") == "true", OnlyProtected: request.URL.Query().Get("only_protected") == "true", Page: parsePositiveInteger(request.URL.Query().Get("page"), 1), PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50), } if repositoryText := request.URL.Query().Get("repository_id"); repositoryText != "" { repositoryIdentifier, parseError := uuid.Parse(repositoryText) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Repositorykennung ist keine gültige UUID.")) return } listFilter.RepositoryID = &repositoryIdentifier } if jobText := request.URL.Query().Get("job_id"); jobText != "" { jobIdentifier, parseError := uuid.Parse(jobText) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Auftragskennung ist keine gültige UUID.")) return } listFilter.JobID = &jobIdentifier } loadedBackups, totalCount, listError := handler.store.ListBackups(request.Context(), listFilter) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WritePaginatedSuccess(responseWriter, request, loadedBackups, PaginationMeta{ Page: listFilter.Page, PageSize: listFilter.PageSize, Total: int64(totalCount), }) } // handleDashboard bedient GET /dashboard. // // Jedes Widget meldet, ob es eine Datengrundlage hat. Drei der zehn im Plan // genannten haben sie in dieser Ausbaustufe nicht; sie erscheinen trotzdem — // mit der Angabe, was fehlt. Ein weggelassenes Widget sieht aus wie ein // vergessenes, ein gefülltes wäre eine erfundene Statistik (PROMPT.md §139). func (handler *jobHandler) handleDashboard(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) dashboardData, dashboardError := handler.store.Dashboard(request.Context()) if dashboardError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(dashboardError)) return } WriteSuccess(responseWriter, request, http.StatusOK, dashboardData) }