feat(admin): Abgleich zwischen Platte und Datenbank
Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.
Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:
Datei da, kein Eintrag Eintrag anlegen, samt Ordnerkette,
Pruefsumme und Dateityp
Eintrag da, Datei fort Eintrag entfernen, dazu Vorschaubild und
fruehere Fassungen
von aussen ersetzt Groesse und Pruefsumme uebernehmen
Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.
Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.
Zwei Fallen, die erst der Praxistest zeigte:
- 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 hat das Alpine-Abbild keine
/etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
jede Textdatei waere als application/octet-stream gelandet.
- COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
Text und bricht mit "operator does not exist: uuid = text" ab. Die
Ordnerkette wurde nie angelegt.
Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.
scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fce8053d09
commit
55dcf517f2
12 files changed
+1029
-4
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user