package httpapi import ( "context" "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/repository" ) // repositoryHandler bedient die Repository-Endpunkte (SYNCOVA_API.md §8). // // Bis Phase 22 gab es hier nur eine Liste: Repositories entstanden über // `syncova-repo create` und wurden **von Hand in die Datenbank eingetragen**. // Das fiel erst beim vollständigen Durchlauf auf — die Anlage ließ sich über // ihre eigene API nicht in Betrieb nehmen. type repositoryHandler struct { // store ist die Datenzugriffsschicht. store *jobs.PostgresStore // auditRecorder protokolliert die verändernden Zugriffe. auditRecorder audit.Recorder // logger protokolliert technische Fehler. logger *slog.Logger } // registerRepositoryRequest ist der Rumpf von POST /repositories. type registerRepositoryRequest struct { // Name ist die sprechende Bezeichnung. Name string `json:"name"` // Location ist der Pfad der Ablage. Location string `json:"location"` // RepositoryType benennt die Ablageart; leer bedeutet "local". RepositoryType string `json:"repository_type,omitempty"` } // updateRepositoryRequest ist der Rumpf von PATCH /repositories/{id}. type updateRepositoryRequest struct { // Status ist der neue Betriebszustand. Status string `json:"status"` } // repositoryCheckResponse ist die Antwort der Prüfendpunkte. type repositoryCheckResponse struct { // RepositoryID ist das geprüfte Repository. RepositoryID uuid.UUID `json:"repository_id"` // Reachable meldet, ob das Repository geöffnet werden konnte. Reachable bool `json:"reachable"` // RepositoryUUID ist die im Repository hinterlegte Kennung. RepositoryUUID string `json:"repository_uuid,omitempty"` // Error ist der Grund eines Fehlschlags. Error string `json:"error,omitempty"` // Details tragen das Ergebnis der jeweiligen Prüfung. Details map[string]any `json:"details,omitempty"` } // handleRegisterRepository bedient POST /repositories. // // **Es wird nichts angelegt, sondern übernommen.** Ein Repository entsteht auf // einem Datenträger — mit `syncova-repo create`, das Descriptor und // Verzeichnisse schreibt und den gehärteten Modus setzt. Dieser Endpunkt öffnet // das vorhandene Repository, liest seine Kennung aus dem Descriptor und trägt // es ein. // // Ein Eintrag ohne Repository dahinter wäre ein Ziel, das erst um zwei Uhr // nachts als nicht vorhanden auffällt. func (handler *repositoryHandler) handleRegisterRepository(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var registerPayload registerRepositoryRequest if decodeError := decodeJSONBody(request, ®isterPayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } if registerPayload.Name == "" || registerPayload.Location == "" { WriteError(responseWriter, request, requestLogger, NewValidationError("Name und Ort des Repositorys sind erforderlich.")) return } repositoryType := registerPayload.RepositoryType if repositoryType == "" { repositoryType = "local" } // Erst nachsehen, dann eintragen. Der Descriptor sagt, ob dort überhaupt // ein Repository liegt und welche Kennung es trägt. inspectedRepository, inspectError := openRepositoryForInspection(request.Context(), registerPayload.Location, handler.logger) if inspectError != nil { WriteError(responseWriter, request, requestLogger, NewValidationError( "Unter "+registerPayload.Location+" liegt kein lesbares Repository: "+inspectError.Error()+ ". Legen Sie es zuerst mit 'syncova-repo create' an.")) return } repositoryDescriptor := inspectedRepository.Descriptor() if closeError := inspectedRepository.Close(); closeError != nil { requestLogger.Warn("das geprüfte repository liess sich nicht schliessen", slog.String("grund", closeError.Error())) } registeredRepository, registerError := handler.store.RegisterRepository(request.Context(), jobs.RepositoryRegistration{ Name: registerPayload.Name, RepositoryType: repositoryType, Location: registerPayload.Location, Status: jobs.RepositoryStatusActive, Hardened: repositoryDescriptor.Immutable, RepositoryUUID: repositoryDescriptor.RepositoryID, }) if errors.Is(registerError, jobs.ErrRepositoryNameTaken) || errors.Is(registerError, jobs.ErrRepositoryLocationTaken) { WriteError(responseWriter, request, requestLogger, &APIError{ StatusCode: http.StatusConflict, Code: ErrorCodeConflict, Message: registerError.Error(), }) return } if registerError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(registerError)) return } handler.recordAudit(request, actingUser, audit.ActionRepositoryRegistered, registeredRepository.ID, map[string]any{ "name": registeredRepository.Name, "location": registeredRepository.Location, "hardened": registeredRepository.Hardened, }) WriteSuccess(responseWriter, request, http.StatusCreated, buildRepositoryResponse(*registeredRepository)) } // handleGetRepository bedient GET /repositories/{id}. func (handler *repositoryHandler) handleGetRepository(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Repositorys ist ungültig.")) return } foundRepository, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier) if errors.Is(readError, jobs.ErrRepositoryNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden.")) return } if readError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(readError)) return } WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*foundRepository)) } // handleUpdateRepository bedient PATCH /repositories/{id}. // // Änderbar ist ausschließlich der Betriebszustand. Ort und Name gehören zum // Repository selbst; sie hier zu ändern hieße, den Eintrag von der Ablage zu // lösen, auf die er zeigt. func (handler *repositoryHandler) handleUpdateRepository(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Repositorys ist ungültig.")) return } var updatePayload updateRepositoryRequest if decodeError := decodeJSONBody(request, &updatePayload); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } newStatus := jobs.RepositoryStatus(updatePayload.Status) if !isKnownRepositoryStatus(newStatus) { WriteError(responseWriter, request, requestLogger, NewValidationError( "Zulässige Zustände sind active, read_only, unavailable und maintenance.")) return } updatedRepository, updateError := handler.store.UpdateRepositoryStatus(request.Context(), repositoryIdentifier, newStatus) if errors.Is(updateError, jobs.ErrRepositoryNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden.")) return } if updateError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(updateError)) return } // Ein Ziel aus dem Betrieb zu nehmen, hält Sicherungen an. Das gehört ins // Protokoll: Sonst sucht später jemand den Grund für ausbleibende Backups. handler.recordAudit(request, actingUser, audit.ActionRepositoryStatusChanged, updatedRepository.ID, map[string]any{"status": string(newStatus)}) WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*updatedRepository)) } // handleTestRepository bedient POST /repositories/{id}/test. // // Die schnelle Prüfung: Lässt sich das Repository öffnen und ist es dasselbe // wie beim Eintragen? Ein Ziel, dessen Kennung sich geändert hat, ist ein // anderes Repository am selben Pfad — und die dort vermerkten Backups sind // nicht die, die man sucht. func (handler *repositoryHandler) handleTestRepository(responseWriter http.ResponseWriter, request *http.Request) { handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context, openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error) { repositoryDescriptor := openedRepository.Descriptor() checkDetails := map[string]any{ "repository_uuid": repositoryDescriptor.RepositoryID, "format_version": repositoryDescriptor.FormatVersion, "hardened": repositoryDescriptor.Immutable, } if storedRecord.RepositoryUUID != "" && storedRecord.RepositoryUUID != repositoryDescriptor.RepositoryID { checkDetails["identity_mismatch"] = true return checkDetails, errors.New("das repository unter diesem pfad ist nicht mehr dasselbe " + "(erwartet " + storedRecord.RepositoryUUID + ", vorgefunden " + repositoryDescriptor.RepositoryID + ")") } return checkDetails, nil }) } // handleRepositoryHealth bedient POST /repositories/{id}/health-check. func (handler *repositoryHandler) handleRepositoryHealth(responseWriter http.ResponseWriter, request *http.Request) { handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context, openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) { healthReport, healthError := openedRepository.Health(checkContext) if healthError != nil { return nil, healthError } return map[string]any{ "status": string(healthReport.Status), "message": healthReport.Message, "recommended_action": healthReport.RecommendedAction, "backup_count": healthReport.BackupCount, "capacity_bytes": healthReport.CapacityBytes, "used_bytes": healthReport.UsedBytes, "free_bytes": healthReport.FreeBytes, "used_percentage": healthReport.UsedPercentage(), "latency_ms": healthReport.LatencyMilliseconds, }, nil }) } // handleRepositoryIntegrityScan bedient POST /repositories/{id}/integrity-scan. // // Der vollständige Lauf liest jeden Block und prüft ihn gegen seine Prüfsumme. // Er läuft synchron: Ein Endpunkt, der sofort „gestartet" meldet und das // Ergebnis nirgends hinterlegt, wäre ein Prüfwerkzeug ohne Prüfergebnis. func (handler *repositoryHandler) handleRepositoryIntegrityScan(responseWriter http.ResponseWriter, request *http.Request) { deepScan := request.URL.Query().Get("deep") != "false" handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context, openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) { scanReport, scanError := openedRepository.Scan(checkContext, repository.ScanOptions{ VerifyChunkContents: deepScan, }) if scanError != nil { return nil, scanError } scanDetails := map[string]any{ "verified_chunk_contents": scanReport.VerifiedChunkContents, "backups_checked": scanReport.BackupsChecked, "backups_healthy": scanReport.BackupsHealthy, "chunks_checked": scanReport.ChunksChecked, "missing_chunks": scanReport.MissingChunks, "corrupted_chunks": scanReport.CorruptedChunks, "orphaned_chunks": scanReport.OrphanedChunks, "healthy": scanReport.IsHealthy(), "summary": scanReport.Summary(), "affected_backup_ids": scanReport.AffectedBackupIDs, } // Ein Befund ist **kein** Fehler des Endpunkts: Die Prüfung ist // ordnungsgemäß gelaufen und hat ein Ergebnis. Sie als Fehler zu melden // verwechselte „die Prüfung schlug fehl" mit „das Repository ist // beschädigt" — zwei völlig verschiedene Lagen. return scanDetails, nil }) } // handleRebuildCatalog bedient POST /repositories/{id}/rebuild-catalog. // // Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Genau // deshalb lässt er sich jederzeit neu bauen — und genau deshalb ist der // Wiederaufbau harmlos. func (handler *repositoryHandler) handleRebuildCatalog(responseWriter http.ResponseWriter, request *http.Request) { handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context, openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) { rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(checkContext) if rebuildError != nil { return nil, rebuildError } return map[string]any{"backups_in_catalog": len(rebuiltCatalog.Entries)}, nil }) } // repositoryCheckFunction führt eine Prüfung auf einem geöffneten Repository aus. type repositoryCheckFunction func(checkContext context.Context, openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error) // runRepositoryCheck öffnet das Repository und führt eine Prüfung aus. // // Der gemeinsame Rahmen aller vier Prüfendpunkte: Kennung lesen, Datensatz // holen, Repository öffnen, prüfen, schließen. Ohne ihn stünde derselbe Ablauf // viermal da, und beim vierten vergisst jemand das Schließen. func (handler *repositoryHandler) runRepositoryCheck(responseWriter http.ResponseWriter, request *http.Request, checkFunction repositoryCheckFunction) { requestLogger := logging.WithContext(request.Context(), handler.logger) repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Repositorys ist ungültig.")) return } storedRecord, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier) if errors.Is(readError, jobs.ErrRepositoryNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden.")) return } if readError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(readError)) return } openedRepository, openError := openRepositoryForInspection(request.Context(), storedRecord.Location, handler.logger) if openError != nil { // Ein nicht erreichbares Repository ist eine Auskunft über die Anlage, // kein Serverfehler. Die Antwort trägt sie in der Hülle, damit die // Oberfläche sie anzeigen kann. WriteSuccess(responseWriter, request, http.StatusOK, repositoryCheckResponse{ RepositoryID: repositoryIdentifier, Reachable: false, Error: openError.Error(), }) return } defer func() { if closeError := openedRepository.Close(); closeError != nil { requestLogger.Warn("das repository liess sich nicht schliessen", slog.String("grund", closeError.Error())) } }() checkDetails, checkError := checkFunction(request.Context(), openedRepository, storedRecord) checkResponse := repositoryCheckResponse{ RepositoryID: repositoryIdentifier, Reachable: checkError == nil, RepositoryUUID: openedRepository.Descriptor().RepositoryID, Details: checkDetails, } if checkError != nil { checkResponse.Error = checkError.Error() } WriteSuccess(responseWriter, request, http.StatusOK, checkResponse) } // openRepositoryForInspection öffnet ein Repository schreibgeschützt. // // Schreibgeschützt, weil jede Prüfung nur liest — und weil ein Prüfaufruf sonst // die Schreibsperre einer laufenden Sicherung bräuchte und daran wartete // (dieselbe Entscheidung wie bei der Wiederherstellung in Phase 9). func openRepositoryForInspection(openContext context.Context, repositoryLocation string, baseLogger *slog.Logger) (*repository.LocalRepository, error) { inspectionContext, cancelInspection := context.WithTimeout(openContext, 30*time.Second) defer cancelInspection() return repository.Open(inspectionContext, repositoryLocation, repository.OpenOptions{ReadOnly: true}, baseLogger) } // isKnownRepositoryStatus prüft einen Zustandswert. func isKnownRepositoryStatus(candidateStatus jobs.RepositoryStatus) bool { switch candidateStatus { case jobs.RepositoryStatusActive, jobs.RepositoryStatusReadOnly, jobs.RepositoryStatusUnavailable, jobs.RepositoryStatusMaintenance: return true default: return false } } // buildRepositoryResponse formt die Antwort eines Repositorys. func buildRepositoryResponse(storedRepository jobs.Repository) repositoryResponse { return repositoryResponse{ ID: storedRepository.ID, Name: storedRepository.Name, RepositoryType: storedRepository.RepositoryType, Location: storedRepository.Location, Status: string(storedRepository.Status), AcceptsBackups: storedRepository.Status.AcceptsWrites(), Hardened: storedRepository.Hardened, CreatedAt: storedRepository.CreatedAt, } } // recordAudit schreibt einen Eintrag ins Auditprotokoll. func (handler *repositoryHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, entityIdentifier uuid.UUID, auditDetails map[string]any) { if handler.auditRecorder == nil { return } correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context()) recordError := handler.auditRecorder.Record(request.Context(), audit.Event{ UserID: &actingUser.ID, ActorUsername: actingUser.Username, Action: auditAction, EntityType: "repository", EntityID: &entityIdentifier, Result: audit.ResultSuccess, IPAddress: clientIPAddress(request), UserAgent: request.UserAgent(), CorrelationID: correlationIdentifier, Details: auditDetails, }) if recordError != nil { logging.WithContext(request.Context(), handler.logger).Error( "der audit-eintrag liess sich nicht schreiben", slog.String("aktion", string(auditAction)), slog.String("grund", recordError.Error())) } }