Files
Aether/backend/internal/rescan/rescan.go
T
marcandClaude Opus 5 662c0ebdc1 security(rescan): Notbremse vor dem harten Löschen
internal/rescan ist das einzige Stück Aether, das hart löscht: DELETE FROM
files, kein Papierkorb. Entfernt wird, was beim Durchgang über die Platte nicht
gesehen wurde.

Der Ernstfall ist nicht ausgedacht. LocalFS.Walk gibt für ein nicht vorhandenes
Verzeichnis nil zurück – kein Fehler, keine Einträge. Genau der Fingerabdruck
eines Bind-Mounts, der nach einem Neustart nicht hochkam, oder eines
Tippfehlers in DATA_PATH. Dann gilt jede Datei als verwaist, und ein einziger
Aufruf mit ?anwenden=true löscht den gesamten Bestand aus der Datenbank. Die
Bytes lägen noch da, aber niemand wüsste mehr, dass es sie gibt – und der GC
räumte sie 24 Stunden später ab.

Der GC hat diese Bremse seit dem 12.09. Beide gleichen Platte und Datenbank ab,
nur aus entgegengesetzter Richtung, und beide haben denselben Fehlerfall: Eine
Seite antwortet fälschlich mit „leer". Der GC löscht dann Blobs – die Datenbank
weiss hinterher noch, was fehlt. Hier fällt die Zeile selbst. Die gefährlichere
Seite war die ungesicherte.

Zwei Bremsen: Die Notbremse (nichts gefunden, Datenbank kennt Dateien) lässt
sich nicht übersteuern. Die Mengenbremse (mehr als die Hälfte) verlangt
&bestaetigt=true. Die leere Installation ist ausgenommen.

Dazu umgebaut: erst sammeln, dann entscheiden, dann löschen. Vorher wurde
mitten in der Schleife gelöscht; die Gesamtzahl kennt man dort erst, wenn die
Hälfte schon weg ist.

Der Bericht nennt den Grund und ob er bestätigbar ist – die Oberfläche soll
keinen Knopf anbieten, der nichts bewirkt. Ein abgebrochener Lauf steht rot in
der Karte und im Audit-Log; gerade der, denn er ist der Hinweis darauf, dass
mit dem Datenordner etwas nicht stimmt.

Fünf Tests, unter Mutation nachgewiesen. Der Ernstfall läuft am echten Gerät:
fünf Zeilen in der Datenbank, ein Wurzelverzeichnis ohne users/ – nach Apply
stehen alle fünf noch, auch mit Bestätigung.

audit und thumbnails bekommen bewusst keine Go-Tests; die Begründung steht in
BEFUNDE.md statt als ewig offener Punkt.

Nebenbefund behoben: kalender-teilen-selbsttest.py leckte je Lauf ein
App-Passwort des Besitzers. Nach achtzehn Läufen war maxAppPasswords = 20
erreicht, und zwei Suiten scheiterten mit 409, ohne dass am Code etwas kaputt
war.

Geprüft: go build/vet sauber, alle Go-Pakete grün, 243 Prüfungen der sieben
Selbsttest-Suiten grün, 48 Vitest-Tests, tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 15:26:17 +02:00

