package httpapi import ( "errors" "net/http" "os" "path" "path/filepath" "sort" "strings" "github.com/google/uuid" "github.com/syncova/syncova/packages/platform/logging" "github.com/syncova/syncova/packages/recovery" "github.com/syncova/syncova/packages/repository" ) // browseHandler bedient die beiden Blätterendpunkte. // // Sie sind für eine Wiederherstellung gebaut und lösen zwei Probleme, die sich // mit einem Textfeld nicht lösen lassen: // // 1. **Wohin darf zurückgeschrieben werden?** Der Dienst läuft mit // `ProtectSystem=strict`; außerhalb weniger Pfade ist das Dateisystem für // ihn schreibgeschützt. Ein Betreiber tippt „/opt/test", bekommt // „permission denied" und hat keine Möglichkeit zu erkennen, welcher Ort // überhaupt in Frage kommt. `GET /filesystem/browse` beantwortet genau das // — es meldet je Verzeichnis, ob der **Dienst** dort schreiben kann, // geprüft durch einen tatsächlichen Schreibversuch. // // 2. **Was steckt in dem Backup?** Ohne Inhaltsverzeichnis lässt sich weder // eine einzelne Datei noch ein Unterordner gezielt zurückholen. `GET // /backups/{id}/contents` liefert das Manifest als Ebene eines Baums. // // Beide lesen nur. type browseHandler struct { restoreHandlerReference *restoreHandler targetGuard *recovery.TargetGuard } // filesystemEntry ist ein Eintrag des Dateisystems. type filesystemEntry struct { // Name ist der letzte Pfadbestandteil. Name string `json:"name"` // Path ist der vollständige absolute Pfad. Path string `json:"path"` // IsDirectory unterscheidet Verzeichnis von Datei. IsDirectory bool `json:"is_directory"` // SizeBytes ist die Größe bei Dateien. SizeBytes int64 `json:"size_bytes,omitempty"` // IsWritable meldet, ob der Dienst hier anlegen darf. // // Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten: // Unter `ProtectSystem=strict` sagen die Bits nichts über das aus, was der // Namensraum zulässt. IsWritable bool `json:"is_writable"` // ForbiddenReason nennt den Grund, wenn der Zielschutz den Ort ausschließt. ForbiddenReason string `json:"forbidden_reason,omitempty"` } // browseFilesystemResponse ist die Antwort auf das Blättern im Dateisystem. type browseFilesystemResponse struct { // Path ist das aufgelistete Verzeichnis. Path string `json:"path"` // ParentPath ist das übergeordnete Verzeichnis; leer bei der Wurzel. ParentPath string `json:"parent_path,omitempty"` // Entries sind die enthaltenen Verzeichnisse. Entries []filesystemEntry `json:"entries"` // SuggestedPaths sind Orte, an denen der Dienst nachweislich schreiben darf. // // Sie stehen in der Antwort, damit die Oberfläche einen brauchbaren // Startpunkt anbieten kann, statt den Betreiber suchen zu lassen. SuggestedPaths []string `json:"suggested_paths,omitempty"` } // backupContentEntry ist ein Eintrag im Inhaltsverzeichnis eines Backups. type backupContentEntry struct { Name string `json:"name"` // Path ist der Pfad im Manifest — genau der Wert, den eine // Wiederherstellung als `path_prefix` erwartet. Path string `json:"path"` IsDirectory bool `json:"is_directory"` EntryType string `json:"entry_type"` SizeBytes int64 `json:"size_bytes,omitempty"` ModifiedAt string `json:"modified_at,omitempty"` Mode string `json:"mode,omitempty"` // ChildCount ist die Zahl der Einträge unterhalb eines Verzeichnisses. ChildCount int `json:"child_count,omitempty"` // TotalBytes ist die Datenmenge unterhalb eines Verzeichnisses. TotalBytes int64 `json:"total_bytes,omitempty"` } // browseBackupResponse ist die Antwort auf das Blättern im Backup. type browseBackupResponse struct { BackupID string `json:"backup_id"` Path string `json:"path"` ParentPath string `json:"parent_path,omitempty"` // Entries sind die Einträge auf dieser Ebene. Entries []backupContentEntry `json:"entries"` // TotalEntryCount ist die Zahl aller Einträge im Backup. TotalEntryCount int `json:"total_entry_count"` } // handleBrowseFilesystem bedient GET /filesystem/browse. func (handler *browseHandler) handleBrowseFilesystem(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger) requestedPath := strings.TrimSpace(request.URL.Query().Get("path")) if requestedPath == "" { requestedPath = "/" } if !filepath.IsAbs(requestedPath) { WriteError(responseWriter, request, requestLogger, NewValidationError("Der Pfad muss absolut sein.")) return } // Symlinks werden aufgelöst, bevor gelesen wird: Sonst ließe sich über // einen Verweis an jeder Prüfung vorbei in ein fremdes Verzeichnis sehen. resolvedPath, resolveError := filepath.EvalSymlinks(filepath.Clean(requestedPath)) if resolveError != nil { resolvedPath = filepath.Clean(requestedPath) } directoryEntries, readError := os.ReadDir(resolvedPath) if readError != nil { if errors.Is(readError, os.ErrNotExist) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Verzeichnis wurde nicht gefunden.")) return } WriteError(responseWriter, request, requestLogger, NewValidationError("Das Verzeichnis lässt sich nicht lesen: "+readError.Error())) return } entries := make([]filesystemEntry, 0, len(directoryEntries)) for _, directoryEntry := range directoryEntries { // Nur Verzeichnisse: Ein Wiederherstellungsziel ist immer ein // Verzeichnis, und die Dateien daneben wären nur Rauschen. if !directoryEntry.IsDir() { continue } // Versteckte Verzeichnisse bleiben draußen. Wer eines braucht, tippt // den Pfad — die Liste soll den Normalfall zeigen. if strings.HasPrefix(directoryEntry.Name(), ".") { continue } childPath := filepath.Join(resolvedPath, directoryEntry.Name()) entry := filesystemEntry{ Name: directoryEntry.Name(), Path: childPath, IsDirectory: true, IsWritable: directoryIsWritable(childPath), } if handler.targetGuard != nil { if guardError := handler.targetGuard.Validate(childPath); guardError != nil { entry.ForbiddenReason = guardError.Error() // Ein gesperrter Ort ist nie ein zulässiges Ziel, auch wenn das // Dateisystem ihn zuließe. entry.IsWritable = false } } entries = append(entries, entry) } sort.Slice(entries, func(firstIndex, secondIndex int) bool { return entries[firstIndex].Name < entries[secondIndex].Name }) response := browseFilesystemResponse{ Path: resolvedPath, Entries: entries, SuggestedPaths: writableSuggestions(handler.targetGuard), } if resolvedPath != "/" { response.ParentPath = filepath.Dir(resolvedPath) } WriteSuccess(responseWriter, request, http.StatusOK, response) } // handleBrowseBackupContents bedient GET /backups/{id}/contents. func (handler *browseHandler) handleBrowseBackupContents(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.restoreHandlerReference.logger) backupIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewValidationError("Die Backup-Kennung ist keine gültige UUID.")) return } repositoryPath, backupIDInRepository, resolveError := handler.restoreHandlerReference.resolveBackup( request.Context(), backupIdentifier) if resolveError != nil { WriteError(responseWriter, request, requestLogger, resolveError) return } // Schreibgeschützt: Ein Inhaltsverzeichnis liest nur und soll neben einer // laufenden Sicherung erstellt werden können. openedRepository, openError := repository.Open(request.Context(), repositoryPath, repository.OpenOptions{ReadOnly: true}, handler.restoreHandlerReference.logger) if openError != nil { WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError("Das Repository des Backups ist derzeit nicht erreichbar.")) return } defer func() { _ = openedRepository.Close() }() backupManifest, manifestError := openedRepository.ReadManifest(request.Context(), backupIDInRepository) if manifestError != nil { WriteError(responseWriter, request, requestLogger, NewValidationError("Das Manifest des Backups lässt sich nicht lesen: "+manifestError.Error())) return } currentPath := strings.Trim(strings.TrimSpace(request.URL.Query().Get("path")), "/") response := browseBackupResponse{ BackupID: backupIdentifier.String(), Path: currentPath, Entries: collectLevel(backupManifest.Entries, currentPath), TotalEntryCount: len(backupManifest.Entries), } if currentPath != "" { parentPath := path.Dir(currentPath) if parentPath == "." { parentPath = "" } response.ParentPath = parentPath } WriteSuccess(responseWriter, request, http.StatusOK, response) } // collectLevel bildet aus einem flachen Manifest eine Verzeichnisebene. // // Das Manifest kennt keine Baumstruktur, sondern eine flache Liste von Pfaden. // Der Baum entsteht hier — und zwar **ohne** sich auf Verzeichniseinträge zu // verlassen: Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis // nicht als eigener Eintrag vorliegt. Wer nur die Einträge vom Typ `directory` // auflistet, verliert dann ganze Teilbäume. func collectLevel(manifestEntries []repository.ManifestEntry, currentPath string) []backupContentEntry { prefix := "" if currentPath != "" { prefix = currentPath + "/" } // Verzeichnisse werden über ihre Kinder erkannt und dabei gleich // aufsummiert: Ein Betreiber will vor dem Zurückholen wissen, wie viel an // einem Ordner hängt. directories := make(map[string]*backupContentEntry) files := make([]backupContentEntry, 0, 32) for _, manifestEntry := range manifestEntries { entryPath := strings.Trim(manifestEntry.Path, "/") if prefix != "" && !strings.HasPrefix(entryPath, prefix) { continue } remainder := strings.TrimPrefix(entryPath, prefix) if remainder == "" { continue } separatorIndex := strings.Index(remainder, "/") if separatorIndex < 0 { // Direktes Kind dieser Ebene. if manifestEntry.EntryType == "directory" { directoryPath := prefix + remainder if _, exists := directories[remainder]; !exists { directories[remainder] = &backupContentEntry{ Name: remainder, Path: directoryPath, IsDirectory: true, EntryType: "directory", Mode: manifestEntry.Mode, } } continue } files = append(files, backupContentEntry{ Name: remainder, Path: entryPath, IsDirectory: false, EntryType: manifestEntry.EntryType, SizeBytes: manifestEntry.SizeBytes, ModifiedAt: formatOptionalTime(manifestEntry), Mode: manifestEntry.Mode, }) continue } // Ein Nachfahre: Er belegt, dass es das Verzeichnis gibt, auch wenn // kein eigener Eintrag dafür existiert. directoryName := remainder[:separatorIndex] existing, exists := directories[directoryName] if !exists { existing = &backupContentEntry{ Name: directoryName, Path: prefix + directoryName, IsDirectory: true, EntryType: "directory", } directories[directoryName] = existing } existing.ChildCount++ existing.TotalBytes += manifestEntry.SizeBytes } entries := make([]backupContentEntry, 0, len(directories)+len(files)) for _, directoryEntry := range directories { entries = append(entries, *directoryEntry) } entries = append(entries, files...) // Verzeichnisse zuerst, dann alphabetisch — die Reihenfolge, die jeder // Dateimanager verwendet. sort.Slice(entries, func(firstIndex, secondIndex int) bool { if entries[firstIndex].IsDirectory != entries[secondIndex].IsDirectory { return entries[firstIndex].IsDirectory } return entries[firstIndex].Name < entries[secondIndex].Name }) return entries } // formatOptionalTime gibt einen Zeitstempel aus, sofern gesetzt. func formatOptionalTime(manifestEntry repository.ManifestEntry) string { if manifestEntry.ModifiedAt.IsZero() { return "" } return manifestEntry.ModifiedAt.UTC().Format("2006-01-02T15:04:05Z") } // directoryIsWritable prüft durch einen echten Schreibversuch. // // Die Rechtebits zu lesen genügt nicht: Unter `ProtectSystem=strict` ist das // Dateisystem für den Dienst außerhalb weniger Pfade schreibgeschützt, und // davon steht nichts im Modus. Genau diese Lücke hat dazu geführt, dass eine // Wiederherstellung nach `/opt/test` mit „permission denied" endete, obwohl // das Verzeichnis dem Anschein nach beschreibbar war. func directoryIsWritable(directoryPath string) bool { probeFile, createError := os.CreateTemp(directoryPath, ".syncova-schreibprobe-*") if createError != nil { return false } probeName := probeFile.Name() _ = probeFile.Close() _ = os.Remove(probeName) return true } // writableSuggestions nennt Orte, an denen der Dienst nachweislich schreiben darf. // // Ohne diese Liste sucht ein Betreiber im Blindflug: Die meisten Verzeichnisse // eines gehärteten Systems scheiden aus, und welche übrig bleiben, hängt an der // systemd-Einheit — nicht an etwas, das man dem Dateisystem ansieht. func writableSuggestions(targetGuard *recovery.TargetGuard) []string { // Reihenfolge ist Absicht: Zuerst die Flaeche, die `setup.sh` anlegt und // in ReadWritePaths eintraegt. `/var/lib` steht bewusst nicht dabei — es // ist im Zielschutz gesperrt. candidates := []string{ "/srv/syncova-restore", "/srv", "/var/tmp", "/home", } suggestions := make([]string, 0, len(candidates)) for _, candidate := range candidates { if targetGuard != nil { if guardError := targetGuard.Validate(candidate); guardError != nil { continue } } if directoryIsWritable(candidate) { suggestions = append(suggestions, candidate) } } return suggestions }