feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,245 @@
|
||||
// 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"} {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package gc
|
||||
|
||||
import "testing"
|
||||
|
||||
// Die Notbremse ist die letzte Verteidigungslinie: Hinter ihr löscht der GC
|
||||
// echte Nutzerdateien. Eine kaputte Abfrage oder eine Verbindung zur falschen
|
||||
// Datenbank sieht von hier aus exakt wie "alles ist verwaist" aus.
|
||||
func TestShouldAbort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
referenced int
|
||||
scanned int
|
||||
want bool
|
||||
}{
|
||||
{"database knows nothing but storage is full", 0, 500, true},
|
||||
{"fresh install – nothing anywhere", 0, 0, false},
|
||||
{"normal run", 120, 125, false},
|
||||
{"single orphan among many", 1, 2, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldAbort(tt.referenced, tt.scanned); got != tt.want {
|
||||
t.Errorf("shouldAbort(%d, %d) = %v, want %v",
|
||||
tt.referenced, tt.scanned, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user