Files
Aether/backend/internal/gc/gc.go
T
marcandClaude Opus 5 9c5cda75d7 feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten
Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen
Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert
sich nichts.

Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen
Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und
OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus,
sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch
bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines
Dokuments, für das man OnlyOffice gewählt hat.

Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des
Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung
nicht im Namen, eine neue Fassung überschreibt. files.preview_version
sagt, ob das Abgelegte noch passt.

Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns
abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu,
zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für
die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung
401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine
Fehlerseite im Zwischenspeicher.

Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz:
  erster Abruf 1,05 s -> zweiter 0,009 s
  erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei
  GC: herrenloses PDF von vorgestern weg, echtes bleibt
  endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt
  ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415

Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten
(Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent –
ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille
Überraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:30:49 +02:00

322 lines
9.7 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 gc räumt verwaiste Blobs auf: Dateien im Speicher, auf die keine
// Datenbankzeile mehr zeigt.
//
// Solche Waisen entstehen, wenn ein Vorgang zwischen Blob-Schreiben und
// Datenbank-Commit abbricht – etwa weil der Prozess stirbt oder ein Löschen
// nur halb durchläuft. Ohne Aufräumen belegen sie für immer Platz, ohne dass
// sie irgendwo auftauchen.
//
// Der Ablauf ist bewusst defensiv: Es wird nur gelöscht, was die Datenbank
// nicht kennt UND älter als eine Schonfrist ist. Der Sinn der Schonfrist:
// Zwischen `storage.Put` und dem `INSERT` liegt ein kurzer Moment, in dem ein
// völlig gesunder Upload wie eine Waise aussieht.
package gc
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
"aether/internal/storage"
)
const (
// gracePeriod schützt frische Blobs, deren Datenbankzeile noch unterwegs
// ist. Großzügig bemessen – ein Tag zu spät aufgeräumt kostet nichts, einen
// laufenden Upload zu löschen dagegen sehr wohl.
gracePeriod = 24 * time.Hour
// Interval zwischen zwei Durchläufen.
Interval = 12 * time.Hour
)
type Collector struct {
db *pgxpool.Pool
storage storage.Storage
}
func New(db *pgxpool.Pool, st storage.Storage) *Collector {
return &Collector{db: db, storage: st}
}
// Result fasst einen Durchlauf zusammen.
type Result struct {
Scanned int
Orphans int
Deleted int
FreedBytes int64
SkippedYoung int
}
// Run führt einen Durchlauf aus.
func (c *Collector) Run(ctx context.Context) (Result, error) {
var res Result
referenced, err := c.referencedKeys(ctx)
if err != nil {
return res, err
}
cutoff := time.Now().Add(-gracePeriod)
type orphan struct {
key string
size int64
}
var orphans []orphan
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
res.Scanned++
if referenced[key] {
return nil
}
if info.ModTime.After(cutoff) {
res.SkippedYoung++
return nil
}
orphans = append(orphans, orphan{key: key, size: info.Size})
return nil
})
if err != nil {
return res, fmt.Errorf("walk %q: %w", prefix, err)
}
}
res.Orphans = len(orphans)
if shouldAbort(len(referenced), res.Scanned) {
log.Ctx(ctx).Error().
Int("scanned", res.Scanned).
Msg("gc: database reports no referenced blobs while storage is not empty – skipping run")
return res, nil
}
for _, o := range orphans {
if err := c.storage.Delete(ctx, o.key); err != nil {
log.Ctx(ctx).Warn().Err(err).Str("key", o.key).Msg("gc: could not delete orphan")
continue
}
res.Deleted++
res.FreedBytes += o.size
log.Ctx(ctx).Info().
Str("key", o.key).Int64("bytes", o.size).
Msg("gc: orphaned blob removed")
}
return res, nil
}
// shouldAbort ist die Notbremse vor dem Löschen: Kennt die Datenbank keinen
// einzigen Blob, obwohl im Speicher welche liegen, ist mit hoher
// Wahrscheinlichkeit die Abfrage kaputt oder die Verbindung auf die falsche
// Datenbank gerichtet. Das Ergebnis wäre "alles ist verwaist" – der GC würde
// den kompletten Bestand löschen. Dann lieber gar nichts tun und laut sein.
//
// Die leere Installation (nichts in der Datenbank, nichts auf der Platte) ist
// davon ausgenommen: da gibt es schlicht nichts zu verwechseln.
func shouldAbort(referencedCount, scanned int) bool {
return referencedCount == 0 && scanned > 0
}
// referencedKeys sammelt jeden Speicherschlüssel, auf den die Datenbank zeigt –
// aktive Dateien, Papierkorb und Versionen gleichermaßen. Thumbnails haben
// keine eigene Spalte, ihr Schlüssel leitet sich aus der Datei-ID ab.
func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error) {
keys := make(map[string]bool)
// files.storage_key deckt aktive UND gelöschte Dateien ab: beim Löschen
// wandert der Schlüssel auf den Papierkorb-Pfad mit.
rows, err := c.db.Query(ctx, `SELECT storage_key, id, has_thumbnail FROM files`)
if err != nil {
return nil, fmt.Errorf("query file keys: %w", err)
}
for rows.Next() {
var key, id string
var hasThumb bool
if err := rows.Scan(&key, &id, &hasThumb); err != nil {
rows.Close()
return nil, fmt.Errorf("scan file key: %w", err)
}
keys[strings.TrimPrefix(key, "/")] = true
if hasThumb {
keys["thumbs/"+id+".jpg"] = true
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate file keys: %w", err)
}
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
prevRows, err := c.db.Query(ctx,
`SELECT id::text FROM files WHERE preview_version > 0`)
if err != nil {
return nil, fmt.Errorf("query preview keys: %w", err)
}
for prevRows.Next() {
var id string
if err := prevRows.Scan(&id); err != nil {
prevRows.Close()
return nil, fmt.Errorf("scan preview key: %w", err)
}
keys["previews/"+id+".pdf"] = true
}
prevRows.Close()
if err := prevRows.Err(); err != nil {
return nil, fmt.Errorf("iterate preview keys: %w", err)
}
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
//
// Das Logo ist ein einzelner Schlüssel, das App-Symbol ein Verzeichnis:
// Endet der Wert auf "/", zählt alles darunter als referenziert.
brows, err := c.db.Query(ctx,
`SELECT COALESCE(value, '') FROM settings
WHERE key IN ('logo_storage_key', 'app_icon_storage_key')`)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("query branding keys: %w", err)
}
if err == nil {
for brows.Next() {
var key string
if err := brows.Scan(&key); err != nil {
brows.Close()
return nil, fmt.Errorf("scan branding key: %w", err)
}
if key == "" {
continue
}
key = strings.TrimPrefix(key, "/")
if !strings.HasSuffix(key, "/") {
keys[key] = true
continue
}
if err := c.storage.Walk(ctx, key, func(k string, _ storage.FileInfo) error {
keys[k] = true
return nil
}); err != nil {
brows.Close()
return nil, fmt.Errorf("walk branding dir %s: %w", key, err)
}
}
brows.Close()
if err := brows.Err(); err != nil {
return nil, fmt.Errorf("iterate branding keys: %w", err)
}
}
// Profilbilder stehen in der Nutzerzeile, nicht in files. Ohne diese
// Abfrage hielte der GC sie für verwaist, sobald "avatars" mitläuft.
arows, err := c.db.Query(ctx,
`SELECT avatar_key FROM users WHERE avatar_key IS NOT NULL AND avatar_key <> ''`)
if err != nil {
return nil, fmt.Errorf("query avatar keys: %w", err)
}
for arows.Next() {
var key string
if err := arows.Scan(&key); err != nil {
arows.Close()
return nil, fmt.Errorf("scan avatar key: %w", err)
}
keys[strings.TrimPrefix(key, "/")] = true
}
arows.Close()
if err := arows.Err(); err != nil {
return nil, fmt.Errorf("iterate avatar keys: %w", err)
}
vrows, err := c.db.Query(ctx, `SELECT storage_key FROM file_versions`)
if err != nil {
return nil, fmt.Errorf("query version keys: %w", err)
}
defer vrows.Close()
for vrows.Next() {
var key string
if err := vrows.Scan(&key); err != nil {
return nil, fmt.Errorf("scan version key: %w", err)
}
keys[strings.TrimPrefix(key, "/")] = true
}
return keys, vrows.Err()
}
// Start lässt den GC im Hintergrund laufen: einmal kurz nach dem Start, danach
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
// Hochfahren konkurriert.
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
go func() {
timer := time.NewTimer(5 * time.Minute)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
}
res, err := c.Run(ctx)
if err != nil {
log.Ctx(ctx).Error().Err(err).Msg("gc: run failed")
} else {
log.Ctx(ctx).Info().
Int("scanned", res.Scanned).
Int("orphans", res.Orphans).
Int("deleted", res.Deleted).
Int64("freed_bytes", res.FreedBytes).
Int("skipped_young", res.SkippedYoung).
Msg("gc: run complete")
if onDone != nil && res.Deleted > 0 {
onDone(res)
}
}
timer.Reset(Interval)
}
}()
}
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
//
// Vorschaubilder, Profilbilder und die als PDF gewandelten Office-Dokumente
// zählen nicht gegen das Kontingent eines Kontos – niemand hat sie
// hochgeladen, und sie entstehen jederzeit neu. Platte kosten sie trotzdem,
// und in der Anzeige „X von Y GB belegt" tauchen sie nicht auf.
//
// Genau deshalb steht die Zahl hier: Ein Ordner, der wächst, ohne dass ihn
// irgendwo jemand sieht, ist eine stille Überraschung. Sichtbar ist sie eine
// Größe, über die man entscheiden kann.
type DerivedUsage struct {
Prefix string `json:"prefix"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
// Derived zählt die abgeleiteten Ordner durch. Reines Lesen – dieser Aufruf
// löscht nichts.
func (c *Collector) Derived(ctx context.Context) ([]DerivedUsage, error) {
out := make([]DerivedUsage, 0, 3)
for _, prefix := range []string{"thumbs", "previews", "avatars"} {
u := DerivedUsage{Prefix: prefix}
err := c.storage.Walk(ctx, prefix, func(_ string, info storage.FileInfo) error {
u.Files++
u.Bytes += info.Size
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %s: %w", prefix, err)
}
out = append(out, u)
}
return out, nil
}