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,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
|
||||
}
|
||||
Reference in new issue
Block a user