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,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 ""
|
||||
}
|
||||
Reference in new issue
Block a user