diff --git a/README.md b/README.md index 898f2a1..7bfef85 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,46 @@ hunderte pro Minute. --- +## Dateien von außen: Abgleich mit der Datenbank + +Aether führt zwei Bücher: die Bytes auf der Platte und die Einträge in der +Datenbank. Normalerweise entstehen beide zusammen. Wer Dateien **von außen** +hineinlegt – per SFTP oder aus einer Sicherung zurückgeholt –, erzeugt nur +das eine: Die Datei liegt da, aber Aether weiß nichts von ihr, zeigt sie +nicht an, und die Speicherbereinigung hält sie nach 24 Stunden für einen Rest +und löscht sie. + +Dagegen gibt es unter **Admin → Audit** den Knopf **Rescan storage**: + +| Fall | Was der Abgleich tut | +|---|---| +| Datei da, kein Eintrag | legt den Eintrag an – samt Ordnerkette, Prüfsumme und Dateityp | +| Eintrag da, Datei fort | entfernt den Eintrag, dazu Vorschaubild und frühere Fassungen | +| Datei von außen ersetzt | übernimmt neue Größe und Prüfsumme | + +**Zwei Schritte, nie einer.** *Check* liest nur und zeigt, was passieren +würde; erst *Apply* ändert etwas. Was hier gelöscht wird, ist nicht der +Papierkorb – es ist endgültig. + +Was der Abgleich **nicht** anfasst: `.trash/` und `.versions/`. Beide liegen +im selben Verzeichnisbaum, gehören aber dem System. Würden sie übernommen, +tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu einer +eigenen Datei. + +### Für Sicherungen heißt das + +Die Dateien liegen im Klartext unter `users//home//` – +eine Wiederherstellung einzelner Dateien aus einem Datei-Backup (etwa dem +File-Restore des Proxmox Backup Servers) ist damit möglich, ohne die Datenbank +mit zurückzuspielen. Der Weg ist: **Datei zurücklegen → Rescan → Check → +Apply.** Ohne den Abgleich bliebe sie unsichtbar. + +Für den umgekehrten Fall – ein Eintrag, dessen Datei aus der Sicherung +*nicht* zurückkam – zeigt derselbe Lauf die verwaisten Einträge und räumt sie +auf Wunsch weg. + +--- + ## Kalender (CalDAV) Adresse: `https:///caldav/` – oder gar nichts eintippen und die diff --git a/TESTING.md b/TESTING.md index edaa8f0..2d6d4cc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -135,6 +135,46 @@ diesem Bereich sucht: Freigabepfad lag außerhalb jeder Ratenbegrenzung; jeder Versuch rechnet Argon2, war also zugleich ein billiger Weg, den Server zu beschäftigen. +## Abgleich Platte ↔ Datenbank: Selbsttest per Skript + +Der Abgleich (Admin → Audit → **Rescan storage**) übernimmt Dateien, die von +außen dazugekommen sind – aus einer Sicherung zurückgeholt oder per SFTP +abgelegt – und **entfernt Einträge**, deren Datei fort ist. Das zweite ist der +Grund für diesen Test: Ein Fehler darin löscht Daten, die niemand vermisst, +bis er sie braucht. + + python3 scripts/rescan-selbsttest.py [BASIS-URL] [DATENPFAD] + +Der Test schreibt selbst Dateien ins Datenverzeichnis – er muss also auf dem +Rechner laufen, auf dem die Daten liegen (Vorgabe `~/aether-data`). + +20 Prüfungen in fünf Gruppen: **Prüfen ändert nichts** (zweimal geprüft +liefert dasselbe, nichts wird sichtbar), **Übernehmen** (Ordnerkette angelegt, +Prüfsumme und Größe stimmen, Dateityp erkannt, Herunterladen liefert die +Datei), **von außen ersetzt** (Größe und Prüfsumme werden berichtigt), +**Datei ist fort** (verwaister Eintrag wird gefunden, bleibt beim Prüfen +stehen, verschwindet erst beim Anwenden), **nicht anfassen** (Papierkorb und +frühere Fassungen liegen im selben Baum und dürfen nicht übernommen werden – +sonst tauchten gelöschte Dateien wieder auf und jede frühere Fassung würde zu +einer eigenen Datei), und **nur für Verwalter**. + +### Warum es das überhaupt gibt + +Der GC räumt Dateien weg, zu denen es keinen Eintrag gibt. Für Reste +gelöschter Dateien ist das richtig – für eine per SFTP abgelegte Datei ist es +falsch: Sie ist in Aether unsichtbar und nach 24 Stunden weg. Der Abgleich ist +die Gegenrichtung und macht aus solchen Dateien richtige Einträge. + +Zwei Fallen, die beim Bauen auffielen und die ein Test hier abdecken muss: + +- **Der Dateityp muss aus Endung oder Inhalt kommen.** Beim Hochladen schickt + der Browser ihn mit; eine per SFTP abgelegte Datei bringt niemanden mit, der + ihn kennt. Dazu kommt: Das Alpine-Abbild hat keine `/etc/mime.types`, und + Gos eingebaute Liste kennt nicht einmal `.txt`. +- **Prüfsumme nicht überspringen.** Sie kostet einen vollständigen Lesevorgang + je Datei, aber ein Eintrag ohne sie ist ein Eintrag, dessen Beschädigung + beim Herunterladen niemand bemerkt. + ## Notfall: Migration hängt fest ("Dirty database version") Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index 889a662..1caf298 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -32,6 +32,7 @@ import ( "aether/internal/gc" "aether/internal/office" appoidc "aether/internal/oidc" + "aether/internal/rescan" "aether/internal/sharing" "aether/internal/storage" "aether/internal/tags" @@ -222,7 +223,10 @@ func main() { collector := gc.New(pool, blobStore) adminStore := admin.NewStore(pool) - adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore) + // Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die + // andere Richtung: von der Platte in die Datenbank statt umgekehrt. + rescanner := rescan.New(pool, fileStore, blobStore) + adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner) // docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann // schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go @@ -585,6 +589,7 @@ func main() { r.Get("/admin/audit", adminHandler.ListAudit) r.Get("/admin/audit/actions", adminHandler.ListAuditActions) r.Post("/admin/gc", adminHandler.RunGC) + r.Post("/admin/rescan", adminHandler.Rescan) // Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen – // Platz, der in keinem Kontingent auftaucht. r.Get("/admin/storage/derived", adminHandler.DerivedStorage) diff --git a/backend/internal/admin/audit_handler.go b/backend/internal/admin/audit_handler.go index 3c82a2d..492ea21 100644 --- a/backend/internal/admin/audit_handler.go +++ b/backend/internal/admin/audit_handler.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "aether/internal/audit" + "aether/internal/rescan" "aether/pkg/response" ) @@ -118,3 +119,47 @@ func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) { } response.JSON(w, http.StatusOK, usage) } + +// POST /api/v1/admin/rescan[?anwenden=true] – Platte mit der Datenbank +// abgleichen. +// +// Die Gegenrichtung zum GC: Der räumt Dateien weg, zu denen es keinen Eintrag +// gibt. Hier geht es um Dateien, die von außen dazugekommen sind – aus einer +// Sicherung zurückgeholt oder per SFTP abgelegt. Ohne Abgleich sind sie in +// Aether unsichtbar, und der GC würde sie nach 24 Stunden für Reste halten. +// +// Ohne Parameter wird **nur gelesen**. Erst `anwenden=true` ändert etwas. +// Zwei Schritte, weil es um Dateien geht: Wer nicht vorher sehen kann, was +// passieren wird, kann es auch nicht verhindern. +func (h *Handler) Rescan(w http.ResponseWriter, r *http.Request) { + if _, ok := requireAdmin(r); !ok { + response.Error(w, http.StatusForbidden, "admin required") + return + } + if h.rescan == nil { + response.Error(w, http.StatusServiceUnavailable, "rescan not available") + return + } + + anwenden := r.URL.Query().Get("anwenden") == "true" + var ( + bef *rescan.Report + err error + ) + if anwenden { + bef, err = h.rescan.Apply(r.Context()) + } else { + bef, err = h.rescan.Check(r.Context()) + } + if err != nil { + response.Error(w, http.StatusInternalServerError, "rescan failed") + return + } + + if anwenden { + h.logAdmin(r, audit.ActionGarbageCollect, "storage", "", "", + fmt.Sprintf("rescan applied: %d adopted, %d corrected, %d rows removed, %d errors", + bef.Adopted, bef.Corrected, bef.Removed, bef.Errors)) + } + response.JSON(w, http.StatusOK, bef) +} diff --git a/backend/internal/admin/handler.go b/backend/internal/admin/handler.go index a076154..4841ae5 100644 --- a/backend/internal/admin/handler.go +++ b/backend/internal/admin/handler.go @@ -19,6 +19,7 @@ import ( "aether/internal/auth" "aether/internal/files" "aether/internal/gc" + "aether/internal/rescan" "aether/internal/storage" "aether/pkg/middleware" "aether/pkg/response" @@ -30,10 +31,11 @@ type Handler struct { auditor *audit.Logger gc *gc.Collector files *files.Store + rescan *rescan.Scanner } -func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store) *Handler { - return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore} +func NewHandler(store *Store, st storage.Storage, auditor *audit.Logger, collector *gc.Collector, fileStore *files.Store, scanner *rescan.Scanner) *Handler { + return &Handler{store: store, storage: st, auditor: auditor, gc: collector, files: fileStore, rescan: scanner} } // logAdmin schreibt einen Audit-Eintrag für eine Admin-Aktion. Bündelt das diff --git a/backend/internal/rescan/apply.go b/backend/internal/rescan/apply.go new file mode 100644 index 0000000..39c059b --- /dev/null +++ b/backend/internal/rescan/apply.go @@ -0,0 +1,236 @@ +package rescan + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// checksumOf liest die Datei einmal ganz und liefert Prüfsum und Größe. +// +// Ohne sie ginge es schneller – aber eine Datei ohne Prüfsum in der +// Datenbank ist eine Datei, deren Beschädigung beim Herunterladen niemand +// bemerkt. Das ist der Punkt, an dem Aether sonst gerade nicht spart. +// Der Anfang der Datei genügt zur Typerkennung – http.DetectContentType +// sieht sich ohnehin nur die ersten 512 Bytes an. +const sniffLen = 512 + +func (s *Scanner) checksumOf(ctx context.Context, key string) (sum string, size int64, head []byte, err error) { + rc, err := s.storage.Get(ctx, key) + if err != nil { + return "", 0, nil, fmt.Errorf("lesen: %w", err) + } + defer rc.Close() + + h := sha256.New() + // Die Datei wird für die Prüfsumme ohnehin einmal ganz gelesen; den + // Anfang dabei mitzunehmen kostet nichts und erspart einen zweiten Weg + // über die Platte. + kopf := &bytes.Buffer{} + n, err := io.Copy(h, io.TeeReader(rc, &begrenzterSchreiber{buf: kopf, rest: sniffLen})) + if err != nil { + return "", 0, nil, fmt.Errorf("lesen: %w", err) + } + return hex.EncodeToString(h.Sum(nil)), n, kopf.Bytes(), nil +} + +// begrenzterSchreiber nimmt nur die ersten rest Bytes an und wirft den Rest +// weg – ohne das läge bei einer 5-GB-Datei alles im Arbeitsspeicher. +type begrenzterSchreiber struct { + buf *bytes.Buffer + rest int +} + +func (w *begrenzterSchreiber) Write(p []byte) (int, error) { + if w.rest > 0 { + n := min(w.rest, len(p)) + w.buf.Write(p[:n]) + w.rest -= n + } + return len(p), nil +} + +// mimeOf bestimmt den Dateityp – erst über die Endung, sonst über den +// Inhalt. +// +// Der Umweg über den Inhalt ist nötig, nicht schmückend: Das Alpine-Abbild +// bringt keine /etc/mime.types mit, und Gos eingebaute Liste kennt gerade +// einmal ein Dutzend Endungen – .txt gehört nicht dazu. Ohne den zweiten +// Anlauf bekäme jede Textdatei "application/octet-stream" und würde in der +// Vorschau nicht angezeigt. +// +// Beim Hochladen fällt das nie auf, weil der Browser den Typ mitschickt. +// Eine per SFTP abgelegte Datei bringt niemanden mit, der ihn kennt. +func mimeOf(name string, head []byte) string { + if t := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); t != "" { + return ohneParameter(t) + } + if len(head) > 0 { + return ohneParameter(http.DetectContentType(head)) + } + return "application/octet-stream" +} + +// "text/plain; charset=utf-8" → "text/plain", so wie es der Upload-Weg auch +// speichert. +func ohneParameter(t string) string { + if i := strings.Index(t, ";"); i > 0 { + return strings.TrimSpace(t[:i]) + } + return t +} + +// resolveOwner findet heraus, wem eine gefundene Datei gehört: das Konto +// mit diesem Namen, oder die Gruppe mit dieser Kennung. +func (s *Scanner) resolveOwner(ctx context.Context, root, owner string) (ownerID, groupID *uuid.UUID, err error) { + if root == "groups" { + id, err := uuid.Parse(owner) + if err != nil { + return nil, nil, fmt.Errorf("keine Gruppenkennung: %q", owner) + } + var exists bool + if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM groups WHERE id = $1)`, id).Scan(&exists); err != nil { + return nil, nil, err + } + if !exists { + return nil, nil, fmt.Errorf("Gruppe %s gibt es nicht", owner) + } + return nil, &id, nil + } + + var id uuid.UUID + err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE username = $1`, owner).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, fmt.Errorf("Konto %q gibt es nicht", owner) + } + if err != nil { + return nil, nil, err + } + return &id, nil, nil +} + +// ensureFolders legt die Ordnerkette an, in der die Datei liegt, und liefert +// den innersten Ordner. Ein Ordner, den es schon gibt, wird benutzt. +func (s *Scanner) ensureFolders(ctx context.Context, ownerID, groupID *uuid.UUID, folderPath string) (*uuid.UUID, error) { + if folderPath == "" { + return nil, nil // liegt direkt im Wurzelverzeichnis + } + var parent *uuid.UUID + for _, name := range strings.Split(folderPath, "/") { + if name == "" { + continue + } + var id uuid.UUID + // COALESCE statt zweier Abfragen: Ein Ordner hängt entweder an einem + // Konto oder an einer Gruppe, nie an beidem. + err := s.db.QueryRow(ctx, + `SELECT id FROM folders + WHERE COALESCE(owner_id, group_id) = COALESCE($1::uuid, $2::uuid) + AND name = $3 + AND parent_id IS NOT DISTINCT FROM $4`, + ownerID, groupID, name, parent, + ).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + err = s.db.QueryRow(ctx, + `INSERT INTO folders (owner_id, group_id, parent_id, name) + VALUES ($1, $2, $3, $4) RETURNING id`, + ownerID, groupID, parent, name, + ).Scan(&id) + } + if err != nil { + return nil, fmt.Errorf("Ordner %q: %w", name, err) + } + copy := id + parent = © + } + return parent, nil +} + +// adopt macht aus einer Datei auf der Platte einen Entry in Aether. +func (s *Scanner) adopt(ctx context.Context, root, owner, folder, name, key string) error { + ownerID, groupID, err := s.resolveOwner(ctx, root, owner) + if err != nil { + return err + } + folderID, err := s.ensureFolders(ctx, ownerID, groupID, folder) + if err != nil { + return err + } + sum, size, head, err := s.checksumOf(ctx, key) + if err != nil { + return err + } + + _, err = s.db.Exec(ctx, + `INSERT INTO files (owner_id, group_id, folder_id, name, mime_type, size, checksum, storage_key) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + ownerID, groupID, folderID, name, mimeOf(name, head), size, sum, key) + if err != nil { + return fmt.Errorf("Entry anlegen: %w", err) + } + return nil +} + +// correct übernimmt Größe und Prüfsum einer von außen ersetzten Datei. +func (s *Scanner) correct(ctx context.Context, id uuid.UUID, key string) error { + sum, size, _, err := s.checksumOf(ctx, key) + if err != nil { + return err + } + _, err = s.db.Exec(ctx, + `UPDATE files SET size = $2, checksum = $3, updated_at = now() WHERE id = $1`, + id, size, sum) + return err +} + +// remove räumt einen Entry weg, dessen Datei nicht mehr exists ist – und mit +// ihm alles, was nur wegen dieser Datei existierte: frühere Fassungen, +// Vorschaubild, umgewandelte Ansicht. +// +// Die Reihenfolge ist Absicht: erst die Schlüssel der früheren Fassungen +// sample, dann die Zeile löschen (die Kaskade nimmt die Versionszeilen mit), +// dann die Bytes. Andersherum bliebe bei einem Abbruch eine Zeile stehen, die +// auf nichts mehr zeigt – also genau das, was hier aufgeräumt werden soll. +func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error { + rows, err := s.db.Query(ctx, `SELECT storage_key FROM file_versions WHERE file_id = $1`, id) + if err != nil { + return fmt.Errorf("frühere Fassungen lesen: %w", err) + } + var versionKeys []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + rows.Close() + return err + } + versionKeys = append(versionKeys, k) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + if _, err := s.db.Exec(ctx, `DELETE FROM files WHERE id = $1`, id); err != nil { + return fmt.Errorf("Entry remove: %w", err) + } + + // Ab hier ist der Entry weg; was jetzt noch scheitert, ist ein Rest auf + // der Platte, den der GC beim nächsten Lauf ohnehin einsammelt. + for _, k := range versionKeys { + _ = s.storage.Delete(ctx, k) + } + _ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg") + _ = s.storage.Delete(ctx, "previews/"+id.String()+".pdf") + return nil +} diff --git a/backend/internal/rescan/rescan.go b/backend/internal/rescan/rescan.go new file mode 100644 index 0000000..50d090c --- /dev/null +++ b/backend/internal/rescan/rescan.go @@ -0,0 +1,243 @@ +// Package rescan gleicht die Platte mit der Datenbank ab – in der Richtung, +// die der GC nicht abdeckt. +// +// Der GC (internal/gc) räumt Dateien weg, zu denen es keinen Eintrag mehr +// gibt. Das ist richtig für Reste gelöschter Dateien, aber falsch für +// Dateien, die von außen dazugekommen sind: per SFTP abgelegt oder aus einer +// Sicherung zurückgeholt. Ohne diesen Abgleich sind sie in Aether unsichtbar +// – und der GC hält sie nach 24 Stunden für Reste. +// +// Deshalb zwei Schritte, nie einer: Check liest nur, Apply ändert. Ein Knopf, +// der beides in einem tut, gibt niemandem die Gelegenheit, vorher hinzusehen. +// Hier geht es um Dateien. +package rescan + +import ( + "context" + "errors" + "fmt" + "path" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "aether/internal/files" + "aether/internal/storage" +) + +// Grenzen, damit ein Abgleich auf großem Bestand nicht endlos läuft und die +// Antwort nicht ins Uferlose wächst. +const ( + maxSamples = 200 + maxDuration = 10 * time.Minute +) + +type Kind string + +const ( + // KindNew: Datei liegt auf der Platte, kein Eintrag in der Datenbank. + // Apply macht daraus einen Eintrag – sie gehört dem Konto, in dessen + // Verzeichnis sie liegt. + KindNew Kind = "new" + // KindOrphaned: Eintrag da, Datei fort. Apply entfernt den Eintrag samt + // Vorschaubild und früheren Fassungen. + KindOrphaned Kind = "orphaned" + // KindChanged: beides da, aber die Größe weicht ab – jemand hat die Datei + // von außen ersetzt. Apply übernimmt Größe und Prüfsumme. + KindChanged Kind = "changed" +) + +type Entry struct { + Kind Kind `json:"kind"` + StorageKey string `json:"storage_key"` + // Path ist der Pfad, wie er in Aether erscheint – nicht der auf der Platte. + Path string `json:"path"` + Owner string `json:"owner"` + Size int64 `json:"size"` + Reason string `json:"reason,omitempty"` +} + +type Report struct { + New int `json:"new"` + Orphaned int `json:"orphaned"` + Changed int `json:"changed"` + Scanned int `json:"scanned"` + Samples []Entry `json:"samples"` + Aborted bool `json:"aborted,omitempty"` + + // Nur bei Apply gefüllt. + Adopted int `json:"adopted,omitempty"` + Removed int `json:"removed,omitempty"` + Corrected int `json:"corrected,omitempty"` + Errors int `json:"errors,omitempty"` +} + +type Scanner struct { + db *pgxpool.Pool + store *files.Store + storage storage.Storage +} + +func New(db *pgxpool.Pool, store *files.Store, st storage.Storage) *Scanner { + return &Scanner{db: db, store: store, storage: st} +} + +// known hält einen Datenbankeintrag so, wie der Abgleich ihn braucht: über +// den Ablagepfad auffindbar. +type known struct { + id uuid.UUID + size int64 + deleted bool + seen bool +} + +func (s *Scanner) knownFiles(ctx context.Context) (map[string]*known, error) { + rows, err := s.db.Query(ctx, `SELECT id, storage_key, size, is_deleted FROM files`) + if err != nil { + return nil, fmt.Errorf("read known files: %w", err) + } + defer rows.Close() + + out := make(map[string]*known, 1024) + for rows.Next() { + var k known + var key string + if err := rows.Scan(&k.id, &key, &k.size, &k.deleted); err != nil { + return nil, fmt.Errorf("scan file row: %w", err) + } + row := k + out[key] = &row + } + return out, rows.Err() +} + +// splitKey liest aus einem Ablagepfad, wohin die Datei gehört: +// +// users/{name}/home/{ordner...}/{datei} +// groups/{id}/home/{ordner...}/{datei} +// +// Alles andere (.trash, .versions, thumbs, previews, avatars, branding, tmp) +// ist kein Nutzerinhalt und wird nicht angefasst. +func splitKey(key string) (root, owner, folderPath, name string, ok bool) { + parts := strings.Split(key, "/") + if len(parts) < 4 { + return "", "", "", "", false + } + if parts[0] != "users" && parts[0] != "groups" { + return "", "", "", "", false + } + if parts[2] != "home" { + return "", "", "", "", false // .trash und .versions gehören dem System + } + name = parts[len(parts)-1] + folderPath = strings.Join(parts[3:len(parts)-1], "/") + return parts[0], parts[1], folderPath, name, name != "" +} + +// Check liest nur und ändert nichts – jederzeit gefahrlos. +func (s *Scanner) Check(ctx context.Context) (*Report, error) { return s.run(ctx, false) } + +// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste +// Einträge. +func (s *Scanner) Apply(ctx context.Context) (*Report, error) { return s.run(ctx, true) } + +func (s *Scanner) run(ctx context.Context, apply bool) (*Report, error) { + ctx, cancel := context.WithTimeout(ctx, maxDuration) + defer cancel() + + known, err := s.knownFiles(ctx) + if err != nil { + return nil, err + } + + // Bewusst eine leere Liste statt nil: Als JSON wird daraus [] statt null, + // und kein Aufrufer braucht dafür einen Sonderfall. + rep := &Report{Samples: []Entry{}} + sample := func(e Entry) { + if len(rep.Samples) < maxSamples { + rep.Samples = append(rep.Samples, e) + } + } + + for _, prefix := range []string{"users/", "groups/"} { + err := s.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error { + if ctx.Err() != nil { + rep.Aborted = true + return ctx.Err() + } + root, owner, folder, name, ok := splitKey(key) + if !ok { + return nil + } + rep.Scanned++ + + if row, exists := known[key]; exists { + row.seen = true + if row.deleted { + return nil // liegt im Papierkorb – kein Widerspruch + } + if row.size != info.Size { + rep.Changed++ + e := Entry{Kind: KindChanged, StorageKey: key, + Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size} + if apply { + if err := s.correct(ctx, row.id, key); err != nil { + e.Reason = err.Error() + rep.Errors++ + } else { + rep.Corrected++ + } + } + sample(e) + } + return nil + } + + rep.New++ + e := Entry{Kind: KindNew, StorageKey: key, + Path: path.Join(owner, folder, name), Owner: owner, Size: info.Size} + if apply { + if err := s.adopt(ctx, root, owner, folder, name, key); err != nil { + e.Reason = err.Error() + rep.Errors++ + } else { + rep.Adopted++ + } + } + sample(e) + return nil + }) + if err != nil && !errors.Is(err, context.DeadlineExceeded) { + return nil, fmt.Errorf("walk %s: %w", prefix, err) + } + } + + // Einträge, deren Datei fehlt. Papierkorb und frühere Fassungen liegen + // außerhalb des Durchgangs – für die wird einzeln nachgesehen, statt sie + // pauschal für verwaist zu erklären. + for key, row := range known { + if row.seen || row.deleted { + continue + } + if _, _, _, _, ok := splitKey(key); !ok { + if _, err := s.storage.Stat(ctx, key); err == nil { + continue + } + } + rep.Orphaned++ + e := Entry{Kind: KindOrphaned, StorageKey: key, Path: key} + if apply { + if err := s.remove(ctx, row.id); err != nil { + e.Reason = err.Error() + rep.Errors++ + } else { + rep.Removed++ + } + } + sample(e) + } + + return rep, nil +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 248c823..880396a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -103,6 +103,32 @@ export interface PublicShareInfo { mime_type: string } +/** Ein einzelner Befund des Abgleichs zwischen Platte und Datenbank. */ +export interface RescanEntry { + kind: 'new' | 'orphaned' | 'changed' + storage_key: string + /** Pfad, wie er in Aether erscheint – nicht der auf der Platte. */ + path: string + owner: string + size: number + /** Gesetzt, wenn das Anwenden an diesem Eintrag gescheitert ist. */ + reason?: string +} + +export interface RescanReport { + new: number + orphaned: number + changed: number + scanned: number + samples: RescanEntry[] + aborted?: boolean + /** Nur nach dem Anwenden gefüllt. */ + adopted?: number + removed?: number + corrected?: number + errors?: number +} + export interface FileVersion { id: string file_id: string @@ -851,6 +877,10 @@ export const adminApi = { derivedStorage: () => request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'), + /** Abgleich Platte ↔ Datenbank. Ohne `apply` wird **nur gelesen**. */ + rescan: (apply = false) => + request('POST', `/admin/rescan${apply ? '?anwenden=true' : ''}`), + runCleanup: () => request<{ scanned: number; orphans: number; deleted: number freed_bytes: number; skipped_young: number diff --git a/frontend/src/pages/admin/AuditTab.tsx b/frontend/src/pages/admin/AuditTab.tsx index 55eb93b..3329dca 100644 --- a/frontend/src/pages/admin/AuditTab.tsx +++ b/frontend/src/pages/admin/AuditTab.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import RescanCard from './RescanCard' import { adminApi, type AuditEntry, type OcrStatus } from '../../api/client' import { formatBytes } from './ui' @@ -176,6 +177,7 @@ function StorageCleanup() { return ( <> +
= { + new: 'New on disk', + orphaned: 'Record without file', + changed: 'Replaced on disk', +} + +const kindColor: Record = { + new: 'var(--color-primary)', + orphaned: 'var(--color-danger)', + changed: 'var(--color-accent)', +} + +/** + * Abgleich zwischen Platte und Datenbank – die Gegenrichtung zur + * Speicherbereinigung daneben. + * + * Bewusst in zwei Schritten: „Check" liest nur, „Apply" ändert. Ein einzelner + * Knopf, der sofort loslegt, gäbe niemandem die Gelegenheit, vorher + * hinzusehen – und hier werden Einträge angelegt und gelöscht. + */ +export default function RescanCard() { + const [busy, setBusy] = useState<'check' | 'apply' | null>(null) + const [report, setReport] = useState(null) + const [applied, setApplied] = useState(false) + const [error, setError] = useState('') + + const pending = report ? report.new + report.orphaned + report.changed : 0 + + async function run(apply: boolean) { + setBusy(apply ? 'apply' : 'check') + setError('') + try { + const r = await adminApi.rescan(apply) + setReport(r) + setApplied(apply) + } catch { + setError(apply ? 'Applying failed' : 'Check failed') + } finally { + setBusy(null) + } + } + + return ( +
+
+
+
+ Rescan storage +
+
+ {error ? {error} + : report ? summary(report, applied) + : 'Finds files placed on disk from outside — restored from a backup or copied in over SFTP — ' + + 'and records whose file is gone. Checking changes nothing.'} +
+
+ + {report && !applied && pending > 0 && ( + + )} +
+ + {report && report.samples.length > 0 && ( +
+ {report.samples.map(e => ( +
+ + {kindLabel[e.kind]} + + + {e.path} + + {e.reason + ? {e.reason} + : + {e.kind === 'orphaned' ? '' : formatBytes(e.size)} + } +
+ ))} +
+ )} +
+ ) +} + +function summary(r: RescanReport, applied: boolean): string { + const pending = r.new + r.orphaned + r.changed + if (applied) { + const teile = [ + r.adopted ? `${r.adopted} adopted` : '', + r.corrected ? `${r.corrected} corrected` : '', + r.removed ? `${r.removed} records removed` : '', + r.errors ? `${r.errors} failed` : '', + ].filter(Boolean) + return teile.length ? teile.join(', ') : 'Nothing changed' + } + if (pending === 0) { + return `Everything matches — ${r.scanned} stored files checked` + } + const teile = [ + r.new ? `${r.new} new on disk` : '', + r.changed ? `${r.changed} replaced` : '', + r.orphaned ? `${r.orphaned} records without a file` : '', + ].filter(Boolean) + return `${teile.join(', ')} (${r.scanned} checked)` + + (r.aborted ? ' — stopped early, run again' : '') +} diff --git a/scripts/rescan-selbsttest.py b/scripts/rescan-selbsttest.py new file mode 100644 index 0000000..7ed7889 --- /dev/null +++ b/scripts/rescan-selbsttest.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Selbsttest des Abgleichs zwischen Platte und Datenbank. + + python3 scripts/rescan-selbsttest.py [BASIS-URL] [DATENPFAD] [BENUTZER] [PASSWORT] + +Der Abgleich (Admin → Speicher → „Rescan storage") übernimmt Dateien, die von +außen dazugekommen sind, und **entfernt Einträge**, deren Datei fort ist. Das +zweite ist der Grund für diesen Test: Ein Fehler darin löscht Daten, die +niemand vermisst, bis er sie braucht. + +Der Test schreibt selbst Dateien in das Datenverzeichnis – er muss also auf +dem Rechner laufen, auf dem die Daten liegen, und räumt am Ende auf. +""" +import hashlib +import http.cookiejar +import json +import os +import shutil +import sys +import urllib.error +import urllib.request +import uuid + +BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090").rstrip("/") +DATEN = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/aether-data") +USER = sys.argv[3] if len(sys.argv) > 3 else "admin" +PASSWORT = sys.argv[4] if len(sys.argv) > 4 else "admin123" + +ok_count = 0 +fail = [] + + +def check(name, cond, detail=""): + global ok_count + if cond: + ok_count += 1 + print(f" \033[32mok\033[0m {name}") + else: + fail.append(name) + print(f" \033[31mFEHL\033[0m {name} {detail}") + + +jar = http.cookiejar.CookieJar() +OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + + +def api(method, path, body=None, headers=None, opener=None, roh=False): + data = json.dumps(body).encode() if body is not None else None + hdrs = dict(headers or {}) + hdrs["Content-Type"] = "application/json" + req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method=method) + try: + with (opener or OPENER).open(req) as r: + payload = r.read() + if roh: + return r.status, payload + text = payload.decode() + return r.status, (json.loads(text).get("data") if text else None) + except urllib.error.HTTPError as e: + return e.code, None + + +st, login = api("POST", "/api/v1/auth/login", {"username": USER, "password": PASSWORT}) +assert st == 200, f"Anmeldung fehlgeschlagen: {st}" +CSRF = {"X-CSRF-Token": login["csrf_token"]} + +HOME = os.path.join(DATEN, "users", USER, "home") +assert os.path.isdir(HOME), f"Datenverzeichnis nicht gefunden: {HOME}" + +MARKE = "selbsttest-" + uuid.uuid4().hex[:8] +ORDNER = os.path.join(HOME, MARKE) +angelegt = [] + + +def aufraeumen(): + shutil.rmtree(ORDNER, ignore_errors=True) + # Was der Abgleich angelegt hat, wieder wegräumen – über die + # Schnittstelle, damit auch die Einträge verschwinden. + _, lst = api("GET", "/api/v1/files") + for f in (lst or {}).get("folders", []): + if f["name"] == MARKE: + api("DELETE", f"/api/v1/folders/{f['id']}", None, CSRF) + api("POST", "/api/v1/admin/rescan?anwenden=true", {}, CSRF) + + +def schreiben(name, inhalt: bytes): + os.makedirs(ORDNER, exist_ok=True) + pfad = os.path.join(ORDNER, name) + with open(pfad, "wb") as f: + f.write(inhalt) + return pfad + + +def rescan(anwenden=False): + st, r = api("POST", f"/api/v1/admin/rescan{'?anwenden=true' if anwenden else ''}", {}, CSRF) + assert st == 200, f"Abgleich fehlgeschlagen: {st}" + return r + + +def eigene(r): + return [e for e in (r.get("samples") or []) + if MARKE in e["path"] or MARKE in e["storage_key"]] + + +print(f"Abgleich-Selbsttest gegen {BASE}\nDaten: {DATEN}\n") + +try: + print("── Prüfen ändert nichts ──") + INHALT = b"aus der Sicherung zurueckgeholt\n" + schreiben("beleg.txt", INHALT) + + vorher = rescan() + meine = eigene(vorher) + check("neue Datei wird gefunden", + len(meine) == 1 and meine[0]["kind"] == "new", meine) + + _, lst = api("GET", "/api/v1/files") + check("aber noch nicht sichtbar", + all(f["name"] != MARKE for f in lst["folders"]), lst["folders"]) + + nochmal = rescan() + check("zweimal prüfen liefert dasselbe", + len(eigene(nochmal)) == 1, eigene(nochmal)) + + print("\n── Übernehmen ──") + r = rescan(anwenden=True) + check("meldet eine Übernahme", r["adopted"] >= 1 and r.get("errors", 0) == 0, + {k: v for k, v in r.items() if k != "samples"}) + + _, lst = api("GET", "/api/v1/files") + ordner = {f["name"]: f for f in lst["folders"]} + check("Ordner ist angelegt", MARKE in ordner, list(ordner)) + _, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}") + datei = next((f for f in drin["files"] if f["name"] == "beleg.txt"), None) + check("Datei ist sichtbar", datei is not None, drin["files"]) + # Ohne richtige Prüfsumme merkt niemand, wenn die Datei später kippt – + # genau die Zusage, die Aether sonst überall einhält. + check("Prüfsumme stimmt", + datei and datei["checksum"] == hashlib.sha256(INHALT).hexdigest(), datei) + check("Größe stimmt", datei and datei["size"] == len(INHALT), datei) + # Beim Hochladen schickt der Browser den Typ mit; hier gibt es niemanden, + # der ihn kennt – er muss aus Endung oder Inhalt kommen. + check("Dateityp erkannt", datei and datei["mime_type"] == "text/plain", + datei["mime_type"] if datei else None) + + st, inhalt = api("GET", f"/api/v1/files/{datei['id']}/download", None, None, roh=True) + check("Herunterladen liefert die Datei", st == 200 and inhalt == INHALT, inhalt[:40]) + + check("danach ist nichts mehr offen", len(eigene(rescan())) == 0, eigene(rescan())) + + print("\n── Von außen ersetzt ──") + NEU = b"jemand hat sie per SFTP ueberschrieben\n" + schreiben("beleg.txt", NEU) + r = rescan() + meine = eigene(r) + check("Änderung wird bemerkt", len(meine) == 1 and meine[0]["kind"] == "changed", meine) + + rescan(anwenden=True) + _, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}") + datei = next(f for f in drin["files"] if f["name"] == "beleg.txt") + check("Größe berichtigt", datei["size"] == len(NEU), datei["size"]) + check("Prüfsumme berichtigt", + datei["checksum"] == hashlib.sha256(NEU).hexdigest(), datei["checksum"]) + + print("\n── Datei ist fort ──") + os.remove(os.path.join(ORDNER, "beleg.txt")) + r = rescan() + meine = eigene(r) + check("verwaister Eintrag wird gefunden", + len(meine) == 1 and meine[0]["kind"] == "orphaned", meine) + + _, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}") + check("solange nur geprüft wird, bleibt der Eintrag", + any(f["name"] == "beleg.txt" for f in drin["files"]), drin["files"]) + + rescan(anwenden=True) + _, drin = api("GET", f"/api/v1/files?folder_id={ordner[MARKE]['id']}") + check("nach dem Anwenden ist er weg", + all(f["name"] != "beleg.txt" for f in drin["files"]), drin["files"]) + + print("\n── Was der Abgleich **nicht** anfassen darf ──") + # Papierkorb und frühere Fassungen liegen im selben Baum. Würden sie + # übernommen, tauchten gelöschte Dateien wieder auf und jede frühere + # Fassung würde zu einer eigenen Datei. + trash = os.path.join(DATEN, "users", USER, ".trash") + versions = os.path.join(DATEN, "users", USER, ".versions") + os.makedirs(trash, exist_ok=True) + os.makedirs(versions, exist_ok=True) + t1 = os.path.join(trash, MARKE + "-papierkorb.txt") + v1 = os.path.join(versions, MARKE + "-fassung") + open(t1, "wb").write(b"im Papierkorb\n") + open(v1, "wb").write(b"fruehere Fassung\n") + r = rescan() + check("Papierkorb bleibt unangetastet", + not any("papierkorb" in e["storage_key"] for e in r["samples"]), r["samples"][:3]) + check("frühere Fassungen bleiben unangetastet", + not any(".versions" in e["storage_key"] for e in r["samples"]), r["samples"][:3]) + os.remove(t1) + os.remove(v1) + + print("\n── Nur für Verwalter ──") + fremd_name = "rescan-selbsttest-fremd" + fremd_pw = "Fremd!" + uuid.uuid4().hex[:12] + st, fremd = api("POST", "/api/v1/admin/users", + {"username": fremd_name, "display_name": "Fremd", + "password": fremd_pw, "is_admin": False, "quota_bytes": 0}, CSRF) + if st in (200, 201): + jar2 = http.cookiejar.CookieJar() + op2 = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar2)) + _, l2 = api("POST", "/api/v1/auth/login", + {"username": fremd_name, "password": fremd_pw}, None, op2) + st, _ = api("POST", "/api/v1/admin/rescan", {}, + {"X-CSRF-Token": l2["csrf_token"]}, op2) + check("gewöhnliches Konto darf nicht", st == 403, st) + api("DELETE", f"/api/v1/admin/users/{fremd['id']}", None, CSRF) + +finally: + aufraeumen() + +print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen") +if fail: + print("Fehlgeschlagen: " + ", ".join(fail)) +sys.exit(1 if fail else 0) diff --git a/scripts/sharing-selbsttest.py b/scripts/sharing-selbsttest.py index f8f28a5..27fba44 100644 --- a/scripts/sharing-selbsttest.py +++ b/scripts/sharing-selbsttest.py @@ -101,7 +101,7 @@ admin, ADMIN_CSRF = anmelden(USER, PASSWORT) # ── Zweitnutzer: nur mit ihm lässt sich prüfen, dass fremde Dateien fremd # bleiben. Ein Test, der nur das eigene Konto kennt, kann das nicht. -fremd_name = "selbsttest-fremd" +fremd_name = "selbsttest-fremd-" + uuid.uuid4().hex[:6] fremd_pw = "Fremd!" + uuid.uuid4().hex[:12] st, fremd = ruf(admin, "POST", "/api/v1/admin/users", {"username": fremd_name, "display_name": "Selbsttest Fremd", @@ -119,6 +119,12 @@ def aufraeumen(): ruf(admin, "DELETE", f"/api/v1/shares/{lid}", None, ADMIN_CSRF) if eigen: ruf(admin, "DELETE", f"/api/v1/files/{eigen['id']}", None, ADMIN_CSRF) + # Löschen heißt Papierkorb – ein Test, der nur so weit aufräumt, hinterlässt + # bei jedem Lauf eine Leiche mehr. Also auch dort hinterher. + _, muell = ruf(admin, "GET", "/api/v1/trash") + for f in muell or []: + if f.get("name", "").startswith("selbsttest-"): + ruf(admin, "DELETE", f"/api/v1/trash/{f['id']}", None, ADMIN_CSRF) ruf(admin, "DELETE", f"/api/v1/admin/users/{FREMD_ID}", None, ADMIN_CSRF)