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

+66
View File
@@ -0,0 +1,66 @@
package documents
import (
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
)
// minFreeBytes ist die Untergrenze, unter der die Texterkennung pausiert.
//
// OCR ist das einzige Stück dieser Anwendung, das von sich aus und ohne
// Zutun eines Menschen Platz verbraucht: Es arbeitet eine Warteschlange ab,
// solange etwas darin liegt. Eine Cloud, die ihre eigene Platte vollschreibt,
// nimmt Uploads nicht mehr an – und das wäre dann kein OCR-Problem mehr,
// sondern ein Ausfall.
//
// 2 GiB ist mit Absicht großzügig: Der Sidecar einer Erkennung ist kaum größer
// als ein Megabyte, tesseract und ghostscript brauchen währenddessen aber
// Arbeitsdateien, deren Größe von der Auflösung der Vorlage abhängt. Lieber
// eine Weile nichts erkennen als eine volle Platte.
const minFreeBytes = 2 << 30 // 2 GiB
// freeBytes meldet den freien Platz auf dem Dateisystem, auf dem path liegt.
func freeBytes(path string) (uint64, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, fmt.Errorf("statfs %s: %w", path, err)
}
// Bavail, nicht Bfree: Bfree zählt auch die Reserve mit, die nur root
// benutzen darf – der Server läuft aber als unprivilegierter Nutzer und
// käme daran nie heran.
return st.Bavail * uint64(st.Bsize), nil
}
// sweepTemp räumt Zwischendateien früherer Läufe weg.
//
// Im Normalfall räumt der Arbeiter selbst auf. Wird der Container aber hart
// beendet – `docker kill`, ein Absturz, Stromausfall –, läuft kein defer mehr,
// und die angefangene Datei bleibt liegen. Ohne diesen Durchgang sammelt sich
// das über Monate an, ausgerechnet auf der Platte, die auch die Dateien trägt.
//
// Es wird nur weggeräumt, was diese Anwendung selbst angelegt hat: das Präfix
// "ocr-" im Zwischenverzeichnis. Fremde Dateien bleiben unangetastet.
func sweepTemp(dir string) (int, int64) {
entries, err := os.ReadDir(dir)
if err != nil {
return 0, 0
}
var count int
var bytes int64
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "ocr-") {
continue
}
full := filepath.Join(dir, e.Name())
if info, err := e.Info(); err == nil {
bytes += info.Size()
}
if err := os.Remove(full); err == nil {
count++
}
}
return count, bytes
}
+302
View File
@@ -0,0 +1,302 @@
// Package documents macht den Inhalt von Dokumenten durchsuchbar.
//
// Die Namenssuche (internal/files/search.go) findet "Rechnung_Stadtwerke.pdf".
// Ein Scanner nennt seine Ablage aber "scan_0042.pdf" – der Name sagt dann
// nichts, und der Beleg ist praktisch verloren. Dieses Paket holt den Text aus
// der Datei und legt ihn nach file_text, wo die Suche ihn findet.
package documents
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"unicode/utf8"
)
// Herkunft des Textes. Der Unterschied zählt bei der Fehlersuche: Findet die
// Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen haben kann.
const (
SourceLayer = "layer" // aus der Textebene des PDFs – verlustfrei
SourceOCR = "ocr" // aus dem Bild erkannt – kann Fehler enthalten
)
// minCharsPerPage entscheidet, ob ein PDF schon Text mitbringt.
//
// Nicht "> 0 Zeichen": Fast jeder Scan trägt eine Kopfzeile, eine Seitenzahl
// oder ein Wasserzeichen als echten Text – oft nur zehn Zeichen. Ohne diese
// Schwelle gälte er als lesbar, und die eigentliche Rechnung bliebe
// unauffindbar. Eine getippte Seite hat mehrere tausend Zeichen; 120 trennt die
// beiden Fälle mit reichlich Abstand in beide Richtungen.
const minCharsPerPage = 120
// maxTextBytes deckelt, was in die Datenbank geht. Ein 400-seitiges Handbuch
// erzeugt sonst mehrere Megabyte Text pro Datei, und der GIN-Index wächst mit.
// Für einen Beleg ist das weit jenseits von allem; abgeschnitten wird am Ende,
// wo der Inhalt ohnehin am wenigsten aussagt.
const maxTextBytes = 1 << 20 // 1 MiB
// Extractor holt Text aus einer Datei. Er ruft externe Programme auf –
// pdftotext, ocrmypdf, tesseract –, die im Container mitgeliefert werden.
type Extractor struct {
// Languages sind die Sprachmodelle für tesseract, z. B. "deu+eng".
Languages string
// MaxPages begrenzt, wie viele Seiten erkannt werden. OCR kostet Sekunden
// je Seite; ohne Grenze blockiert ein einzelnes dickes PDF die
// Warteschlange für alle anderen.
MaxPages int
// Timeout je Datei. Greift zusätzlich zu MaxPages, weil auch eine einzelne
// Seite entgleisen kann.
Timeout time.Duration
// TmpDir nimmt die Zwischendateien auf. Leer heißt: das System entscheidet.
TmpDir string
}
// Result ist der gefundene Text mitsamt seiner Herkunft.
type Result struct {
Text string
Source string
// Lang sind die verwendeten Sprachmodelle – bei SourceLayer leer, weil
// dort nichts erkannt, sondern nur ausgelesen wurde.
Lang string
Pages int
// Chars ist die Länge *vor* dem Kürzen auf maxTextBytes.
Chars int
}
// Supported sagt, ob aus dieser Datei überhaupt Text zu holen ist.
//
// Bewusst eng: PDFs und Bilder. Word- und Excel-Dateien bräuchten je einen
// eigenen Umwandler; sie tragen ihren Text ohnehin meist schon im Namen und
// sind selten das, was eingescannt wird.
func Supported(mime, name string) bool {
m := strings.ToLower(mime)
if strings.Contains(m, "pdf") {
return true
}
if strings.HasPrefix(m, "image/") {
// SVG ist Text, kein Bild – tesseract kann damit nichts anfangen.
return !strings.Contains(m, "svg")
}
switch strings.ToLower(filepath.Ext(name)) {
case ".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp":
return true
}
return false
}
// Available prüft, ob die Programme da sind. Fehlen sie, läuft die Cloud
// weiter – nur ohne Volltext. Ein fehlendes Paket soll den Start nicht
// verhindern.
func Available() (bool, []string) {
var missing []string
for _, bin := range []string{"pdftotext", "ocrmypdf", "tesseract"} {
if _, err := exec.LookPath(bin); err != nil {
missing = append(missing, bin)
}
}
return len(missing) == 0, missing
}
// Extract liest die Datei unter path. Das Original wird dabei **nicht**
// angefasst.
//
// Das ist die wichtigste Eigenschaft hier: ocrmypdf schreibt normalerweise ein
// neues PDF mit eingebetteter Textebene. Täten wir das, änderten sich Größe und
// Prüfsumme der Datei – die gespeicherte Prüfsumme passte nicht mehr, die
// Versionshistorie bekäme einen Eintrag, den niemand ausgelöst hat, und
// WebDAV-Clients hielten die Datei für fremdverändert und lüden sie erneut. Für
// ein Archiv ist eine unangetastete Datei mehr wert als ein durchsuchbares PDF.
// Der Text landet deshalb daneben in der Datenbank, das PDF bleibt Byte für
// Byte, wie es hochgeladen wurde.
func (e *Extractor) Extract(ctx context.Context, path, mime, name string) (Result, error) {
if e.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, e.Timeout)
defer cancel()
}
isPDF := strings.Contains(strings.ToLower(mime), "pdf") ||
strings.EqualFold(filepath.Ext(name), ".pdf")
if isPDF {
return e.extractPDF(ctx, path)
}
return e.extractImage(ctx, path)
}
func (e *Extractor) extractPDF(ctx context.Context, path string) (Result, error) {
pages := e.pageCount(ctx, path)
// Erst der billige Weg: Hat das PDF eine Textebene, ist pdftotext in
// Millisekunden fertig und liefert den Text exakt so, wie er gesetzt wurde.
// OCR wäre hier nicht nur langsamer, sondern schlechter – es würde
// fehlerfreien Text durch geratenen ersetzen.
limit := pages
if e.MaxPages > 0 && (limit == 0 || limit > e.MaxPages) {
limit = e.MaxPages
}
args := []string{"-q"}
if limit > 0 {
args = append(args, "-l", fmt.Sprint(limit))
}
args = append(args, path, "-")
layer, err := e.run(ctx, "pdftotext", args...)
if err == nil {
text := clean(layer)
perPage := len(text)
if pages > 1 {
perPage = len(text) / pages
}
if perPage >= minCharsPerPage {
return finish(text, SourceLayer, "", pages), nil
}
}
// Zu wenig Text – also ein Scan. Jetzt OCR.
sidecar, err := os.CreateTemp(e.TmpDir, "ocr-*.txt")
if err != nil {
return Result{}, fmt.Errorf("sidecar anlegen: %w", err)
}
sidecarPath := sidecar.Name()
sidecar.Close()
defer os.Remove(sidecarPath)
ocrArgs := []string{
"--quiet",
"-l", e.Languages,
// Der eigentliche Zweck des Aufrufs: --sidecar schreibt den erkannten
// Text als reine Textdatei daneben.
"--sidecar", sidecarPath,
// Wir landen hier nur, wenn kaum Text da ist. --force-ocr rastert die
// Seite und erkennt sie neu, statt an den paar vorhandenen Zeichen zu
// scheitern (ocrmypdf bricht sonst mit PriorOcrFoundError ab).
"--force-ocr",
// Kein Ergebnis-PDF erzeugen. Uns interessiert allein der Sidecar; das
// Schreiben (und Optimieren) einer Datei, die sofort wieder
// verschwindet, wäre reine Rechenzeit und reiner Plattenplatz.
"--output-type", "none",
"--optimize", "0",
}
if e.MaxPages > 0 && pages > e.MaxPages {
ocrArgs = append(ocrArgs, "--pages", fmt.Sprintf("1-%d", e.MaxPages))
}
// Ausgabe nach stdout, und dank --output-type none kommt dort nichts an:
// gemessen 0 Bytes, Sidecar trotzdem vollständig. Damit entsteht gar kein
// Ergebnis-PDF – wichtig auf einer Platte, die knapp werden kann, denn ein
// force-OCR rastert jede Seite und das Ergebnis kann ein Vielfaches der
// Vorlage wiegen.
//
// /dev/null als Ausgabe geht übrigens **nicht** – ocrmypdf bricht damit
// mit Exit 3 ab. Der Bindestrich ist der Weg.
ocrArgs = append(ocrArgs, path, "-")
if _, err := e.run(ctx, "ocrmypdf", ocrArgs...); err != nil {
return Result{}, fmt.Errorf("ocrmypdf: %w", err)
}
raw, err := os.ReadFile(sidecarPath)
if err != nil {
return Result{}, fmt.Errorf("sidecar lesen: %w", err)
}
return finish(clean(string(raw)), SourceOCR, e.Languages, pages), nil
}
func (e *Extractor) extractImage(ctx context.Context, path string) (Result, error) {
out, err := e.run(ctx, "tesseract", path, "stdout", "-l", e.Languages)
if err != nil {
return Result{}, fmt.Errorf("tesseract: %w", err)
}
return finish(clean(out), SourceOCR, e.Languages, 1), nil
}
// pageCount fragt pdfinfo nach der Seitenzahl. Schlägt das fehl, arbeiten wir
// mit 0 weiter – die Seitenzahl steuert nur Grenzen, sie ist nicht kritisch.
func (e *Extractor) pageCount(ctx context.Context, path string) int {
out, err := e.run(ctx, "pdfinfo", path)
if err != nil {
return 0
}
for _, line := range strings.Split(out, "\n") {
if !strings.HasPrefix(line, "Pages:") {
continue
}
var n int
if _, err := fmt.Sscanf(strings.TrimSpace(line), "Pages: %d", &n); err == nil {
return n
}
}
return 0
}
func (e *Extractor) run(ctx context.Context, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
var out, errOut bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errOut
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errOut.String())
if len(msg) > 300 {
msg = msg[:300] + "…"
}
if msg != "" {
return "", fmt.Errorf("%s: %w (%s)", name, err, msg)
}
return "", fmt.Errorf("%s: %w", name, err)
}
return out.String(), nil
}
// clean räumt auf, was aus den Werkzeugen kommt.
//
// **Der Seitenumbruch (\f) bleibt stehen.** Für die Suche ist er bedeutungslos
// – ein tsvector kennt keine Seiten –, für den Betrachter ist er das Einzige,
// woran sich ablesen lässt, *wo* ein Wort steht. Bei einem Scan liegt im PDF
// nur ein Bild; der erkannte Text steht ausschließlich hier. Ohne die Marke
// beginnt der Betrachter bei einem zwölfseitigen Beleg wieder auf Seite 1.
//
// Ein Wust aus Leerzeilen bläht den Text dagegen nur auf. Ungültiges UTF-8
// muss raus, weil Postgres es sonst zurückweist und der ganze Auftrag
// scheitert; OCR liefert es bei verrauschten Vorlagen durchaus.
func clean(s string) string {
s = strings.ReplaceAll(s, "\x00", "")
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, "")
}
var b strings.Builder
b.Grow(len(s))
blank := 0
for _, line := range strings.Split(s, "\n") {
line = strings.TrimRight(line, " \t\r")
// Die Seitenmarke steht bei pdftotext am Ende der letzten Zeile einer
// Seite. Sie überlebt das Trimmen und zählt nicht als leer.
if strings.TrimSpace(strings.ReplaceAll(line, "\f", "")) == "" && !strings.Contains(line, "\f") {
blank++
if blank > 1 {
continue
}
} else {
blank = 0
}
b.WriteString(line)
b.WriteByte('\n')
}
return strings.TrimSpace(b.String())
}
// finish kürzt auf maxTextBytes und merkt sich die ursprüngliche Länge.
func finish(text, source, lang string, pages int) Result {
chars := utf8.RuneCountInString(text)
if len(text) > maxTextBytes {
text = text[:maxTextBytes]
// Nicht mitten in einem Zeichen abschneiden – das ergäbe ungültiges
// UTF-8, und Postgres lehnte den ganzen Text ab.
for len(text) > 0 && !utf8.ValidString(text) {
text = text[:len(text)-1]
}
}
return Result{Text: text, Source: source, Lang: lang, Pages: pages, Chars: chars}
}
+86
View File
@@ -0,0 +1,86 @@
package documents
import (
"net/http"
"github.com/google/uuid"
"aether/pkg/response"
)
// Handler zeigt den Stand der Texterkennung.
//
// Ohne diese Auskunft ist nicht zu erkennen, ob der Volltext funktioniert:
// Eine Suche, die nichts findet, sieht genauso aus wie eine Suche in einem
// Bestand, der noch gar nicht erkannt wurde. Nach dem ersten Start läuft der
// Arbeiter je nach Menge Stunden – ohne Zähler weiß niemand, ob er
// vorankommt.
type Handler struct {
store *Store
// isAdmin und userID trennen die Rechteprüfung von diesem Paket.
// documents kennt weder Sessions noch Rollen, und soll es auch nicht.
isAdmin func(r *http.Request) bool
userID func(r *http.Request) uuid.UUID
}
func NewHandler(store *Store, isAdmin func(r *http.Request) bool, userID func(r *http.Request) uuid.UUID) *Handler {
return &Handler{store: store, isAdmin: isAdmin, userID: userID}
}
// Status beantwortet GET /api/v1/admin/ocr – der Gesamtstand des Servers.
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
if !h.isAdmin(r) {
response.Error(w, http.StatusForbidden, "admin required")
return
}
// h.store nil heißt: OCR ist abgeschaltet oder die Programme fehlen. Das
// ist eine Auskunft, kein Fehler – die Oberfläche soll „aus" anzeigen
// können, statt in einen Fehlerzustand zu laufen.
if h == nil || h.store == nil {
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
stats, err := h.store.Stats(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "ocr status failed")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"enabled": true,
"pending": stats.Pending,
"running": stats.Running,
"done": stats.Done,
"failed": stats.Failed,
"indexed": stats.Indexed,
})
}
// MyStatus beantwortet GET /api/v1/ocr – der Stand der **eigenen** Dateien.
//
// Ohne diesen Endpunkt wüsste nur der Administrator, ob ein Dokument schon
// gelesen wurde. Das ist die falsche Person: Wissen muss es, wer die Rechnung
// hochgeladen hat und sie gerade nicht findet.
func (h *Handler) MyStatus(w http.ResponseWriter, r *http.Request) {
uid := h.userID(r)
if uid == uuid.Nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return
}
if h == nil || h.store == nil {
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
stats, err := h.store.StatsForUser(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "ocr status failed")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"enabled": true,
"pending": stats.Pending,
"running": stats.Running,
"done": stats.Done,
"failed": stats.Failed,
"indexed": stats.Indexed,
})
}
+315
View File
@@ -0,0 +1,315 @@
package documents
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ExtractVersion ist die Fassung der Textaufbereitung.
//
// Sie steht an jedem Eintrag in file_text. Wird die Aufbereitung geändert, so
// dass alte Einträge nicht mehr taugen, wird die Zahl erhöht – beim Start
// werden sie dann erneut erkannt, statt still nach der alten Regel liegen zu
// bleiben. Ohne so eine Marke fällt der Unterschied nie auf: Der Text ist ja
// da, er kann nur weniger.
//
// 1 – Seitenumbruch (\f) bleibt im Text stehen (23.08.2026)
const ExtractVersion = 1
// ErrNoJob heißt: gerade nichts zu tun. Kein Fehler, sondern der Normalfall.
var ErrNoJob = errors.New("no pending job")
// maxAttempts begrenzt, wie oft ein Auftrag wiederholt wird.
//
// Zwei Wiederholungen fangen das ab, was vorübergehend war – ein Neustart
// mitten im Lauf, kurzzeitig fehlender Speicherplatz. Was dreimal scheitert,
// scheitert an der Datei selbst (kaputtes PDF, passwortgeschützt), und ein
// vierter Versuch verbrennt nur Rechenzeit. Der Auftrag bleibt dann als
// 'failed' mit Fehlertext stehen, statt still zu verschwinden.
const maxAttempts = 3
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) *Store {
return &Store{db: db}
}
// Job ist ein Auftrag samt allem, was der Arbeiter über die Datei braucht.
type Job struct {
ID uuid.UUID
FileID uuid.UUID
Attempts int
Name string
MimeType string
StorageKey string
Size int64
}
// Enqueue trägt eine Datei zur Texterkennung ein.
//
// ON CONFLICT statt eines zweiten Eintrags: Wird dieselbe Datei erneut
// hochgeladen, ist der alte Text hinfällig – der Auftrag wird auf 'pending'
// zurückgesetzt, statt sich daneben zu stapeln. Der Zähler geht dabei auf 0,
// weil eine neue Fassung eine neue Chance verdient: Vielleicht war es genau
// der kaputte Scan, der ersetzt wurde.
func (s *Store) Enqueue(ctx context.Context, fileID uuid.UUID) error {
_, err := s.db.Exec(ctx, `
INSERT INTO ocr_jobs (file_id, status)
VALUES ($1, 'pending')
ON CONFLICT (file_id) DO UPDATE
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
fileID)
if err != nil {
return fmt.Errorf("enqueue ocr job: %w", err)
}
return nil
}
// Claim holt den ältesten offenen Auftrag und markiert ihn als laufend.
//
// FOR UPDATE SKIP LOCKED ist der Kern: Zwei Arbeiter greifen damit nie
// denselben Auftrag – der zweite überspringt die gesperrte Zeile, statt darauf
// zu warten. Heute läuft nur einer, aber die Sperre kostet nichts und erspart
// die Suche nach doppelt erkannten Dateien, sobald es zwei werden.
//
// 'running' wird mit übernommen, nicht nur 'pending': Ein Auftrag, der beim
// Neustart mittendrin war, steht sonst für immer auf 'running' und wird nie
// wieder angefasst. Der Zähler sorgt dafür, dass daraus keine Endlosschleife
// wird, falls die Datei den Server reproduzierbar umbringt.
func (s *Store) Claim(ctx context.Context) (*Job, error) {
var j Job
err := s.db.QueryRow(ctx, `
UPDATE ocr_jobs
SET status = 'running', attempts = attempts + 1, updated_at = NOW()
WHERE id = (
SELECT j.id
FROM ocr_jobs j
JOIN files f ON f.id = j.file_id AND f.is_deleted = false
WHERE j.status IN ('pending', 'running')
AND j.attempts < $1
ORDER BY j.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1)
RETURNING id, file_id, attempts,
(SELECT name FROM files WHERE id = file_id),
(SELECT mime_type FROM files WHERE id = file_id),
(SELECT storage_key FROM files WHERE id = file_id),
(SELECT size FROM files WHERE id = file_id)`,
maxAttempts,
).Scan(&j.ID, &j.FileID, &j.Attempts, &j.Name, &j.MimeType, &j.StorageKey, &j.Size)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoJob
}
if err != nil {
return nil, fmt.Errorf("claim ocr job: %w", err)
}
return &j, nil
}
// Complete speichert den Text und schließt den Auftrag ab. Beides in einer
// Transaktion: Ein gespeicherter Text ohne erledigten Auftrag würde beim
// nächsten Durchgang erneut erkannt, ein erledigter Auftrag ohne Text ließe die
// Datei unauffindbar zurück.
func (s *Store) Complete(ctx context.Context, job *Job, res Result) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO file_text (file_id, content, source, lang, chars, pages, extract_version)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (file_id) DO UPDATE
SET content = EXCLUDED.content, source = EXCLUDED.source,
lang = EXCLUDED.lang, chars = EXCLUDED.chars,
pages = EXCLUDED.pages, extract_version = EXCLUDED.extract_version,
created_at = NOW()`,
job.FileID, res.Text, res.Source, res.Lang, res.Chars, res.Pages, ExtractVersion,
); err != nil {
return fmt.Errorf("save text: %w", err)
}
if _, err := tx.Exec(ctx,
`UPDATE ocr_jobs SET status = 'done', last_error = '', updated_at = NOW() WHERE id = $1`,
job.ID,
); err != nil {
return fmt.Errorf("finish job: %w", err)
}
return tx.Commit(ctx)
}
// Fail vermerkt den Fehler. Ist der Zähler ausgeschöpft, bleibt der Auftrag als
// 'failed' liegen; sonst geht er zurück in die Schlange.
func (s *Store) Fail(ctx context.Context, job *Job, cause error) error {
msg := cause.Error()
if len(msg) > 500 {
msg = msg[:500] + "…"
}
status := "pending"
if job.Attempts >= maxAttempts {
status = "failed"
}
_, err := s.db.Exec(ctx,
`UPDATE ocr_jobs SET status = $2, last_error = $3, updated_at = NOW() WHERE id = $1`,
job.ID, status, msg)
return err
}
// Skip schließt einen Auftrag ohne Text ab – etwa wenn die Datei inzwischen
// gelöscht wurde. 'done' statt 'failed': Es ist nichts schiefgegangen.
func (s *Store) Skip(ctx context.Context, jobID uuid.UUID, reason string) error {
_, err := s.db.Exec(ctx,
`UPDATE ocr_jobs SET status = 'done', last_error = $2, updated_at = NOW() WHERE id = $1`,
jobID, reason)
return err
}
// Stats zählt die Aufträge je Zustand – für die Anzeige im Admin-Bereich und
// für die Prüfung, ob der Arbeiter überhaupt vorankommt.
type Stats struct {
Pending int `json:"pending"`
Running int `json:"running"`
Done int `json:"done"`
Failed int `json:"failed"`
// Indexed ist die Zahl der Dateien mit gespeichertem Text.
Indexed int `json:"indexed"`
}
func (s *Store) Stats(ctx context.Context) (Stats, error) {
var st Stats
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FILTER (WHERE status = 'pending'),
COUNT(*) FILTER (WHERE status = 'running'),
COUNT(*) FILTER (WHERE status = 'done'),
COUNT(*) FILTER (WHERE status = 'failed'),
(SELECT COUNT(*) FROM file_text)
FROM ocr_jobs`,
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
if err != nil {
return st, fmt.Errorf("ocr stats: %w", err)
}
return st, nil
}
// StatsForUser zählt nur, was diesem Nutzer gehört.
//
// Warum es das getrennt von Stats gibt: Den Gesamtstand darf nur der
// Administrator sehen – die Zahl aller Dokumente auf dem Server verrät etwas
// über fremde Bestände. Wissen muss es aber genau der, der die Rechnung
// hochgeladen hat: Findet seine Suche sie nicht, ist die Frage „ist sie schon
// gelesen?" die erste, die er stellt.
//
// Gezählt wird über owner_id, also die eigenen Dateien. Dateien in
// Gruppenordnern bleiben außen vor – sie gehören der Gruppe, und ihr Stand
// wäre für den Einzelnen keine Auskunft über den eigenen Bestand.
func (s *Store) StatsForUser(ctx context.Context, userID uuid.UUID) (Stats, error) {
var st Stats
err := s.db.QueryRow(ctx, `
SELECT COUNT(*) FILTER (WHERE j.status = 'pending'),
COUNT(*) FILTER (WHERE j.status = 'running'),
COUNT(*) FILTER (WHERE j.status = 'done'),
COUNT(*) FILTER (WHERE j.status = 'failed'),
COUNT(*) FILTER (WHERE t.file_id IS NOT NULL)
FROM files f
JOIN ocr_jobs j ON j.file_id = f.id
LEFT JOIN file_text t ON t.file_id = f.id
WHERE f.owner_id = $1 AND f.is_deleted = false`,
userID,
).Scan(&st.Pending, &st.Running, &st.Done, &st.Failed, &st.Indexed)
if err != nil {
return st, fmt.Errorf("ocr stats for user: %w", err)
}
return st, nil
}
// EnqueueMissing trägt alle Bestandsdateien nach, die noch keinen Auftrag
// haben. Ohne das bliebe alles unauffindbar, was vor dem Einbau hochgeladen
// wurde – und das ist beim ersten Start genau der gesamte Bestand.
//
// Die Auswahl der Typen passiert hier in SQL statt über Supported(): Bei
// zehntausend Dateien wäre es Unfug, jede einzelne nach Go zu holen, nur um
// ihren MIME-Typ anzusehen.
func (s *Store) EnqueueMissing(ctx context.Context, limit int) (int, error) {
tag, err := s.db.Exec(ctx, `
INSERT INTO ocr_jobs (file_id, status)
SELECT f.id, 'pending'
FROM files f
WHERE f.is_deleted = false
AND (f.mime_type ILIKE '%pdf%'
OR (f.mime_type ILIKE 'image/%' AND f.mime_type NOT ILIKE '%svg%')
OR f.name ~* '\.(pdf|png|jpe?g|tiff?|bmp|webp)$')
AND NOT EXISTS (SELECT 1 FROM ocr_jobs j WHERE j.file_id = f.id)
ORDER BY f.created_at DESC
LIMIT $1
ON CONFLICT (file_id) DO NOTHING`, limit)
if err != nil {
return 0, fmt.Errorf("enqueue missing: %w", err)
}
return int(tag.RowsAffected()), nil
}
// EnqueueOutdated legt Einträge erneut in die Warteschlange, die nach einer
// älteren Regel entstanden sind.
//
// Anlass war die Seitenmarke: Bis Fassung 1 wurde der Seitenumbruch aus dem
// Text entfernt. Wer damit indiziert wurde, ist zwar auffindbar – aber der
// Betrachter kann bei einem Scan nicht sagen, auf welcher Seite das Wort
// steht. Ein stiller Rest, der sich nur durch erneutes Erkennen auflöst.
//
// Bewusst mit derselben Obergrenze wie EnqueueMissing: Bei einem großen
// Bestand soll ein Neustart nicht die ganze Platte an OCR verheizen, sondern
// den Rückstand über mehrere Starte abtragen.
func (s *Store) EnqueueOutdated(ctx context.Context, limit int) (int, error) {
tag, err := s.db.Exec(ctx, `
INSERT INTO ocr_jobs (file_id, status)
SELECT t.file_id, 'pending'
FROM file_text t
JOIN files f ON f.id = t.file_id AND f.is_deleted = false
WHERE t.extract_version < $2
LIMIT $1
ON CONFLICT (file_id) DO UPDATE
SET status = 'pending', attempts = 0, last_error = '', updated_at = NOW()`,
limit, ExtractVersion)
if err != nil {
return 0, fmt.Errorf("enqueue outdated: %w", err)
}
return int(tag.RowsAffected()), nil
}
// TextPages liefert den erkannten Text einer Datei, in Seiten zerlegt.
//
// Für den PDF-Betrachter: Bei einem Scan steht im PDF selbst kein Wort, nur
// ein Bild. Ohne diesen Weg fände die Suche im Dokument nichts – ausgerechnet
// bei dem Dateityp, für den der erkannte Text überhaupt gebaut wurde.
//
// Die Zugriffsprüfung passiert im Aufrufer; hier liegt nur die Abfrage.
func (s *Store) TextPages(ctx context.Context, fileID uuid.UUID) ([]string, string, error) {
var content, source string
err := s.db.QueryRow(ctx,
`SELECT content, source FROM file_text WHERE file_id = $1`, fileID,
).Scan(&content, &source)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
if err != nil {
return nil, "", fmt.Errorf("text pages: %w", err)
}
return strings.Split(content, "\f"), source, nil
}
// DeleteText entfernt den Text einer Datei. Wird beim endgültigen Löschen
// gebraucht; solange die Datei nur im Papierkorb liegt, bleibt er stehen –
// beim Wiederherstellen wäre er sonst weg.
func (s *Store) DeleteText(ctx context.Context, fileID uuid.UUID) error {
_, err := s.db.Exec(ctx, `DELETE FROM file_text WHERE file_id = $1`, fileID)
return err
}
+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 ""
}