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") return nil }