Datei- und Ordnerwiederherstellung, Ordnerbaum, Geist Mono im Paket
**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:
parent
053e9ae817
commit
20b0919676
422
apps/api/internal/httpapi/browse_handler.go
Normal file
422
apps/api/internal/httpapi/browse_handler.go
Normal 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
|
||||||
|
}
|
||||||
90
apps/api/internal/httpapi/browse_handler_test.go
Normal file
90
apps/api/internal/httpapi/browse_handler_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -122,3 +122,5 @@ POST /api/v1/users users.write
|
|||||||
POST /api/v1/users/{id}/mfa/disable users.write
|
POST /api/v1/users/{id}/mfa/disable users.write
|
||||||
POST /api/v1/verification verification.write
|
POST /api/v1/verification verification.write
|
||||||
POST /api/v1/verification/{id}/cancel 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
|
||||||
|
|||||||
@ -377,6 +377,19 @@ func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies
|
|||||||
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
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}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
||||||
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
|
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).
|
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
|
||||||
|
|||||||
BIN
apps/web/src/assets/fonts/GeistMono-Medium.woff2
Normal file
BIN
apps/web/src/assets/fonts/GeistMono-Medium.woff2
Normal file
Binary file not shown.
BIN
apps/web/src/assets/fonts/GeistMono-Regular.woff2
Normal file
BIN
apps/web/src/assets/fonts/GeistMono-Regular.woff2
Normal file
Binary file not shown.
BIN
apps/web/src/assets/fonts/GeistMono-SemiBold.woff2
Normal file
BIN
apps/web/src/assets/fonts/GeistMono-SemiBold.woff2
Normal file
Binary file not shown.
92
apps/web/src/assets/fonts/LICENSE.txt
Normal file
92
apps/web/src/assets/fonts/LICENSE.txt
Normal 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.
|
||||||
@ -36,7 +36,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { formatBytes, formatDateTime, formatDuration } from '@/lib/utils';
|
import { formatBytes, formatDateTime } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
adoptRepository,
|
adoptRepository,
|
||||||
checkRepositoryHealth,
|
checkRepositoryHealth,
|
||||||
@ -44,7 +44,7 @@ import {
|
|||||||
measureEnforcement,
|
measureEnforcement,
|
||||||
rebuildCatalog,
|
rebuildCatalog,
|
||||||
startIntegrityScan,
|
startIntegrityScan,
|
||||||
type IntegrityScanResult,
|
type IntegrityScanDetails,
|
||||||
type Repository,
|
type Repository,
|
||||||
} from './repositoriesApi';
|
} from './repositoriesApi';
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ export function RepositoriesPage({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
||||||
const [selectedRepository, setSelectedRepository] = useState<Repository | null>(null);
|
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 [repositoryPendingScan, setRepositoryPendingScan] = useState<Repository | null>(null);
|
||||||
|
|
||||||
const [adoptName, setAdoptName] = useState('');
|
const [adoptName, setAdoptName] = useState('');
|
||||||
@ -96,10 +96,10 @@ export function RepositoriesPage({
|
|||||||
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
|
// Ein Befund ist ein Ergebnis, kein Fehler des Laufs. Die Meldung
|
||||||
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
|
// unterscheidet beides — ein Prüfwerkzeug, das grundlos Alarm schlägt,
|
||||||
// wird bald nicht mehr ernst genommen.
|
// 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(
|
toast.showError(
|
||||||
'Der Integritätslauf hat Befunde',
|
'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 {
|
} else {
|
||||||
toast.showSuccess(
|
toast.showSuccess(
|
||||||
@ -119,11 +119,21 @@ export function RepositoriesPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const healthMutation = useMutation(checkRepositoryHealth, {
|
const healthMutation = useMutation(checkRepositoryHealth, {
|
||||||
onSuccess: (health) => {
|
onSuccess: (checkResponse) => {
|
||||||
toast.showSuccess(
|
// Erreichbarkeit und Befund sind zwei Aussagen. Die erste steht in der
|
||||||
'Gesundheitsprüfung abgeschlossen',
|
// Hülle, die zweite in `details`.
|
||||||
health.message ?? `Zustand: ${health.status}`,
|
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();
|
repositoriesResource.reload();
|
||||||
},
|
},
|
||||||
onError: (apiError) =>
|
onError: (apiError) =>
|
||||||
@ -143,11 +153,10 @@ export function RepositoriesPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rebuildMutation = useMutation(rebuildCatalog, {
|
const rebuildMutation = useMutation(rebuildCatalog, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (checkResponse) => {
|
||||||
toast.showSuccess(
|
toast.showSuccess(
|
||||||
'Katalog neu aufgebaut',
|
'Katalog neu aufgebaut',
|
||||||
result.summary ??
|
`${checkResponse.details?.backups_in_catalog ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
|
||||||
`${result.backups_found ?? 0} Wiederherstellungspunkte aus den Manifesten gelesen.`,
|
|
||||||
);
|
);
|
||||||
repositoriesResource.reload();
|
repositoriesResource.reload();
|
||||||
},
|
},
|
||||||
@ -410,10 +419,10 @@ function IntegrityScanCard({
|
|||||||
result,
|
result,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
readonly result: IntegrityScanResult;
|
readonly result: IntegrityScanDetails;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const hasFindings = result.chunks_missing > 0 || result.chunks_corrupted > 0;
|
const hasFindings = result.missing_chunks > 0 || result.corrupted_chunks > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mt-4">
|
<Card className="mt-4">
|
||||||
@ -429,7 +438,7 @@ function IntegrityScanCard({
|
|||||||
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
|
<Callout tone={hasFindings ? 'critical' : 'healthy'}>
|
||||||
{hasFindings
|
{hasFindings
|
||||||
? 'Das Repository weist Befunde auf. Wiederherstellungen aus betroffenen Backups wären unvollständig.'
|
? '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>
|
</Callout>
|
||||||
|
|
||||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<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')}
|
{result.chunks_checked.toLocaleString('de-DE')}
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="Fehlend">
|
<DetailItem label="Fehlend">
|
||||||
<span className={result.chunks_missing > 0 ? 'text-critical' : undefined}>
|
<span className={result.missing_chunks > 0 ? 'text-critical' : undefined}>
|
||||||
{result.chunks_missing.toLocaleString('de-DE')}
|
{result.missing_chunks.toLocaleString('de-DE')}
|
||||||
</span>
|
</span>
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="Beschädigt">
|
<DetailItem label="Beschädigt">
|
||||||
<span className={result.chunks_corrupted > 0 ? 'text-critical' : undefined}>
|
<span className={result.corrupted_chunks > 0 ? 'text-critical' : undefined}>
|
||||||
{result.chunks_corrupted.toLocaleString('de-DE')}
|
{result.corrupted_chunks.toLocaleString('de-DE')}
|
||||||
</span>
|
</span>
|
||||||
</DetailItem>
|
</DetailItem>
|
||||||
<DetailItem label="Dauer">{formatDuration(result.duration_seconds)}</DetailItem>
|
<DetailItem label="Backups">
|
||||||
|
{result.backups_healthy} von {result.backups_checked} vollständig
|
||||||
|
</DetailItem>
|
||||||
</dl>
|
</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">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
<p className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
||||||
Betroffene Objekte
|
Betroffene Backups
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1.5 space-y-1">
|
<ul className="mt-1.5 space-y-1">
|
||||||
{result.findings.map((finding) => (
|
{result.affected_backup_ids.map((backupIdentifier) => (
|
||||||
<li key={finding} className="break-all font-mono text-xs text-fg-muted">
|
<li key={backupIdentifier} className="break-all font-mono text-xs text-fg-muted">
|
||||||
{finding}
|
{backupIdentifier}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -32,21 +32,49 @@ export interface Repository {
|
|||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis eines Integritätslaufs. */
|
/**
|
||||||
export interface IntegrityScanResult {
|
* Antworthülle der Repository-Prüfungen.
|
||||||
/** Geprüft. */
|
*
|
||||||
|
* **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;
|
chunks_checked: number;
|
||||||
/** Nicht auffindbar. */
|
/** Nicht auffindbar — jeder einzelne verhindert eine Wiederherstellung. */
|
||||||
chunks_missing: number;
|
missing_chunks: number;
|
||||||
/** Prüfsumme stimmt nicht. */
|
/** Prüfsumme stimmt nicht. */
|
||||||
chunks_corrupted: number;
|
corrupted_chunks: number;
|
||||||
manifests_checked?: number;
|
/** Blöcke ohne Verweis aus einem Manifest. */
|
||||||
manifests_invalid?: number;
|
orphaned_chunks: number;
|
||||||
duration_seconds?: number;
|
backups_checked: number;
|
||||||
/** Zusammenfassung im Klartext. */
|
backups_healthy: number;
|
||||||
summary?: string;
|
/** Meldet, ob die Blockinhalte gelesen wurden oder nur die Kennungen. */
|
||||||
/** Betroffene Objekte, sofern benennbar. */
|
verified_chunk_contents: boolean;
|
||||||
findings?: string[];
|
healthy: boolean;
|
||||||
|
summary: string;
|
||||||
|
/** Betroffene Backups, sofern benennbar. */
|
||||||
|
affected_backup_ids?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ergebnis einer Gesundheitsprüfung. */
|
/** Ergebnis einer Gesundheitsprüfung. */
|
||||||
@ -126,8 +154,8 @@ export async function updateRepository(
|
|||||||
/** Prüft die Erreichbarkeit. */
|
/** Prüft die Erreichbarkeit. */
|
||||||
export async function testRepository(
|
export async function testRepository(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<RepositoryHealth> {
|
): Promise<RepositoryCheckResponse<Record<string, unknown>>> {
|
||||||
return requestApi<RepositoryHealth>(
|
return requestApi<RepositoryCheckResponse<Record<string, unknown>>>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/test`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
);
|
);
|
||||||
@ -136,8 +164,8 @@ export async function testRepository(
|
|||||||
/** Führt eine Gesundheitsprüfung aus. */
|
/** Führt eine Gesundheitsprüfung aus. */
|
||||||
export async function checkRepositoryHealth(
|
export async function checkRepositoryHealth(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<RepositoryHealth> {
|
): Promise<RepositoryCheckResponse<RepositoryHealth>> {
|
||||||
return requestApi<RepositoryHealth>(
|
return requestApi<RepositoryCheckResponse<RepositoryHealth>>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/health-check`,
|
||||||
{ method: 'POST' },
|
{ method: 'POST' },
|
||||||
);
|
);
|
||||||
@ -152,11 +180,21 @@ export async function checkRepositoryHealth(
|
|||||||
*/
|
*/
|
||||||
export async function startIntegrityScan(
|
export async function startIntegrityScan(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<IntegrityScanResult> {
|
): Promise<IntegrityScanDetails> {
|
||||||
return requestApi<IntegrityScanResult>(
|
const response = await requestApi<RepositoryCheckResponse<IntegrityScanDetails>>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/integrity-scan`,
|
||||||
{ method: 'POST', idempotencyKey: true },
|
{ 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(
|
export async function rebuildCatalog(
|
||||||
repositoryIdentifier: string,
|
repositoryIdentifier: string,
|
||||||
): Promise<{ backups_found?: number; summary?: string }> {
|
): Promise<RepositoryCheckResponse<{ backups_in_catalog?: number }>> {
|
||||||
return requestApi<{ backups_found?: number; summary?: string }>(
|
return requestApi<RepositoryCheckResponse<{ backups_in_catalog?: number }>>(
|
||||||
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
|
`/repositories/${encodeURIComponent(repositoryIdentifier)}/rebuild-catalog`,
|
||||||
{ method: 'POST', idempotencyKey: true },
|
{ method: 'POST', idempotencyKey: true },
|
||||||
);
|
);
|
||||||
|
|||||||
189
apps/web/src/features/restores/BackupContentPicker.tsx
Normal file
189
apps/web/src/features/restores/BackupContentPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
apps/web/src/features/restores/DirectoryPicker.tsx
Normal file
220
apps/web/src/features/restores/DirectoryPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -41,6 +41,8 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
import { cn, formatBytes, formatDuration } from '@/lib/utils';
|
||||||
|
import { BackupContentPicker } from './BackupContentPicker';
|
||||||
|
import { DirectoryPicker } from './DirectoryPicker';
|
||||||
import {
|
import {
|
||||||
createRestore,
|
createRestore,
|
||||||
validateRestore,
|
validateRestore,
|
||||||
@ -170,35 +172,15 @@ export function RestoreWizard({
|
|||||||
|
|
||||||
<DialogBody className="min-h-64">
|
<DialogBody className="min-h-64">
|
||||||
{currentStepIndex === 0 ? (
|
{currentStepIndex === 0 ? (
|
||||||
<div className="space-y-4">
|
<DirectoryPicker selectedPath={targetPath} onSelect={setTargetPath} />
|
||||||
<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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{currentStepIndex === 1 ? (
|
{currentStepIndex === 1 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextInput
|
<BackupContentPicker
|
||||||
label="Nur ein Teilbaum (optional)"
|
backupIdentifier={backupIdentifier}
|
||||||
placeholder="daten/projekte"
|
selectedPath={pathPrefix}
|
||||||
value={pathPrefix}
|
onSelect={setPathPrefix}
|
||||||
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."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3 border-t border-line pt-4">
|
<div className="space-y-3 border-t border-line pt-4">
|
||||||
@ -209,8 +191,8 @@ export function RestoreWizard({
|
|||||||
label="Vorhandene Dateien überschreiben"
|
label="Vorhandene Dateien überschreiben"
|
||||||
hint={
|
hint={
|
||||||
mayOverwrite
|
mayOverwrite
|
||||||
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt. Zusätzlich verlangt der Server danach den wörtlich wiederholten Zielpfad.'
|
? 'Ohne dieses Kennzeichen wird ein nicht leeres Ziel abgelehnt.'
|
||||||
: 'Ihrer Rolle fehlt die Berechtigung restores.overwrite. Sie steckt bewusst nicht in restores.execute.'
|
: 'Ihrer Rolle fehlt das Recht restores.overwrite.'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -218,24 +200,16 @@ export function RestoreWizard({
|
|||||||
checked={skipPermissions}
|
checked={skipPermissions}
|
||||||
onCheckedChange={setSkipPermissions}
|
onCheckedChange={setSkipPermissions}
|
||||||
label="Rechte nicht zurückschreiben"
|
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
|
<CheckboxField
|
||||||
checked={skipDeepCheck}
|
checked={skipDeepCheck}
|
||||||
onCheckedChange={setSkipDeepCheck}
|
onCheckedChange={setSkipDeepCheck}
|
||||||
label="Blockprüfung überspringen"
|
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>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
82
apps/web/src/features/restores/browseApi.ts
Normal file
82
apps/web/src/features/restores/browseApi.ts
Normal 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 } : {},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -15,11 +15,10 @@
|
|||||||
* 2. **Das dunkle Thema hängt an `[data-theme='dark']`, nicht an `.dark`.** Der
|
* 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
|
* Umschalter der Konsole setzt dieses Attribut, und er ist getestet. Beide
|
||||||
* Schreibweisen werden unterstützt.
|
* Schreibweisen werden unterstützt.
|
||||||
* 3. **Die Schrift lädt nicht nach.** Geist Mono steht zuerst im Stapel und
|
* 3. **Geist Mono liegt im Paket.** Sie wird vom eigenen Ursprung ausgeliefert,
|
||||||
* wird verwendet, wenn sie auf dem Gerät liegt; sonst greift die
|
* nicht von einem CDN: Das verlangt die Content-Security-Policy, und ein
|
||||||
* System-Monospace. Eine Schrift von einem fremden Host zu holen verbietet
|
* Backup-Server, dessen Oberfläche von der Erreichbarkeit eines fremden
|
||||||
* die Content-Security-Policy der Auslieferung — und ein Backup-Server, der
|
* Hosts abhängt, wäre auch ohne CSP falsch.
|
||||||
* für seine Oberfläche ins Internet greift, wäre auch ohne CSP falsch.
|
|
||||||
*
|
*
|
||||||
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
|
* Die Benennung ist bewusst semantisch (`--surface-card`, `--text-primary`)
|
||||||
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
|
* statt shadcn-typisch (`--card`, `--foreground`): Die Zuordnung des Presets
|
||||||
@ -29,6 +28,45 @@
|
|||||||
|
|
||||||
@import 'tailwindcss';
|
@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
|
/* Beide Schreibweisen: Der Umschalter setzt `data-theme`, `.dark` ist die
|
||||||
* shadcn-übliche Form und kostet nichts. */
|
* shadcn-übliche Form und kostet nichts. */
|
||||||
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));
|
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *, .dark, .dark *));
|
||||||
|
|||||||
@ -40,6 +40,28 @@ readonly defaultRepositoryPath="/srv/syncova-repository"
|
|||||||
# backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf.
|
# backupDirectory nimmt Sicherungen von Datenbank und Konfiguration auf.
|
||||||
readonly backupDirectory="/var/backups/syncova"
|
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
|
# Ausgabe
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -212,6 +234,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--paket) packageDirectory="${2:-}"; shift 2 ;;
|
--paket) packageDirectory="${2:-}"; shift 2 ;;
|
||||||
--repository) repositoryPath="${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 ;;
|
--adresse) listenAddress="${2:-}"; shift 2 ;;
|
||||||
--admin) administratorName="${2:-}"; shift 2 ;;
|
--admin) administratorName="${2:-}"; shift 2 ;;
|
||||||
--ohne-haertung) createHardenedRepository="nein"; shift ;;
|
--ohne-haertung) createHardenedRepository="nein"; shift ;;
|
||||||
@ -1047,6 +1073,11 @@ noteCreated "directory:${configurationDirectory}"
|
|||||||
|
|
||||||
install -d -m 0700 -o "${serviceAccount}" -g "${serviceAccount}" "${backupDirectory}"
|
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}/"
|
cp -R "${packageDirectory}/bin" "${installationRoot}/"
|
||||||
[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/"
|
[[ -d "${packageDirectory}/web" ]] && cp -R "${packageDirectory}/web" "${installationRoot}/"
|
||||||
[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/"
|
[[ -d "${packageDirectory}/migrations" ]] && cp -R "${packageDirectory}/migrations" "${installationRoot}/"
|
||||||
@ -1296,7 +1327,7 @@ NoNewPrivileges=yes
|
|||||||
PrivateTmp=${privateTmpSetting}
|
PrivateTmp=${privateTmpSetting}
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
ReadWritePaths=${repositoryPath} ${backupDirectory}
|
ReadWritePaths=${repositoryPath} ${backupDirectory} ${restoreDirectory}${additionalRestoreRoots}
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
ProtectControlGroups=yes
|
ProtectControlGroups=yes
|
||||||
|
|||||||
0
untitled.md
Normal file
0
untitled.md
Normal file
Loading…
Reference in New Issue
Block a user