Datei- und Ordnerwiederherstellung, Ordnerbaum, Geist Mono im Paket
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 45s
CI / Sicherheitsprüfungen (push) Successful in 27s

**Warum keine Wiederherstellung funktionierte.** Der Dienst laeuft mit
`ProtectSystem=strict` und `ReadWritePaths` nur auf Repository und
Sicherungsordner — alles andere ist fuer ihn schreibgeschuetzt. Jedes Ziel
ausserhalb endete mit "mkdir: permission denied", und zwar **nach** der
Vorabpruefung. `/tmp` scheiterte anders: Mit `PrivateTmp=yes` hat der Dienst ein
eigenes /tmp, und was dort landet, sieht man von aussen gar nicht.

`setup.sh` legt jetzt `/srv/syncova-restore` an und traegt es in
`ReadWritePaths` ein; `--wiederherstellungsziel` ergaenzt weitere. Der Ort liegt
unter /srv und nicht unter /var/lib — Letzteres steht auf der Sperrliste des
Zielschutzes. Beide Regeln zugleich zu erfuellen laesst genau /srv uebrig; das
ist mir erst aufgefallen, nachdem ich die Flaeche zunaechst falsch gelegt hatte
und der eigene Zielschutz sie ablehnte.

**Zwei neue Endpunkte** (Vertrag entsprechend erweitert):

- `GET /filesystem/browse` — Verzeichnisse mit der Angabe, ob der **Dienst**
  dort schreiben darf. **Gemessen** durch eine Probedatei, nicht aus den
  Rechtebits geraten: Unter ProtectSystem=strict sagen die Bits nichts ueber
  das aus, was der Namensraum zulaesst. Gesperrte Orte werden gezeigt, nicht
  versteckt — sonst bliebe offen, warum ein Pfad fehlt.
- `GET /backups/{id}/contents` — das Manifest als Ebene eines Baums. Der Baum
  entsteht aus den **Pfaden**, nicht aus Verzeichniseintraegen: Ein Manifest
  kann eine Datei enthalten, deren Elternverzeichnis nicht als eigener Eintrag
  vorliegt, und wer nur `directory`-Eintraege auflistet, verliert ganze
  Teilbaeume. Durch Mutation bestaetigt.

**Auswahl statt Textfeld.** Der Assistent hat jetzt einen Ordnerbaum fuer das
Ziel und einen Browser fuer den Backup-Inhalt. Ordner **und** einzelne Dateien
lassen sich waehlen; beides geht als `path_prefix` in die Anfrage, weil der
Server auf Gleichheit oder Praefix mit Verzeichnisgrenze vergleicht. Bewusste
Grenze: eine Auswahl je Lauf — eine Liste kennt die API nicht, und mehrere
Laeufe vorzutaeuschen ergaebe mehrere Ausgaenge, die niemand mehr erklaeren
kann.

**Integritaetslauf.** "can't access property toLocaleString, chunks_checked is
undefined" — die Ergebnisse liegen unter `details`, und die Felder heissen
`missing_chunks`/`corrupted_chunks`, nicht umgekehrt. Betrifft alle vier
Pruefendpunkte; sie tragen dieselbe Huelle. Derselbe Fehler wie bei
/retention-policies: die Antwortform angenommen statt geprueft.

**Geist Mono liegt jetzt im Paket** (drei Schnitte, 128 KB, OFL-Lizenz dabei).
Ausgeliefert vom eigenen Ursprung — das verlangt die CSP, und ein
Backup-Server, dessen Oberflaeche von einem CDN abhaengt, waere auch ohne CSP
falsch. `font-display: swap`, damit der Text sofort steht.

Nachgewiesen gegen Debian 12: Vollwiederherstellung (5 Dateien), nur ein Ordner
(2 Dateien), nur eine Datei (1 Datei) — alle drei bitgenau. Der Server nennt
/srv/syncova-restore als beschreibbar und /etc, /usr, /var als gesperrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jerrit Fritzsche 2026-08-18 15:52:37 +02:00
parent 053e9ae817
commit 20b0919676
17 changed files with 1299 additions and 90 deletions

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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).

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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.

View File

