diff --git a/apps/api/internal/httpapi/browse_handler.go b/apps/api/internal/httpapi/browse_handler.go new file mode 100644 index 0000000..70281c0 --- /dev/null +++ b/apps/api/internal/httpapi/browse_handler.go @@ -0,0 +1,422 @@ +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 +} diff --git a/apps/api/internal/httpapi/browse_handler_test.go b/apps/api/internal/httpapi/browse_handler_test.go new file mode 100644 index 0000000..21b3489 --- /dev/null +++ b/apps/api/internal/httpapi/browse_handler_test.go @@ -0,0 +1,90 @@ +package httpapi + +import ( + "testing" + "time" + + "github.com/syncova/syncova/packages/repository" +) + +// TestCollectLevelBuildsTreeWithoutDirectoryEntries haelt fest, dass der Baum +// aus den Pfaden entsteht und nicht aus Verzeichniseintraegen. +// +// Ein Manifest kann eine Datei enthalten, deren Elternverzeichnis nicht als +// eigener Eintrag vorliegt — etwa bei einer Quelle, die nur Dateien meldet. Wer +// nur die Eintraege vom Typ "directory" auflistet, verliert dann ganze +// Teilbaeume, und die Datei ist ueber die Oberflaeche nicht mehr erreichbar. +func TestCollectLevelBuildsTreeWithoutDirectoryEntries(testInstance *testing.T) { + manifestEntries := []repository.ManifestEntry{ + // Kein Eintrag fuer "berichte" selbst. + {Path: "berichte/2026/jahr.pdf", EntryType: "file", SizeBytes: 900}, + {Path: "berichte/2025/jahr.pdf", EntryType: "file", SizeBytes: 100}, + {Path: "notiz.txt", EntryType: "file", SizeBytes: 6}, + } + + rootLevel := collectLevel(manifestEntries, "") + + if len(rootLevel) != 2 { + testInstance.Fatalf("erwartet 2 Eintraege auf der Wurzel, erhalten %d", len(rootLevel)) + } + + // Verzeichnisse stehen vorn. + if !rootLevel[0].IsDirectory || rootLevel[0].Name != "berichte" { + testInstance.Errorf("das Verzeichnis berichte fehlt oder steht nicht vorn: %+v", rootLevel[0]) + } + + // Die Kennzahlen summieren den ganzen Teilbaum: Ein Betreiber will vor dem + // Zurueckholen wissen, wie viel an einem Ordner haengt. + if rootLevel[0].ChildCount != 2 || rootLevel[0].TotalBytes != 1000 { + testInstance.Errorf("Kennzahlen des Ordners falsch: %d Objekte, %d Byte", + rootLevel[0].ChildCount, rootLevel[0].TotalBytes) + } + + // Und eine Ebene tiefer erscheinen die Jahresordner. + deeperLevel := collectLevel(manifestEntries, "berichte") + + if len(deeperLevel) != 2 { + testInstance.Fatalf("erwartet 2 Jahresordner, erhalten %d", len(deeperLevel)) + } +} + +// TestCollectLevelRespectsDirectoryBoundary haelt die Verzeichnisgrenze fest. +// +// "dokumente" darf nicht auch "dokumentation" treffen — sonst holte eine +// Wiederherstellung Daten zurueck, die niemand ausgewaehlt hat. +func TestCollectLevelRespectsDirectoryBoundary(testInstance *testing.T) { + manifestEntries := []repository.ManifestEntry{ + {Path: "dokumente/a.txt", EntryType: "file", SizeBytes: 1}, + {Path: "dokumentation/b.txt", EntryType: "file", SizeBytes: 1}, + } + + level := collectLevel(manifestEntries, "dokumente") + + if len(level) != 1 || level[0].Name != "a.txt" { + testInstance.Errorf("die Verzeichnisgrenze wird nicht beachtet: %+v", level) + } +} + +// TestCollectLevelKeepsFileMetadata prueft die Angaben je Datei. +func TestCollectLevelKeepsFileMetadata(testInstance *testing.T) { + modificationTime := time.Date(2026, 8, 18, 10, 0, 0, 0, time.UTC) + + level := collectLevel([]repository.ManifestEntry{ + {Path: "notiz.txt", EntryType: "file", SizeBytes: 42, Mode: "0644", ModifiedAt: modificationTime}, + }, "") + + if len(level) != 1 { + testInstance.Fatalf("erwartet einen Eintrag, erhalten %d", len(level)) + } + + // Der Pfad ist genau der Wert, den eine Wiederherstellung als + // `path_prefix` erwartet — eine Abweichung faellt sonst erst beim + // Zurueckschreiben auf. + if level[0].Path != "notiz.txt" || level[0].SizeBytes != 42 || level[0].Mode != "0644" { + testInstance.Errorf("Angaben der Datei unvollstaendig: %+v", level[0]) + } + + if level[0].ModifiedAt != "2026-08-18T10:00:00Z" { + testInstance.Errorf("Zeitstempel falsch: %q", level[0].ModifiedAt) + } +} diff --git a/apps/api/internal/httpapi/contract_routes.txt b/apps/api/internal/httpapi/contract_routes.txt index 3136023..65327d0 100644 --- a/apps/api/internal/httpapi/contract_routes.txt +++ b/apps/api/internal/httpapi/contract_routes.txt @@ -122,3 +122,5 @@ POST /api/v1/users users.write POST /api/v1/users/{id}/mfa/disable users.write POST /api/v1/verification verification.write POST /api/v1/verification/{id}/cancel verification.write +GET /api/v1/filesystem/browse restores.read +GET /api/v1/backups/{id}/contents restores.read diff --git a/apps/api/internal/httpapi/router.go b/apps/api/internal/httpapi/router.go index d88a189..d1a31b1 100644 --- a/apps/api/internal/httpapi/router.go +++ b/apps/api/internal/httpapi/router.go @@ -377,6 +377,19 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore)) requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore)) requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore)) + + // Blättern in Dateisystem und Backup. + // + // Beide gehören zur Wiederherstellung und tragen deshalb deren Leserecht: + // Wer eine Wiederherstellung vorbereiten darf, muss sehen können, was im + // Backup steckt und wohin sich zurückschreiben lässt. + browseHandlerInstance := &browseHandler{ + restoreHandlerReference: restoreHandlerInstance, + targetGuard: restoreHandlerInstance.targetGuard, + } + + requestMultiplexer.Handle("GET "+apiBasePath+"/filesystem/browse", protected("restores.read", browseHandlerInstance.handleBrowseFilesystem)) + requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/contents", protected("restores.read", browseHandlerInstance.handleBrowseBackupContents)) } // registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14). diff --git a/apps/web/src/assets/fonts/GeistMono-Medium.woff2 b/apps/web/src/assets/fonts/GeistMono-Medium.woff2 new file mode 100644 index 0000000..ffd43c1 Binary files /dev/null and b/apps/web/src/assets/fonts/GeistMono-Medium.woff2 differ diff --git a/apps/web/src/assets/fonts/GeistMono-Regular.woff2 b/apps/web/src/assets/fonts/GeistMono-Regular.woff2 new file mode 100644 index 0000000..e62220f Binary files /dev/null and b/apps/web/src/assets/fonts/GeistMono-Regular.woff2 differ diff --git a/apps/web/src/assets/fonts/GeistMono-SemiBold.woff2 b/apps/web/src/assets/fonts/GeistMono-SemiBold.woff2 new file mode 100644 index 0000000..9447e1c Binary files /dev/null and b/apps/web/src/assets/fonts/GeistMono-SemiBold.woff2 differ diff --git a/apps/web/src/assets/fonts/LICENSE.txt b/apps/web/src/assets/fonts/LICENSE.txt new file mode 100644 index 0000000..8d003fe --- /dev/null +++ b/apps/web/src/assets/fonts/LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2023 Vercel, in collaboration with basement.studio + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/src/features/repositories/RepositoriesPage.tsx b/apps/web/src/features/repositories/RepositoriesPage.tsx index 55deaa4..29a592d 100644 --- a/apps/web/src/features/repositories/RepositoriesPage.tsx +++ b/apps/web/src/features/repositories/RepositoriesPage.tsx @@ -36,7 +36,7 @@ import { useToast, type TableColumn, } from '@/components/ui'; -import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils'; +import { formatBytes, formatDateTime } from '@/lib/utils'; import { adoptRepository, checkRepositoryHealth, @@ -44,7 +44,7 @@ import { measureEnforcement, rebuildCatalog, startIntegrityScan, - type IntegrityScanResult, + type IntegrityScanDetails, type Repository, } from './repositoriesApi'; @@ -56,7 +56,7 @@ export function RepositoriesPage({ const toast = useToast(); const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false); const [selectedRepository, setSelectedRepository] = useState(null); - const [scanResult, setScanResult] = useState(null); + const [scanResult, setScanResult] = useState(null); const [repositoryPendingScan, setRepositoryPendingScan] = useState(null); const [adoptName, setAdoptName] = useState(''); @@ -96,10 +96,10 @@ export function RepositoriesPage({ // Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung // unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt, // wird bald nicht mehr ernst genommen. - if (result.chunks_missing > 0 || result.chunks_corrupted > 0) { + if (result.missing_chunks > 0 || result.corrupted_chunks > 0) { toast.showError( 'Der Integritätslauf hat Befunde', - `${result.chunks_missing} Blöcke fehlen, ${result.chunks_corrupted} sind beschädigt.`, + `${result.missing_chunks} Blöcke fehlen, ${result.corrupted_chunks} sind beschädigt.`, ); } else { toast.showSuccess( @@ -119,11 +119,21 @@ export function RepositoriesPage({ }); const healthMutation = useMutation(checkRepositoryHealth, { - onSuccess: (health) => { - toast.showSuccess( - 'Gesundheitsprüfung abgeschlossen', - health.message ?? `Zustand: ${health.status}`, - ); + onSuccess: (checkResponse) => { + // Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der + // Hülle, die zweite in `details`. + if (!checkResponse.reachable) { + toast.showError( + 'Das Repository ist nicht erreichbar', + checkResponse.error ?? 'Ohne nähere Angabe.', + ); + } else { + toast.showSuccess( + 'Gesundheitsprüfung abgeschlossen', + checkResponse.details?.message ?? 'Das Repository ist erreichbar.', + ); + } + repositoriesResource.reload(); }, onError: (apiError) => @@ -143,11 +153,10 @@ export function RepositoriesPage({ }); const rebuildMutation = useMutation(rebuildCatalog, { - onSuccess: (result) => { + onSuccess: (checkResponse) => { toast.showSuccess( 'Katalog neu aufgebaut', - result.summary ?? - `${result.backups_found ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`, + `${checkResponse.details?.backups_in_catalog ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`, ); repositoriesResource.reload(); }, @@ -410,10 +419,10 @@ function IntegrityScanCard({ result, onClose, }: { - readonly result: IntegrityScanResult; + readonly result: IntegrityScanDetails; readonly onClose: () => void; }) { - const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0; + const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0; return ( @@ -429,7 +438,7 @@ function IntegrityScanCard({ {hasFindings ? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.' - : 'Ohne Befund. Jeder geprüfte Block stimmt mit seiner Prüfsumme überein.'} + : result.summary}
@@ -437,27 +446,36 @@ function IntegrityScanCard({ {result.chunks_checked.toLocaleString('de-DE')} - 0 ? 'text-critical' : undefined}> - {result.chunks_missing.toLocaleString('de-DE')} + 0 ? 'text-critical' : undefined}> + {result.missing_chunks.toLocaleString('de-DE')} - 0 ? 'text-critical' : undefined}> - {result.chunks_corrupted.toLocaleString('de-DE')} + 0 ? 'text-critical' : undefined}> + {result.corrupted_chunks.toLocaleString('de-DE')} - {formatDuration(result.duration_seconds)} + + {result.backups_healthy} von {result.backups_checked} vollständig +
- {result.findings && result.findings.length > 0 ? ( + {!result.verified_chunk_contents ? ( + + Nur die Kennungen wurden geprüft, nicht die Blockinhalte. Das ist + ein halber Nachweis. + + ) : null} + + {result.affected_backup_ids && result.affected_backup_ids.length > 0 ? (

- Betroffene Objekte + Betroffene Backups

    - {result.findings.map((finding) => ( -
  • - {finding} + {result.affected_backup_ids.map((backupIdentifier) => ( +
  • + {backupIdentifier}
  • ))}
diff --git a/apps/web/src/features/repositories/repositoriesApi.ts b/apps/web/src/features/repositories/repositoriesApi.ts index 613fb24..5ad491c 100644 --- a/apps/web/src/features/repositories/repositoriesApi.ts +++ b/apps/web/src/features/repositories/repositoriesApi.ts @@ -32,21 +32,49 @@ export interface Repository { created_at?: string; } -/** Ergebnis eines Integritätslaufs. */ -export interface IntegrityScanResult { - /** Geprüft. */ +/** + * Antworthülle der Repository-Prüfungen. + * + * **Alle vier Prüfendpunkte antworten in dieser Form** — Integritätslauf, + * Gesundheitsprüfung, Verbindungstest und Katalog-Neuaufbau. Das eigentliche + * Ergebnis steckt unter `details`, nicht an der Oberfläche der Antwort. + * + * Das falsch anzunehmen kostete eine Fehlermeldung, die wie ein Defekt der + * Anlage aussah: „can't access property toLocaleString, chunks_checked is + * undefined — das ist ein Problem der Prüfung, kein Befund am Repository." + * Genau das Gegenteil war der Fall; die Prüfung war einwandfrei gelaufen. + */ +export interface RepositoryCheckResponse { + repository_id: string; + reachable: boolean; + repository_uuid?: string; + error?: string; + details?: TDetails; +} + +/** + * Ergebnis eines Integritätslaufs. + * + * Die Feldnamen stammen aus `repository.ScanReport` und heißen anders herum als + * erwartet: `missing_chunks`, nicht `chunks_missing`. + */ +export interface IntegrityScanDetails { + /** Geprüfte Blöcke. */ chunks_checked: number; - /** Nicht auffindbar. */ - chunks_missing: number; + /** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */ + missing_chunks: number; /** Prüfsumme stimmt nicht. */ - chunks_corrupted: number; - manifests_checked?: number; - manifests_invalid?: number; - duration_seconds?: number; - /** Zusammenfassung im Klartext. */ - summary?: string; - /** Betroffene Objekte, sofern benennbar. */ - findings?: string[]; + corrupted_chunks: number; + /** Blöcke ohne Verweis aus einem Manifest. */ + orphaned_chunks: number; + backups_checked: number; + backups_healthy: number; + /** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */ + verified_chunk_contents: boolean; + healthy: boolean; + summary: string; + /** Betroffene Backups, sofern benennbar. */ + affected_backup_ids?: string[] | null; } /** Ergebnis einer Gesundheitsprüfung. */ @@ -126,8 +154,8 @@ export async function updateRepository( /** Prüft die Erreichbarkeit. */ export async function testRepository( repositoryIdentifier: string, -): Promise { - return requestApi( +): Promise>> { + return requestApi>>( `/repositories/${encodeURIComponent(repositoryIdentifier)}/test`, { method: 'POST' }, ); @@ -136,8 +164,8 @@ export async function testRepository( /** Führt eine Gesundheitsprüfung aus. */ export async function checkRepositoryHealth( repositoryIdentifier: string, -): Promise { - return requestApi( +): Promise> { + return requestApi>( `/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`, { method: 'POST' }, ); @@ -152,11 +180,21 @@ export async function checkRepositoryHealth( */ export async function startIntegrityScan( repositoryIdentifier: string, -): Promise { - return requestApi( +): Promise { + const response = await requestApi>( `/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`, { method: 'POST', idempotencyKey: true }, ); + + // Ein nicht erreichbares Repository ist kein Befund am Bestand, sondern ein + // Fehler des Laufs — die Unterscheidung, die diese Seite durchgehend macht. + if (!response.reachable || !response.details) { + throw new Error( + response.error || 'Das Repository war während der Prüfung nicht erreichbar.', + ); + } + + return response.details; } /** @@ -168,8 +206,8 @@ export async function startIntegrityScan( */ export async function rebuildCatalog( repositoryIdentifier: string, -): Promise<{ backups_found?: number; summary?: string }> { - return requestApi<{ backups_found?: number; summary?: string }>( +): Promise> { + return requestApi>( `/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`, { method: 'POST', idempotencyKey: true }, ); diff --git a/apps/web/src/features/restores/BackupContentPicker.tsx b/apps/web/src/features/restores/BackupContentPicker.tsx new file mode 100644 index 0000000..18ee067 --- /dev/null +++ b/apps/web/src/features/restores/BackupContentPicker.tsx @@ -0,0 +1,189 @@ +/** + * Auswahl dessen, was zurückgeholt werden soll. + * + * Bisher gab es nur „alles" — ein Textfeld für einen Teilbaum, das voraussetzte, + * dass man die Pfade im Backup auswendig kennt. Jetzt lässt sich blättern. + * + * Der gewählte Pfad wandert als `path_prefix` in die Anfrage. Das genügt für + * beides: Der Server vergleicht auf Gleichheit **oder** Präfix mit + * Verzeichnisgrenze, trifft also sowohl einen ganzen Ordner als auch eine + * einzelne Datei. „dokumente" trifft dabei nicht „dokumentation". + * + * Bewusste Grenze: **eine** Auswahl je Lauf, kein Mehrfachhaken. Eine Liste + * ausgewählter Pfade kennt die API nicht, und sie vorzutäuschen — etwa durch + * mehrere Läufe hintereinander — ergäbe mehrere Wiederherstellungen mit + * getrenntem Ausgang. Ein „teilweise fehlgeschlagen" ließe sich dann niemandem + * mehr erklären. + */ + +import { ChevronRight, File, Folder, FolderOpen } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useApiResource } from '@/api/useApiResource'; +import { describeApiError } from '@/api/useMutation'; +import { Button, Callout, ErrorState, LoadingState } from '@/components/ui'; +import { formatBytes, formatDateTime } from '@/lib/utils'; +import { browseBackupContents } from './browseApi'; + +export function BackupContentPicker({ + backupIdentifier, + selectedPath, + onSelect, +}: { + readonly backupIdentifier: string; + /** Leer bedeutet: das gesamte Backup. */ + readonly selectedPath: string; + readonly onSelect: (contentPath: string) => void; +}) { + const [currentPath, setCurrentPath] = useState(''); + + const contentsResource = useApiResource( + useCallback( + (abortSignal) => browseBackupContents(backupIdentifier, currentPath, abortSignal), + [backupIdentifier, currentPath], + ), + `${backupIdentifier}|${currentPath}`, + ); + + const listing = contentsResource.data; + + return ( +
+ {/* Der Regelfall steht oben und ist vorausgewählt: Die meisten + Wiederherstellungen holen alles zurück. */} +
+ + + {listing ? `${listing.total_entry_count.toLocaleString('de-DE')} Objekte` : ''} + +
+ + {/* Pfadleiste */} +
+ + {currentPath + .split('/') + .filter((segment) => segment !== '') + .map((segment, segmentIndex, allSegments) => ( + + + + + ))} +
+ +
+ {contentsResource.loadState === 'loading' ? ( + + ) : contentsResource.loadState === 'failed' && contentsResource.loadError ? ( + + ) : ( +
    + {currentPath !== '' ? ( +
  • + +
  • + ) : null} + + {(listing?.entries ?? []).map((entry) => ( +
  • + + + +
  • + ))} + + {(listing?.entries ?? []).length === 0 ? ( +
  • + Dieser Ordner ist im Backup leer. +
  • + ) : null} +
+ )} +
+ + {selectedPath ? ( + + Zurückgeholt wird nur: {selectedPath} +

+ Ein auf einen Teilbaum beschränkter Lauf hebt die Einstufung des + Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das + Backup. +

+
+ ) : ( + Zurückgeholt wird das gesamte Backup. + )} + + {listing?.entries.some((entry) => entry.modified_at) ? ( +

+ Stand der Dateien:{' '} + {formatDateTime( + listing.entries.find((entry) => entry.modified_at)?.modified_at, + )} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/features/restores/DirectoryPicker.tsx b/apps/web/src/features/restores/DirectoryPicker.tsx new file mode 100644 index 0000000..dc07351 --- /dev/null +++ b/apps/web/src/features/restores/DirectoryPicker.tsx @@ -0,0 +1,220 @@ +/** + * Auswahl des Zielverzeichnisses. + * + * Ersetzt das Textfeld, in das man einen Pfad tippte und erst nach der + * Vorabprüfung erfuhr, dass der Dienst dort gar nicht schreiben darf. + * + * Die tragende Angabe ist **`is_writable`**, und sie wird gemessen: Der Server + * legt eine Probedatei an und entfernt sie wieder. Aus den Rechtebits ließe sie + * sich nicht ableiten — der Dienst läuft mit `ProtectSystem=strict`, und davon + * steht nichts im Modus. Genau deshalb endete eine Wiederherstellung nach + * `/opt/test` mit „permission denied", obwohl das Verzeichnis beschreibbar + * aussah. + * + * Nicht beschreibbare Verzeichnisse werden **gezeigt**, nicht versteckt: Sie + * lassen sich betreten, um tiefer zu blättern, aber nicht auswählen. Sie + * wegzulassen ließe den Betreiber im Dunkeln, warum sein Pfad fehlt. + */ + +import { ChevronRight, FolderOpen, Lock, Plus } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useApiResource } from '@/api/useApiResource'; +import { describeApiError } from '@/api/useMutation'; +import { + Button, + Callout, + ErrorState, + LoadingState, + TextInput, +} from '@/components/ui'; +import { cn } from '@/lib/utils'; +import { browseFilesystem } from './browseApi'; + +export function DirectoryPicker({ + selectedPath, + onSelect, +}: { + readonly selectedPath: string; + readonly onSelect: (directoryPath: string) => void; +}) { + // Der Startpunkt ist das übergeordnete Verzeichnis der Auswahl, sonst die + // Wurzel — so landet man beim zweiten Öffnen dort, wo man aufgehört hat. + const [currentPath, setCurrentPath] = useState(() => { + const trimmedSelection = selectedPath.trim(); + + if (!trimmedSelection.startsWith('/')) { + return '/'; + } + + const parentPath = trimmedSelection.replace(/\/[^/]*$/, ''); + + return parentPath === '' ? '/' : parentPath; + }); + + const [newFolderName, setNewFolderName] = useState(''); + + const browseResource = useApiResource( + useCallback((abortSignal) => browseFilesystem(currentPath, abortSignal), [currentPath]), + currentPath, + ); + + const listing = browseResource.data; + + return ( +
+ {/* Vorschläge zuerst: Sie sind der einzige Weg, ohne Vorwissen an einen + brauchbaren Ort zu kommen. */} + {listing?.suggested_paths && listing.suggested_paths.length > 0 ? ( +
+

Beschreibbare Orte

+
+ {listing.suggested_paths.map((suggestedPath) => ( + + ))} +
+
+ ) : null} + + {/* Pfadleiste */} +
+ + {currentPath + .split('/') + .filter((segment) => segment !== '') + .map((segment, segmentIndex, allSegments) => ( + + + + + ))} +
+ + {/* Liste */} +
+ {browseResource.loadState === 'loading' ? ( + + ) : browseResource.loadState === 'failed' && browseResource.loadError ? ( + + ) : ( +
    + {listing?.parent_path ? ( +
  • + +
  • + ) : null} + + {(listing?.entries ?? []).map((entry) => ( +
  • + + + {entry.is_writable ? ( + + ) : null} +
  • + ))} + + {(listing?.entries ?? []).length === 0 && !listing?.parent_path ? ( +
  • + Keine Unterverzeichnisse. +
  • + ) : null} +
+ )} +
+ + {/* Neues Unterverzeichnis: Der Dienst legt das Ziel selbst an, wenn er + im übergeordneten Verzeichnis schreiben darf. Der Name wandert einfach + an den aktuellen Pfad. */} +
+ setNewFolderName(changeEvent.target.value)} + placeholder="wiederherstellung-2026-08-18" + hint="Es wird beim Zurückschreiben angelegt." + /> + +
+ + {selectedPath ? ( + + Ziel: {selectedPath} + + ) : null} +
+ ); +} diff --git a/apps/web/src/features/restores/RestoreWizard.tsx b/apps/web/src/features/restores/RestoreWizard.tsx index 7e8c68a..b3cc710 100644 --- a/apps/web/src/features/restores/RestoreWizard.tsx +++ b/apps/web/src/features/restores/RestoreWizard.tsx @@ -41,6 +41,8 @@ import { useToast, } from '@/components/ui'; import { cn, formatBytes, formatDuration } from '@/lib/utils'; +import { BackupContentPicker } from './BackupContentPicker'; +import { DirectoryPicker } from './DirectoryPicker'; import { createRestore, validateRestore, @@ -170,35 +172,15 @@ export function RestoreWizard({ {currentStepIndex === 0 ? ( -
- setTargetPath(changeEvent.target.value)} - hint="Absoluter Pfad auf dem Server. Systemverzeichnisse wie /etc oder /usr werden abgelehnt." - error={ - targetPath.trim() && !targetPath.trim().startsWith('/') - ? 'Der Pfad muss absolut sein und mit / beginnen.' - : undefined - } - /> - - - Am besten in ein leeres Verzeichnis — der Ursprungsort wäre sonst überschrieben. - -
+ ) : null} {currentStepIndex === 1 ? (
- setPathPrefix(changeEvent.target.value)} - hint="Leer lassen, um alles zurückzuschreiben. Ein Teilbaum-Restore hebt die Einstufung des Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das Backup." +
@@ -209,8 +191,8 @@ export function RestoreWizard({ label="Vorhandene Dateien überschreiben" hint={ mayOverwrite - ? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.' - : 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.' + ? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.' + : 'Ihrer Rolle fehlt das Recht restores.overwrite.' } /> @@ -218,24 +200,16 @@ export function RestoreWizard({ checked={skipPermissions} onCheckedChange={setSkipPermissions} label="Rechte nicht zurückschreiben" - hint="Dateien entstehen mit den Standardrechten des Dienstkontos statt mit den gesicherten." + hint="Dateien entstehen mit den Standardrechten des Dienstkontos." />
- - {skipDeepCheck ? ( - - Ohne Blockprüfung sagt die Vorabprüfung nur, dass das - Manifest lesbar ist — nicht, dass die Daten dazu noch - existieren. Der Bericht weist das aus. - - ) : null}
) : null} diff --git a/apps/web/src/features/restores/browseApi.ts b/apps/web/src/features/restores/browseApi.ts new file mode 100644 index 0000000..26d5978 --- /dev/null +++ b/apps/web/src/features/restores/browseApi.ts @@ -0,0 +1,82 @@ +/** + * Blättern im Dateisystem des Servers und im Inhalt eines Backups. + * + * Beides gehört zur Wiederherstellung: Wohin darf zurückgeschrieben werden, und + * was steckt überhaupt in dem Backup? Ein Textfeld beantwortet weder das eine + * noch das andere. + */ + +import { requestApi } from '../../api/client'; + +/** Ein Verzeichnis auf dem Server. */ +export interface FilesystemEntry { + name: string; + path: string; + is_directory: boolean; + /** + * Meldet, ob der **Dienst** hier anlegen darf. + * + * Gemessen durch einen Schreibversuch, nicht aus den Rechtebits geraten: Der + * Dienst läuft mit `ProtectSystem=strict`, und davon steht nichts im Modus. + */ + is_writable: boolean; + /** Grund, wenn der Zielschutz den Ort ausschließt. */ + forbidden_reason?: string; +} + +/** Antwort auf das Blättern im Dateisystem. */ +export interface BrowseFilesystemResponse { + path: string; + parent_path?: string; + entries: FilesystemEntry[]; + /** Orte, an denen der Dienst nachweislich schreiben darf. */ + suggested_paths?: string[]; +} + +/** Ein Eintrag im Inhaltsverzeichnis eines Backups. */ +export interface BackupContentEntry { + name: string; + /** Genau der Wert, den eine Wiederherstellung als `path_prefix` erwartet. */ + path: string; + is_directory: boolean; + entry_type: string; + size_bytes?: number; + modified_at?: string; + mode?: string; + /** Zahl der Einträge unterhalb eines Verzeichnisses. */ + child_count?: number; + /** Datenmenge unterhalb eines Verzeichnisses. */ + total_bytes?: number; +} + +/** Antwort auf das Blättern im Backup. */ +export interface BrowseBackupResponse { + backup_id: string; + path: string; + parent_path?: string; + entries: BackupContentEntry[]; + total_entry_count: number; +} + +/** Listet die Verzeichnisse unterhalb eines Pfades. */ +export async function browseFilesystem( + directoryPath: string, + abortSignal?: AbortSignal, +): Promise { + return requestApi( + `/filesystem/browse?path=${encodeURIComponent(directoryPath)}`, + abortSignal ? { signal: abortSignal } : {}, + ); +} + +/** Listet den Inhalt eines Backups auf einer Ebene. */ +export async function browseBackupContents( + backupIdentifier: string, + contentPath: string, + abortSignal?: AbortSignal, +): Promise { + return requestApi( + `/backups/${encodeURIComponent(backupIdentifier)}/contents?path=${encodeURIComponent(contentPath)}`, + abortSignal ? { signal: abortSignal } : {}, + ); +} diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css index 441d0fb..8532c56 100644 --- a/apps/web/src/styles/theme.css +++ b/apps/web/src/styles/theme.css @@ -15,11 +15,10 @@ * 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der * Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide * Schreibweisen werden unterstützt. - * 3. **Die Schrift lädt nicht nach.** Geist Mono steht zuerst im Stapel und - * wird verwendet, wenn sie auf dem Gerät liegt; sonst greift die - * System-Monospace. Eine Schrift von einem fremden Host zu holen verbietet - * die Content-Security-Policy der Auslieferung — und ein Backup-Server, der - * für seine Oberfläche ins Internet greift, wäre auch ohne CSP falsch. + * 3. **Geist Mono liegt im Paket.** Sie wird vom eigenen Ursprung ausgeliefert, + * nicht von einem CDN: Das verlangt die Content-Security-Policy, und ein + * Backup-Server, dessen Oberfläche von der Erreichbarkeit eines fremden + * Hosts abhängt, wäre auch ohne CSP falsch. * * Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`) * statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets @@ -29,6 +28,45 @@ @import 'tailwindcss'; +/* + * Geist Mono liegt **im Paket**, nicht auf einem fremden Host. + * + * Vite bündelt die drei Schnitte mit und vergibt ihnen einen Hash; ausgeliefert + * wird ausschließlich vom eigenen Ursprung. Das ist die einzige Form, die die + * Content-Security-Policy zulässt — und die einzige, die für einen + * Backup-Server vertretbar ist: Seine Oberfläche darf nicht davon abhängen, + * dass ein CDN erreichbar ist. Kosten: rund 128 KB für drei Schnitte. + * + * `font-display: swap` zeigt den Text sofort in der System-Monospace und + * tauscht die Schrift nach. Ein unsichtbarer Text, bis eine Schriftdatei da + * ist, wäre in einer Störungskonsole der falsche Kompromiss. + * + * Lizenz: SIL Open Font License 1.1, siehe assets/fonts/LICENSE.txt. + */ +@font-face { + font-family: 'Geist Mono'; + src: url('../assets/fonts/GeistMono-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist Mono'; + src: url('../assets/fonts/GeistMono-Medium.woff2') format('woff2'); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Geist Mono'; + src: url('../assets/fonts/GeistMono-SemiBold.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + /* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die * shadcn-übliche Form und kostet nichts. */ @custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *)); diff --git a/scripts/setup.sh b/scripts/setup.sh index dec54ec..161dbf9 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -40,6 +40,28 @@ readonly defaultRepositoryPath="/srv/syncova-repository" # backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf. readonly backupDirectory="/var/backups/syncova" +# additionalRestoreRoots sind weitere Ziele fuer Wiederherstellungen. +# +# Wer nach /srv/wiederherstellung oder auf eine Freigabe zurueckschreiben will, +# nennt sie beim Einrichten mit --wiederherstellungsziel. Sie landen in +# ReadWritePaths der Einheit; ohne diesen Eintrag nuetzen die Rechte des +# Verzeichnisses nichts. +additionalRestoreRoots="" + +# restoreDirectory ist die Flaeche, auf die zurueckgeschrieben werden darf. +# +# Sie ist noetig, weil der Dienst mit ProtectSystem=strict laeuft: Ausserhalb +# der in ReadWritePaths genannten Pfade ist das Dateisystem fuer ihn +# schreibgeschuetzt. Ohne eine solche Flaeche endet **jede** Wiederherstellung +# mit "mkdir: permission denied" — und zwar erst nach der Vorabpruefung, also +# an der unangenehmsten Stelle. +# +# Der Ort liegt unter /srv und nicht unter /var/lib: Letzteres steht auf der +# Sperrliste des Zielschutzes (packages/recovery/targetguard.go), weil eine +# Wiederherstellung dorthin den Zustand der Anlage selbst ueberschreiben +# koennte. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig. +readonly restoreDirectory="/srv/syncova-restore" + # --------------------------------------------------------------------------- # Ausgabe # --------------------------------------------------------------------------- @@ -212,6 +234,10 @@ while [[ $# -gt 0 ]]; do case "$1" in --paket) packageDirectory="${2:-}"; shift 2 ;; --repository) repositoryPath="${2:-}"; shift 2 ;; + --wiederherstellungsziel) + # Mehrfach angebbar. Die Pfade kommen in ReadWritePaths der Einheit; + # ohne diesen Eintrag nuetzen die Rechte des Verzeichnisses nichts. + additionalRestoreRoots="${additionalRestoreRoots} ${2:-}"; shift 2 ;; --adresse) listenAddress="${2:-}"; shift 2 ;; --admin) administratorName="${2:-}"; shift 2 ;; --ohne-haertung) createHardenedRepository="nein"; shift ;; @@ -1047,6 +1073,11 @@ noteCreated "directory:${configurationDirectory}" install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${backupDirectory}" +# Die Wiederherstellungsflaeche. Ohne sie scheitert jede Wiederherstellung an +# ProtectSystem=strict — und der Betreiber sucht den Fehler bei den Rechten des +# Zielverzeichnisses statt bei der Haertung des Dienstes. +install -d -m 0750 -o "${serviceAccount}" -g "${serviceAccount}" "${restoreDirectory}" + cp -R "${packageDirectory}/bin" "${installationRoot}/" [[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/" [[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/" @@ -1296,7 +1327,7 @@ NoNewPrivileges=yes PrivateTmp=${privateTmpSetting} ProtectSystem=strict ProtectHome=yes -ReadWritePaths=${repositoryPath} ${backupDirectory} +ReadWritePaths=${repositoryPath} ${backupDirectory} ${restoreDirectory}${additionalRestoreRoots} ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes diff --git a/untitled.md b/untitled.md new file mode 100644 index 0000000..e69de29