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:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+245
View File
@@ -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)
}
}()
}
+28
View File
@@ -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)
}
})
}
}