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

+239
View File
@@ -0,0 +1,239 @@
package documents
import (
"context"
"errors"
"fmt"
"io"
"os"
"time"
"github.com/rs/zerolog"
"aether/internal/storage"
)
// idleInterval ist die Pause, wenn nichts zu tun ist.
//
// Abfragen statt Benachrichtigen (LISTEN/NOTIFY) ist hier die richtige Wahl:
// Eine Abfrage auf einen Teilindex kostet nichts, und der Arbeiter muss ohnehin
// regelmäßig nachsehen – ein Auftrag kann auch dadurch entstehen, dass der
// Server beim letzten Mal mittendrin ausgefallen ist. Fünf Sekunden Verzug
// spielen bei einer Aufgabe, die selbst Sekunden bis Minuten dauert, keine
// Rolle.
const idleInterval = 5 * time.Second
// busyInterval: Wurde gerade etwas erledigt, sofort weitermachen. Eine kurze
// Pause bleibt trotzdem, damit ein Stapel von hundert Scans nicht die ganze
// Maschine belegt und die Cloud daneben zäh wird.
const busyInterval = 200 * time.Millisecond
// maxFileBytes: Größeres wird übersprungen. Der Arbeiter muss die Datei in eine
// temporäre Kopie schreiben, bevor die Werkzeuge sie lesen können; ein
// 4-GB-Video würde dabei die Platte füllen – und Text ist darin ohnehin keiner.
const maxFileBytes = 512 << 20 // 512 MiB
// Worker arbeitet die Warteschlange ab. Einer genügt: OCR lastet einen Kern
// voll aus, und mehrere parallel machen auf einem Heimserver die Oberfläche
// zäh, ohne insgesamt schneller fertig zu sein.
type Worker struct {
store *Store
blobs storage.Storage
extract *Extractor
log zerolog.Logger
// paused merkt sich, ob wegen Platzmangels gerade nichts läuft – nur damit
// die Warnung einmal im Log steht und nicht alle fünf Sekunden.
paused bool
}
func NewWorker(store *Store, blobs storage.Storage, ex *Extractor, log zerolog.Logger) *Worker {
return &Worker{store: store, blobs: blobs, extract: ex, log: log.With().Str("component", "ocr").Logger()}
}
// Run läuft, bis der Kontext endet. Gedacht für `go worker.Run(ctx)` beim Start.
func (w *Worker) Run(ctx context.Context) {
w.log.Info().
Str("languages", w.extract.Languages).
Int("max_pages", w.extract.MaxPages).
Msg("OCR-Arbeiter gestartet")
// Angefangene Dateien eines hart beendeten Laufs wegräumen (siehe
// sweepTemp). Einmal beim Start genügt: Während des Betriebs räumt der
// Arbeiter selbst auf.
if n, freed := sweepTemp(w.extract.TmpDir); n > 0 {
w.log.Info().Int("files", n).Int64("bytes", freed).
Msg("Zwischendateien eines früheren Laufs entfernt")
}
for {
wait := idleInterval
if w.step(ctx) {
wait = busyInterval
}
select {
case <-ctx.Done():
w.log.Info().Msg("OCR-Arbeiter beendet")
return
case <-time.After(wait):
}
}
}
// step erledigt höchstens einen Auftrag. Rückgabe: ob etwas zu tun war.
func (w *Worker) step(ctx context.Context) bool {
// Vor dem Zugreifen nachsehen, nicht danach: Ein Auftrag, den wir schon
// auf 'running' gesetzt haben, hätte einen Versuch verbraucht, obwohl es
// gar nicht an ihm lag.
if free, err := freeBytes(w.extract.TmpDir); err == nil && free < minFreeBytes {
// Nur einmal je Zustandswechsel melden – sonst füllt die Warnung im
// Fünf-Sekunden-Takt den Log, und das ausgerechnet, wenn der Platz
// knapp ist.
if !w.paused {
w.paused = true
w.log.Warn().
Uint64("free_mb", free/(1<<20)).
Uint64("needed_mb", uint64(minFreeBytes)/(1<<20)).
Msg("Texterkennung pausiert: zu wenig Platz auf der Platte")
}
return false
} else if w.paused {
w.paused = false
w.log.Info().Msg("Texterkennung fortgesetzt: wieder genug Platz")
}
job, err := w.store.Claim(ctx)
if errors.Is(err, ErrNoJob) {
return false
}
if err != nil {
// Datenbank weg oder Kontext beendet – nicht in einer engen Schleife
// weiterprobieren.
if ctx.Err() == nil {
w.log.Warn().Err(err).Msg("Auftrag holen fehlgeschlagen")
}
return false
}
start := time.Now()
res, err := w.process(ctx, job)
if err != nil {
if ctx.Err() != nil {
// Herunterfahren mitten im Lauf ist kein Fehler der Datei. Der
// Auftrag steht auf 'running' und wird beim nächsten Start wieder
// aufgegriffen – genau dafür nimmt Claim auch 'running' mit.
return false
}
w.log.Warn().Err(err).
Str("file", job.Name).Int("attempt", job.Attempts).
Msg("Texterkennung fehlgeschlagen")
if err := w.store.Fail(context.WithoutCancel(ctx), job, err); err != nil {
w.log.Error().Err(err).Msg("Fehlschlag konnte nicht vermerkt werden")
}
return true
}
if err := w.store.Complete(ctx, job, res); err != nil {
w.log.Error().Err(err).Str("file", job.Name).Msg("Text konnte nicht gespeichert werden")
return true
}
w.log.Info().
Str("file", job.Name).
Str("source", res.Source).
Int("chars", res.Chars).
Int("pages", res.Pages).
Dur("took", time.Since(start)).
Msg("Text erkannt")
return true
}
func (w *Worker) process(ctx context.Context, job *Job) (Result, error) {
if job.Size > maxFileBytes {
return Result{}, fmt.Errorf("Datei zu groß für die Texterkennung (%d Bytes)", job.Size)
}
if !Supported(job.MimeType, job.Name) {
// Kann vorkommen, wenn der MIME-Typ nachträglich korrigiert wurde.
// Kein Fehler – nur nichts zu tun.
return Result{}, w.store.Skip(ctx, job.ID, "Dateityp trägt keinen Text")
}
// Die Werkzeuge sind externe Programme und brauchen einen Pfad; die
// Ablageschicht gibt grundsätzlich nur einen Datenstrom heraus, weil sie
// später auch S3 bedienen soll.
//
// Bei der lokalen Ablage gibt es den Pfad aber wirklich – dann wird
// **nicht** kopiert. Das ist kein Feinschliff: Eine Kopie bedeutet, dass
// neben jedem Dokument kurzzeitig noch einmal dieselbe Menge auf der
// Platte liegt, und die Erkennung läuft ausgerechnet auf einer Maschine,
// die auch alle Dateien beherbergt.
path, cleanup, err := w.materialize(ctx, job)
if err != nil {
return Result{}, err
}
defer cleanup()
return w.extract.Extract(ctx, path, job.MimeType, job.Name)
}
// materialize besorgt einen Pfad zur Datei – ohne Kopie, wo es geht.
//
// Der zurückgegebene cleanup räumt nur auf, wenn tatsächlich kopiert wurde. Er
// darf das Original niemals löschen; deshalb die klare Trennung der beiden
// Zweige statt eines gemeinsamen Pfads mit einem Merker.
func (w *Worker) materialize(ctx context.Context, job *Job) (string, func(), error) {
if lp, ok := w.blobs.(storage.LocalPather); ok {
path, err := lp.LocalPath(job.StorageKey)
if err == nil {
// Nichts zu räumen: Das ist die Datei selbst. Gelesen wird sie nur.
return path, func() {}, nil
}
// Kein Abbruch – der Weg über die Kopie funktioniert weiterhin.
w.log.Debug().Err(err).Str("key", job.StorageKey).
Msg("kein lokaler Pfad, weiche auf Kopie aus")
}
rc, err := w.blobs.Get(ctx, job.StorageKey)
if err != nil {
return "", nil, fmt.Errorf("Datei lesen: %w", err)
}
defer rc.Close()
// Die Endung mitnehmen: ocrmypdf und tesseract entscheiden auch danach,
// was sie vor sich haben.
pattern := "ocr-*" + extOf(job.Name)
var tmp *os.File
if tf, ok := w.blobs.(storage.TempFiler); ok {
// Über die Ablageschicht, damit die Kopie auf derselben Platte landet
// wie die Daten – nicht in einem knappen /tmp im Container.
tmp, err = tf.CreateTemp(pattern)
} else {
tmp, err = os.CreateTemp("", pattern)
}
if err != nil {
return "", nil, fmt.Errorf("Zwischendatei anlegen: %w", err)
}
name := tmp.Name()
cleanup := func() { os.Remove(name) }
if _, err := io.Copy(tmp, rc); err != nil {
tmp.Close()
cleanup()
return "", nil, fmt.Errorf("Zwischendatei schreiben: %w", err)
}
if err := tmp.Close(); err != nil {
cleanup()
return "", nil, fmt.Errorf("Zwischendatei schließen: %w", err)
}
return name, cleanup, nil
}
func extOf(name string) string {
for i := len(name) - 1; i >= 0 && len(name)-i <= 6; i-- {
if name[i] == '.' {
return name[i:]
}
if name[i] == '/' {
break
}
}
return ""
}