@ -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<Repository | null>(null);
const [scanResult, setScanResult] = useState<IntegrityScanResult | null>(null);
const [scanResult, setScanResult] = useState<IntegrityScanDetails | null>(null);
const [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(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 (
<Card className="mt-4">
@ -429,7 +438,7 @@ function IntegrityScanCard({
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
{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}
</Callout>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@ -437,27 +446,36 @@ function IntegrityScanCard({
{result.chunks_checked.toLocaleString('de-DE')}
</DetailItem>
<DetailItem label="Fehlend">
<span className={result.chunks_missing > 0 ? 'text-critical' : undefined}>
{result.chunks_missing.toLocaleString('de-DE')}
<span className={result.missing_chunks > 0 ? 'text-critical' : undefined}>
{result.missing_chunks.toLocaleString('de-DE')}
</span>
</DetailItem>
<DetailItem label="Beschädigt">
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
{result.chunks_corrupted.toLocaleString('de-DE')}
<span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}>
{result.corrupted_chunks.toLocaleString('de-DE')}
</span>
</DetailItem>
<DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem>
<DetailItem label="Backups">
{result.backups_healthy} von {result.backups_checked} vollständig
</DetailItem>
</dl>
{result.findings && result.findings.length > 0 ? (
{!result.verified_chunk_contents ? (
<Callout tone="warning" className="mt-4">
Nur die Kennungen wurden geprüft, nicht die Blockinhalte. Das ist
ein halber Nachweis.
</Callout>
) : null}
{result.affected_backup_ids && result.affected_backup_ids.length > 0 ? (
<div className="mt-4">
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
Betroffene Objekte
Betroffene Backups
</p>
<ul className="mt-1.5 space-y-1">
{result.findings.map((finding) => (
<li key={finding} className="break-all font-mono text-xs text-fg-muted">
{finding}
{result.affected_backup_ids.map((backupIdentifier) => (
<li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted">
{backupIdentifier}
</li>
))}
</ul>

View File

@ -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<TDetails> {
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<RepositoryHealth> {
return requestApi<RepositoryHealth>(
): Promise<RepositoryCheckResponse<Record<string, unknown>>> {
return requestApi<RepositoryCheckResponse<Record<string, unknown>>>(
`/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<RepositoryHealth> {
return requestApi<RepositoryHealth>(
): Promise<RepositoryCheckResponse<RepositoryHealth>> {
return requestApi<RepositoryCheckResponse<RepositoryHealth>>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
{ method: 'POST' },
);
@ -152,11 +180,21 @@ export async function checkRepositoryHealth(
*/
export async function startIntegrityScan(
repositoryIdentifier: string,
): Promise<IntegrityScanResult> {
return requestApi<IntegrityScanResult>(
): Promise<IntegrityScanDetails> {
const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>(
`/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<RepositoryCheckResponse<{ backups_in_catalog?: number }>> {
return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>(
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
{ method: 'POST', idempotencyKey: true },
);

View File

@ -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 (
<div className="space-y-3">
{/* Der Regelfall steht oben und ist vorausgewählt: Die meisten
Wiederherstellungen holen alles zurück. */}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant={selectedPath === '' ? 'primary' : 'secondary'}
onClick={() => onSelect('')}
>
Gesamtes Backup
</Button>
<span className="text-xs text-fg-muted">
{listing ? `${listing.total_entry_count.toLocaleString('de-DE')} Objekte` : ''}
</span>
</div>
{/* Pfadleiste */}
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setCurrentPath('')}
>
Wurzel
</button>
{currentPath
.split('/')
.filter((segment) => segment !== '')
.map((segment, segmentIndex, allSegments) => (
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setCurrentPath(allSegments.slice(0, segmentIndex + 1).join('/'))}
>
{segment}
</button>
</span>
))}
</div>
<div className="max-h-72 overflow-y-auto border border-line">
{contentsResource.loadState === 'loading' ? (
<LoadingState label="Inhalt wird gelesen …" />
) : contentsResource.loadState === 'failed' && contentsResource.loadError ? (
<ErrorState
message={describeApiError(contentsResource.loadError)}
requestId={contentsResource.loadError.requestId}
onRetry={contentsResource.reload}
/>
) : (
<ul className="divide-y divide-line">
{currentPath !== '' ? (
<li>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
onClick={() => setCurrentPath(listing?.parent_path ?? '')}
>
<FolderOpen className="size-4 shrink-0" aria-hidden />
… eine Ebene höher
</button>
</li>
) : null}
{(listing?.entries ?? []).map((entry) => (
<li key={entry.path} className="flex items-center">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
onClick={() =>
entry.is_directory ? setCurrentPath(entry.path) : onSelect(entry.path)
}
>
{entry.is_directory ? (
<Folder className="size-4 shrink-0 text-fg-muted" aria-hidden />
) : (
<File className="size-4 shrink-0 text-fg-subtle" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg">{entry.name}</span>
<span className="shrink-0 text-[11px] tabular text-fg-muted">
{entry.is_directory
? entry.child_count
? `${entry.child_count} Objekte · ${formatBytes(entry.total_bytes)}`
: ''
: formatBytes(entry.size_bytes)}
</span>
</button>
<Button
size="sm"
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
className="mr-1 shrink-0"
onClick={() => onSelect(entry.path)}
title={
entry.is_directory
? 'Diesen Ordner mit allem darin zurückholen'
: 'Nur diese Datei zurückholen'
}
>
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
</Button>
</li>
))}
{(listing?.entries ?? []).length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-fg-muted">
Dieser Ordner ist im Backup leer.
</li>
) : null}
</ul>
)}
</div>
{selectedPath ? (
<Callout tone="info">
Zurückgeholt wird nur: <code className="font-mono">{selectedPath}</code>
<p className="mt-1 text-xs">
Ein auf einen Teilbaum beschränkter Lauf hebt die Einstufung des
Wiederherstellungspunkts nicht — er prüft einen Teil, nicht das
Backup.
</p>
</Callout>
) : (
<Callout tone="info">Zurückgeholt wird das gesamte Backup.</Callout>
)}
{listing?.entries.some((entry) => entry.modified_at) ? (
<p className="text-xs text-fg-subtle">
Stand der Dateien:{' '}
{formatDateTime(
listing.entries.find((entry) => entry.modified_at)?.modified_at,
)}
</p>
) : null}
</div>
);
}

View File

@ -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 (
<div className="space-y-3">
{/* Vorschläge zuerst: Sie sind der einzige Weg, ohne Vorwissen an einen
brauchbaren Ort zu kommen. */}
{listing?.suggested_paths && listing.suggested_paths.length > 0 ? (
<div>
<p className="mb-1.5 text-xs text-fg-subtle">Beschreibbare Orte</p>
<div className="flex flex-wrap gap-1.5">
{listing.suggested_paths.map((suggestedPath) => (
<Button
key={suggestedPath}
size="sm"
variant={selectedPath === suggestedPath ? 'primary' : 'secondary'}
onClick={() => {
onSelect(suggestedPath);
setCurrentPath(suggestedPath);
}}
>
{suggestedPath}
</Button>
))}
</div>
</div>
) : null}
{/* Pfadleiste */}
<div className="flex flex-wrap items-center gap-1 border border-line bg-sunken px-2 py-1.5 text-xs">
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() => setCurrentPath('/')}
>
/
</button>
{currentPath
.split('/')
.filter((segment) => segment !== '')
.map((segment, segmentIndex, allSegments) => (
<span key={`${segment}-${segmentIndex}`} className="flex items-center gap-1">
<ChevronRight className="size-3 text-fg-subtle" aria-hidden />
<button
type="button"
className="text-fg-muted hover:text-fg"
onClick={() =>
setCurrentPath('/' + allSegments.slice(0, segmentIndex + 1).join('/'))
}
>
{segment}
</button>
</span>
))}
</div>
{/* Liste */}
<div className="max-h-64 overflow-y-auto border border-line">
{browseResource.loadState === 'loading' ? (
<LoadingState label="Verzeichnis wird gelesen …" />
) : browseResource.loadState === 'failed' && browseResource.loadError ? (
<ErrorState
message={describeApiError(browseResource.loadError)}
requestId={browseResource.loadError.requestId}
onRetry={browseResource.reload}
/>
) : (
<ul className="divide-y divide-line">
{listing?.parent_path ? (
<li>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-fg-muted hover:bg-hover"
onClick={() => setCurrentPath(listing.parent_path!)}
>
<FolderOpen className="size-4 shrink-0" aria-hidden />
… eine Ebene höher
</button>
</li>
) : null}
{(listing?.entries ?? []).map((entry) => (
<li key={entry.path} className="flex items-center">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm hover:bg-hover"
onClick={() => setCurrentPath(entry.path)}
title={entry.forbidden_reason}
>
{entry.is_writable ? (
<FolderOpen className="size-4 shrink-0 text-fg-muted" aria-hidden />
) : (
<Lock className="size-4 shrink-0 text-fg-subtle" aria-hidden />
)}
<span className={cn('truncate', entry.is_writable ? 'text-fg' : 'text-fg-subtle')}>
{entry.name}
</span>
{!entry.is_writable ? (
<span className="ml-auto shrink-0 text-[11px] text-fg-subtle">
{entry.forbidden_reason ? 'gesperrt' : 'nicht beschreibbar'}
</span>
) : null}
</button>
{entry.is_writable ? (
<Button
size="sm"
variant={selectedPath === entry.path ? 'primary' : 'ghost'}
className="mr-1 shrink-0"
onClick={() => onSelect(entry.path)}
>
{selectedPath === entry.path ? 'Gewählt' : 'Wählen'}
</Button>
) : null}
</li>
))}
{(listing?.entries ?? []).length === 0 && !listing?.parent_path ? (
<li className="px-3 py-6 text-center text-sm text-fg-muted">
Keine Unterverzeichnisse.
</li>
) : null}
</ul>
)}
</div>
{/* Neues Unterverzeichnis: Der Dienst legt das Ziel selbst an, wenn er
im übergeordneten Verzeichnis schreiben darf. Der Name wandert einfach
an den aktuellen Pfad. */}
<div className="flex items-end gap-2">
<TextInput
label="Neues Unterverzeichnis"
className="flex-1"
value={newFolderName}
onChange={(changeEvent) => setNewFolderName(changeEvent.target.value)}
placeholder="wiederherstellung-2026-08-18"
hint="Es wird beim Zurückschreiben angelegt."
/>
<Button
className="mb-0.5"
disabled={!newFolderName.trim()}
onClick={() => {
const combinedPath =
(currentPath === '/' ? '' : currentPath) + '/' + newFolderName.trim();
onSelect(combinedPath);
setNewFolderName('');
}}
>
<Plus />
Übernehmen
</Button>
</div>
{selectedPath ? (
<Callout tone="info">
Ziel: <code className="font-mono">{selectedPath}</code>
</Callout>
) : null}
</div>
);
}

View File

@ -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({
<DialogBody className="min-h-64">
{currentStepIndex === 0 ? (
<div className="space-y-4">
<TextInput
label="Zielverzeichnis"
required
placeholder="/srv/wiederherstellung"
value={targetPath}
onChange={(changeEvent) => 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
}
/>
<Callout tone="info" title="Wohin am besten?">
Am besten in ein leeres Verzeichnis — der Ursprungsort wäre sonst überschrieben.
</Callout>
</div>
<DirectoryPicker selectedPath={targetPath} onSelect={setTargetPath} />
) : null}
{currentStepIndex === 1 ? (
<div className="space-y-4">
<TextInput
label="Nur ein Teilbaum (optional)"
placeholder="daten/projekte"
value={pathPrefix}
onChange={(changeEvent) => 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."
<BackupContentPicker
backupIdentifier={backupIdentifier}
selectedPath={pathPrefix}
onSelect={setPathPrefix}
/>
<div className="space-y-3 border-t border-line pt-4">
@ -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."
/>
<CheckboxField
checked={skipDeepCheck}
onCheckedChange={setSkipDeepCheck}
label="Blockprüfung überspringen"
hint="Beschleunigt die Vorabprüfung und senkt ihre Aussagekraft: Ohne sie ist nicht belegt, dass jeder benötigte Block noch vorhanden ist."
hint="Schneller, aber dann ist nicht belegt, dass die Daten noch da sind."
/>
</div>
{skipDeepCheck ? (
<Callout tone="warning">
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.
</Callout>
) : null}
</div>
) : null}

View File

@ -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<BrowseFilesystemResponse> {
return requestApi<BrowseFilesystemResponse>(
`/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<BrowseBackupResponse> {
return requestApi<BrowseBackupResponse>(
`/backups/${encodeURIComponent(backupIdentifier)}/contents?path=${encodeURIComponent(contentPath)}`,
abortSignal ? { signal: abortSignal } : {},
);
}

View File

@ -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 *));

View File

@ -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

0
untitled.md Normal file
View File