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