335 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"github.com/rs/zerolog/log"
"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"`
// AbortReason sagt, **warum** nichts geändert wurde. Ein „aborted: true"
// ohne Grund lässt genau die Frage offen, die man dann hat.
AbortReason string `json:"abort_reason,omitempty"`
// Overridable unterscheidet die beiden Bremsen. Die Mengenbremse lässt
// sich bestätigen, die Notbremse nicht – und die Oberfläche soll keinen
// Knopf anbieten, der nichts bewirkt.
Overridable bool `json:"overridable,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, false) }
// Apply übernimmt neue Dateien, berichtigt geänderte und entfernt verwaiste
// Einträge.
// trotzdem hebt die Mengenbremse auf – nicht die Notbremse. Siehe pruefeBremse.
func (s *Scanner) Apply(ctx context.Context, trotzdem bool) (*Report, error) {
return s.run(ctx, true, trotzdem)
}
func (s *Scanner) run(ctx context.Context, apply, trotzdem 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.
// **Erst sammeln, dann entscheiden, dann löschen.** Vorher wurde mitten in
// der Schleife gelöscht – dann kennt man die Gesamtzahl erst, wenn die
// Hälfte schon weg ist, und eine Bremse käme zu spät.
type waise struct {
key string
id uuid.UUID
}
var waisen []waise
var lebende int
for key, row := range known {
if !row.deleted {
lebende++
}
if row.seen || row.deleted {
continue
}
if _, _, _, _, ok := splitKey(key); !ok {
if _, err := s.storage.Stat(ctx, key); err == nil {
continue
}
}
waisen = append(waisen, waise{key: key, id: row.id})
}
rep.Orphaned = len(waisen)
if apply {
if grund, uebersteuerbar := pruefeBremse(rep.Scanned, lebende, len(waisen), trotzdem); grund != "" {
rep.Aborted = true
rep.AbortReason = grund
rep.Overridable = uebersteuerbar
log.Ctx(ctx).Error().
Int("scanned", rep.Scanned).
Int("lebende_eintraege", lebende).
Int("wuerden_fallen", len(waisen)).
Str("grund", grund).
Msg("rescan: Anwenden abgebrochen - das sieht nicht nach Aufraeumen aus")
// Die Waisen trotzdem in die Stichprobe, damit man im Bericht
// sieht, worum es gegangen wäre.
for _, o := range waisen {
sample(Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key})
}
return rep, nil
}
}
for _, o := range waisen {
e := Entry{Kind: KindOrphaned, StorageKey: o.key, Path: o.key}
if apply {
if err := s.remove(ctx, o.id); err != nil {
e.Reason = err.Error()
rep.Errors++
} else {
rep.Removed++
}
}
sample(e)
}
return rep, nil
}
// ── Die Bremsen ──────────────────────────────────────────────────────────────
// mengenAnteil: Ab welchem Anteil der lebenden Einträge ein Lauf nicht mehr
// nach Aufräumen aussieht, sondern nach einem Unfall. Die Hälfte ist
// großzügig – wer wirklich einmal halb Aether von Hand aus dem Datenordner
// entfernt hat, bestätigt einmal und kommt durch.
const mengenAnteil = 2
// pruefeBremse entscheidet, ob wirklich gelöscht werden darf. Leerer Rückgabe-
// wert heißt: ja.
//
// **Warum es das hier braucht, obwohl der GC es schon hat.** Beide gleichen
// Platte und Datenbank ab, nur aus entgegengesetzter Richtung, und beide haben
// denselben Fehlerfall: Eine Seite antwortet fälschlich mit „leer". Der GC
// löscht dann Blobs – die Datenbank weiß hinterher noch, was fehlt. Hier fällt
// `DELETE FROM files`, und damit das Einzige, was noch wusste, dass es die
// Datei gab. Die gefährlichere Seite war die ungesicherte.
//
// Der Auslöser ist keine Theorie: `LocalFS.Walk` gibt für ein **nicht
// vorhandenes** Verzeichnis `nil` zurück – kein Fehler, keine Einträge. Genau
// der Fingerabdruck eines Bind-Mounts, der nach einem Neustart nicht hochkam,
// oder eines Tippfehlers in DATA_PATH. Ohne diese Prüfung gälte dann jede
// Datei als verwaist.
func pruefeBremse(gescannt, lebende, wuerdenFallen int, trotzdem bool) (grund string, uebersteuerbar bool) {
// **Notbremse – nicht übersteuerbar.** Nichts auf der Platte gesehen,
// obwohl die Datenbank Dateien kennt: Das ist kein Bestand, der
// verschwunden ist, das ist ein Speicher, der nicht da war.
if gescannt == 0 && lebende > 0 {
return "auf der Platte wurde keine einzige Datei gefunden, " +
"obwohl die Datenbank welche kennt - vermutlich ist der Datenordner " +
"nicht eingehaengt. Es wurde nichts geloescht.", false
}
// **Mengenbremse – mit Bestaetigung übersteuerbar.** Dass mehr als die
// Hälfte auf einmal fällt, kommt bei echtem Aufräumen nicht vor.
if !trotzdem && wuerdenFallen > 0 && wuerdenFallen*mengenAnteil > lebende {
return fmt.Sprintf(
"%d von %d Eintraegen wuerden entfernt - das ist mehr als die Haelfte. "+
"Wenn das stimmt, noch einmal mit ausdruecklicher Bestaetigung aufrufen.",
wuerdenFallen, lebende), true
}
return "", false
}