package httpapi import ( "errors" "log/slog" "net/http" "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" "github.com/syncova/syncova/packages/verification" ) // verificationHandler bedient die Pruefung (SYNCOVA_API.md ยง14). type verificationHandler struct { // verificationStore ist die Datenzugriffsschicht der Pruefauftraege. verificationStore *verification.Store // jobStore liefert Backups und Repositories. jobStore *jobs.PostgresStore // auditRecorder protokolliert ausgeloeste Pruefungen. auditRecorder audit.Recorder // logger protokolliert technische Fehler. logger *slog.Logger } // verificationRequest ist der Rumpf von POST /verification. type verificationRequest struct { // BackupID ist das zu pruefende Backup. BackupID uuid.UUID `json:"backup_id"` // VerificationType ist die Art der Pruefung. VerificationType string `json:"verification_type"` } // verificationResponse ist die Darstellung eines Pruefauftrags. type verificationResponse struct { // ID ist der oeffentliche Bezeichner. ID uuid.UUID `json:"id"` // BackupID ist das gepruefte Backup. BackupID uuid.UUID `json:"backup_id"` // VerificationType ist die Art der Pruefung. VerificationType string `json:"verification_type"` // Status ist der Zustand. Status string `json:"status"` // Result ist das Ergebnis; leer solange nicht abgeschlossen. Result string `json:"result,omitempty"` // ChunksChecked ist die Zahl gepruefter Bloecke. ChunksChecked int64 `json:"chunks_checked"` // ChunksMissing ist die Zahl fehlender Bloecke. ChunksMissing int64 `json:"chunks_missing"` // ChunksCorrupted ist die Zahl beschaedigter Bloecke. ChunksCorrupted int64 `json:"chunks_corrupted"` // BytesRead ist die gelesene Datenmenge. BytesRead int64 `json:"bytes_read"` // 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"` // ErrorMessage ist die verstaendliche Fehlermeldung. ErrorMessage string `json:"error_message,omitempty"` // Summary fasst das Ergebnis in einem Satz zusammen. // // Die Zusammenfassung sagt ausdruecklich, was **nicht** geprueft wurde: Eine // Manifestpruefung ohne diesen Zusatz liesse sich fuer einen Nachweis der // Wiederherstellbarkeit halten, der sie nicht ist. Summary string `json:"summary,omitempty"` // CorrelationID verbindet den Auftrag mit seinen Protokollzeilen. CorrelationID uuid.UUID `json:"correlation_id"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` } // assuranceResponse ist die Bewertung eines Backups. type assuranceResponse struct { // BackupID ist das bewertete Backup. BackupID uuid.UUID `json:"backup_id"` // Classification ist die objektive Einstufung. Classification string `json:"classification"` // ClassificationDescription erklaert die Einstufung. ClassificationDescription string `json:"classification_description"` // Percentage ist die Bewertung in Prozent. Percentage int `json:"percentage"` // UnknownInputCount ist die Zahl ungemessener Eingangsgroessen. UnknownInputCount int `json:"unknown_input_count"` // IsTrustworthy meldet eine belastbare Bewertung. IsTrustworthy bool `json:"is_trustworthy"` // Summary fasst die Bewertung in einem Satz zusammen. Summary string `json:"summary"` // MissingMeasurements nennt die fehlenden Messungen als Handlungsanweisung. MissingMeasurements []string `json:"missing_measurements"` // Inputs sind die einzelnen Eingangsgroessen. Inputs []verification.ScoreInput `json:"inputs"` // LastVerifiedAt ist die letzte Integritaetspruefung in UTC. LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"` // LastRestoreTestAt ist der letzte Wiederherstellungstest in UTC. LastRestoreTestAt *time.Time `json:"last_restore_test_at,omitempty"` } // handleCreateVerification bedient POST /verification. // // Die Pruefung wird eingereiht, nicht ausgefuehrt: Die Pruefschleife holt sie im // naechsten Durchgang. Deshalb 202 statt 201 โ€” das Ergebnis steht noch aus. func (handler *verificationHandler) handleCreateVerification(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var verificationPayload verificationRequest if decodeError := decodeJSONBody(request, &verificationPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } requestedType, typeError := parseVerificationType(verificationPayload.VerificationType) if typeError != nil { WriteError(responseWriter, request, requestLogger, typeError) return } // Ein Wiederherstellungstest liest das gesamte Backup und schreibt es // versuchsweise zurueck. Er belastet Datentraeger und Leitung erheblich und // haengt deshalb an einem eigenen Recht. if requestedType == verification.TypeRestoreTest && !userHasPermission(actingUser, "verification.restore_test") { permissionError := NewValidationError( "Fuer einen Wiederherstellungstest fehlt die Berechtigung verification.restore_test.") permissionError.Code = ErrorCodePermissionDenied permissionError.StatusCode = http.StatusForbidden WriteError(responseWriter, request, requestLogger, permissionError) return } // Das Backup wird vor dem Einreihen aufgeloest: Eine Pruefung eines nicht // vorhandenen Backups liefe erst in der Schleife auf, wo niemand die Antwort // sieht. backupRecord, backupError := handler.jobStore.GetBackup(request.Context(), verificationPayload.BackupID) if backupError != nil { if errors.Is(backupError, jobs.ErrBackupNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Backup wurde nicht gefunden.")) return } WriteError(responseWriter, request, requestLogger, NewInternalError(backupError)) return } createdJob, createError := handler.verificationStore.CreateJob(request.Context(), verificationPayload.BackupID, requestedType, &actingUser.ID) if createError != nil { WriteError(responseWriter, request, requestLogger, translateVerificationError(createError)) return } handler.recordAudit(request, actingUser, audit.ActionVerificationRequested, verificationPayload.BackupID, map[string]any{ "verification_id": createdJob.ID.String(), "verification_type": string(requestedType), "backup_in_repository": backupRecord.BackupIDInRepository, }) WriteSuccess(responseWriter, request, http.StatusAccepted, buildVerificationResponse(createdJob)) } // handleListVerifications bedient GET /verification. func (handler *verificationHandler) handleListVerifications(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1) requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50) loadedJobs, totalCount, listError := handler.verificationStore.ListJobs( request.Context(), requestedPage, requestedPageSize) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } jobResponses := make([]verificationResponse, 0, len(loadedJobs)) for jobIndex := range loadedJobs { jobResponses = append(jobResponses, buildVerificationResponse(&loadedJobs[jobIndex])) } WritePaginatedSuccess(responseWriter, request, jobResponses, PaginationMeta{ Page: requestedPage, PageSize: requestedPageSize, Total: int64(totalCount), }) } // handleGetVerification bedient GET /verification/{id}. func (handler *verificationHandler) handleGetVerification(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) jobIdentifier, parseError := parseVerificationIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } loadedJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateVerificationError(readError)) return } WriteSuccess(responseWriter, request, http.StatusOK, buildVerificationResponse(loadedJob)) } // handleGetVerificationResults bedient GET /verification/{id}/results. // // Getrennt vom Auftrag, weil der vollstaendige Bericht bei einem grossen Backup // viele Befunde traegt. Eine Liste von Auftraegen bliebe damit nicht mehr // ueberschaubar. func (handler *verificationHandler) handleGetVerificationResults(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) jobIdentifier, parseError := parseVerificationIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } loadedJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateVerificationError(readError)) return } if loadedJob.Report == nil { // Kein Bericht heisst: Die Pruefung ist nicht so weit gekommen. Ein leeres // Ergebnis auszugeben liesse das wie ein sauberes Ergebnis aussehen. WriteError(responseWriter, request, requestLogger, NewNotFoundError( "Fuer diese Pruefung liegt kein Bericht vor. Sie laeuft noch oder konnte nicht durchgefuehrt werden.")) return } resultPayload := map[string]any{ "verification_id": loadedJob.ID, "backup_id": loadedJob.BackupID, "verification_type": string(loadedJob.VerificationType), "result": string(loadedJob.Result), "summary": loadedJob.Report.Summary(), "report": loadedJob.Report, } if loadedJob.RestoreTestReport != nil { resultPayload["restore_test"] = loadedJob.RestoreTestReport } WriteSuccess(responseWriter, request, http.StatusOK, resultPayload) } // handleCancelVerification bedient POST /verification/{id}/cancel. func (handler *verificationHandler) handleCancelVerification(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) jobIdentifier, parseError := parseVerificationIdentifier(request) if parseError != nil { WriteError(responseWriter, request, requestLogger, parseError) return } existingJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, translateVerificationError(readError)) return } if cancelError := handler.verificationStore.CancelJob(request.Context(), jobIdentifier); cancelError != nil { WriteError(responseWriter, request, requestLogger, translateVerificationError(cancelError)) return } handler.recordAudit(request, actingUser, audit.ActionVerificationCancelled, existingJob.BackupID, map[string]any{ "verification_id": jobIdentifier.String(), "verification_type": string(existingJob.VerificationType), }) cancelledJob, _ := handler.verificationStore.GetJob(request.Context(), jobIdentifier) // Die abgebrochene Pruefung sagt nichts ueber das Backup. Das wird gesagt, // damit niemand den Abbruch fuer ein Ergebnis haelt. WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{ "verification": buildVerificationResponse(cancelledJob), "message": "Die Pruefung wurde abgebrochen. Sie sagt damit nichts ueber den Zustand des Backups; " + "die bisherige Einstufung bleibt unveraendert.", }) } // handleGetAssurance bedient GET /backups/{id}/assurance. // // Die Bewertung wird bei jedem Aufruf neu berechnet, nicht aus der Datenbank // gelesen: Sie haengt am Alter der Messungen und veraltet damit von selbst. Ein // gespeicherter Wert wuerde mit jedem Tag falscher, ohne dass sich etwas // aendert โ€” genau die stille Beschoenigung, die es hier nicht geben darf. func (handler *verificationHandler) handleGetAssurance(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) backupIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Backupkennung ist keine gueltige UUID.")) return } backupFacts, factsError := handler.verificationStore.BackupAssuranceFacts(request.Context(), backupIdentifier) if factsError != nil { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Backup wurde nicht gefunden.")) return } classification := verification.Classify(backupFacts) assuranceScore := verification.CalculateScore(backupFacts, time.Now()) // Die berechnete Bewertung wird fortgeschrieben, damit eine Uebersicht sie // anzeigen kann, ohne sie fuer jede Zeile neu zu berechnen. Massgeblich // bleibt die Berechnung โ€” der gespeicherte Wert ist nur ihr Abbild. if saveError := handler.verificationStore.SaveAssuranceScore(request.Context(), backupIdentifier, assuranceScore); saveError != nil { requestLogger.Warn("die bewertung konnte nicht gespeichert werden", slog.String("backup", backupIdentifier.String()), slog.String("grund", saveError.Error())) } WriteSuccess(responseWriter, request, http.StatusOK, assuranceResponse{ BackupID: backupIdentifier, Classification: string(classification), ClassificationDescription: classification.Describe(), Percentage: assuranceScore.Percentage, UnknownInputCount: assuranceScore.UnknownInputCount, IsTrustworthy: assuranceScore.IsTrustworthy(), Summary: assuranceScore.Summary(), MissingMeasurements: assuranceScore.MissingMeasurements(), Inputs: assuranceScore.Inputs, LastVerifiedAt: backupFacts.LastVerifiedAt, LastRestoreTestAt: backupFacts.LastRestoreTestAt, }) } // recordAudit schreibt ein Auditereignis. func (handler *verificationHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, backupIdentifier 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", EntityID: &backupIdentifier, 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("grund", recordError.Error())) } } // buildVerificationResponse wandelt einen Auftrag in seine Darstellung. func buildVerificationResponse(sourceJob *verification.Job) verificationResponse { builtResponse := verificationResponse{ ID: sourceJob.ID, BackupID: sourceJob.BackupID, VerificationType: string(sourceJob.VerificationType), Status: string(sourceJob.Status), Result: string(sourceJob.Result), ChunksChecked: sourceJob.ChunksChecked, ChunksMissing: sourceJob.ChunksMissing, ChunksCorrupted: sourceJob.ChunksCorrupted, BytesRead: sourceJob.BytesRead, StartedAt: sourceJob.StartedAt, CompletedAt: sourceJob.CompletedAt, ErrorMessage: sourceJob.ErrorMessage, CorrelationID: sourceJob.CorrelationID, CreatedAt: sourceJob.CreatedAt, } if sourceJob.Report != nil { builtResponse.Summary = sourceJob.Report.Summary() } if sourceJob.StartedAt != nil && sourceJob.CompletedAt != nil { builtResponse.DurationSeconds = sourceJob.CompletedAt.Sub(*sourceJob.StartedAt).Seconds() } return builtResponse } // parseVerificationType prueft die angeforderte Pruefart. func parseVerificationType(requestedType string) (verification.VerificationType, *APIError) { switch verification.VerificationType(requestedType) { case verification.TypeManifest, verification.TypeChunkPresence, verification.TypeChunkIntegrity, verification.TypeChain, verification.TypeRestoreTest: return verification.VerificationType(requestedType), nil case "": // Ohne Angabe wird die Blockpruefung gewaehlt: Sie ist die schwaechste // Pruefung, die ueberhaupt etwas ueber die Daten aussagt. Die // Manifestpruefung als Vorgabe waere bequem und wertlos. return verification.TypeChunkIntegrity, nil default: return "", NewValidationError( "Die Pruefart ist unbekannt. Zulaessig sind manifest, chunk_presence, " + "chunk_integrity, chain und restore_test.") } } // parseVerificationIdentifier liest die Auftragskennung aus dem Pfad. func parseVerificationIdentifier(request *http.Request) (uuid.UUID, *APIError) { jobIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { return uuid.Nil, NewBadRequestError("Die Pruefkennung ist keine gueltige UUID.") } return jobIdentifier, nil } // translateVerificationError bildet Fehler der Fachschicht auf API-Fehler ab. func translateVerificationError(occurredError error) *APIError { switch { case errors.Is(occurredError, verification.ErrJobNotFound): return NewNotFoundError("Der Pruefauftrag wurde nicht gefunden.") case errors.Is(occurredError, verification.ErrBackupBusy): // 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, das Backup // wird nur bereits geprueft. conflictError := NewValidationError( "Dieses Backup wird bereits geprueft. Warten Sie das Ergebnis ab.") conflictError.Code = ErrorCodeConflict conflictError.StatusCode = http.StatusConflict return conflictError default: return NewInternalError(occurredError) } }