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

+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}
}