patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
2026-08-11 22:27:52 +02:00
2026-08-11 21:27:53 +02:00
2026-08-11 21:27:53 +02:00
2026-08-11 21:27:53 +02:00
2026-08-11 21:27:53 +02:00
2026-08-11 21:27:53 +02:00

archivdms

GoBD-konformes Dokumentenmanagementsystem (DMS) für den DACH-Raum. Eigenständiges Produkt, getrennt von archivmail (E-Mail-Archivierung) — spätere Mail-Anbindung nur optional über archivmails REST-API, kein gemeinsamer Code.

Konzeption basiert auf Recherche zu Paperless-ngx und ecoDMS (siehe dms-featureliste-prompt.md). Grundgerüst (Auth/Tenant/Audit/Mailer-Architektur) aus archivmail portiert und auf ein dokumentzentriertes Modell umgebaut.

Stack

  • Backend: Go (net/http, PostgreSQL via pgx/v5, JWT-Auth)
  • Frontend: Next.js + shadcn/ui
  • Multi-Tenancy: applikationsseitig (tenant_id-Filter je Query, kein Postgres-RLS)
  • Suche: Manticore Search (BM25) — Backend-Endpunkt GET /api/documents/search und Frontend (globale Suchleiste in der TopBar, Ergebnisseite /search mit Tag-/Dokumenttyp-Filter und Pagination) integriert; Hybrid/Vektor geplant

CI-Pipeline (FDN-07)

.gitea/workflows/ci.yml definiert eine Gitea-Actions-Pipeline (Syntax kompatibel zu GitHub Actions) mit zwei Jobs:

  • backend-lint-test: go vet ./..., go test ./... -cover gegen eine pro Lauf frisch gestartete Postgres-Service-Container-Instanz, danach make build als reproduzierbares Artefakt.
  • frontend-lint-test-build: npm ci/npm install-Fallback, ESLint (npm run lint), tsc --noEmit, make build-web (next build) als Artefakt.

Läuft bei jedem Push/PR; ein fehlschlagender Schritt bricht den jeweiligen Job ab (kein continue-on-error), das blockiert den Merge sobald in Gitea die Branch-Protection-Regel "Require status checks to pass" auf diese Jobs gesetzt ist. Wichtig: archivdms hat aktuell noch keinen Gitea-Remote (nur lokales git init), die Pipeline greift daher erst, sobald das Repo zu einer Gitea-Instanz mit aktivierten Actions und registriertem Runner gepusht wird.

Betrieb: Logging, Metriken & Fehler-Tracking (FDN-08)

Jede HTTP-Anfrage läuft durch eine Basis-Middleware-Kette in internal/api/observability.go (requestID -> metrics -> recover -> ServeMux, gebaut in api.New):

  • Korrelations-ID: pro Anfrage wird eine Request-ID erzeugt oder ein mitgelieferter X-Request-ID-Header übernommen (nur alphanumerisch/-_., max. 64 Zeichen — sonst verworfen, Schutz gegen Log-Injection). Die ID steht im context.Context und im Response-Header X-Request-ID. Handler loggen über s.reqLog(r.Context()) bzw. s.reqLog(ctx); jede Zeile bekommt dadurch automatisch request_id=... — auch in der asynchronen Verarbeitung (ProcessDocumentJob, ReprocessDocument), soweit der Request-Context durchgereicht wird.
  • Panic-Recovery: net/http hat kein zentrales Recovery. Die recoverMiddleware fängt jedes Panic ab, loggt es mit Korrelations-ID und gekürztem Stacktrace, zählt archivdms_panics_total hoch und antwortet mit einem sauberen HTTP 500 statt einer abgebrochenen Verbindung.
  • Keine Geheimnisse in Logs: geloggt werden nur Methode, normalisierter Pfad, Status, Dauer und Client-IP. Query-Strings, Header und Bodies werden nie ausgegeben (dort stehen Signed-URL-Signaturen, Share-Tokens, Bearer-Keys, Passwörter). normalizeRoute ersetzt IDs durch {id} und das Segment hinter /share/ immer durch {token}.

GET /metrics

Prometheus-Textformat, ohne Fremdabhängigkeit (internal/api/metrics_handlers.go, reines fmt.Fprintf). Bewusst ohne Login (ein Scraper hat keine Session), dafür IP-beschränkt: Loopback ist immer erlaubt, weitere Scraper werden über den Config-Key api.metrics_allowed_ips (Liste aus IPs oder CIDR-Bereichen, Default leer = nur localhost) freigeschaltet; alles andere bekommt 403. Der Endpunkt gehört nicht ins öffentliche Reverse-Proxy-Mapping.

Ausgegeben werden: archivdms_http_requests_total{method,route,status} (Fehlerrate = Anteil status=~"5.."), archivdms_http_request_duration_seconds_{bucket,sum,count} (Latenz-Histogramm), archivdms_http_requests_in_flight, archivdms_goroutines, archivdms_uptime_seconds, archivdms_panics_total, archivdms_build_info{version} sowie die Queue-Länge archivdms_processing_jobs{status} (aus processing_jobs, aggregiert über alle Mandanten — es werden keine mandantenbezogenen Daten ausgegeben). Die Label-Kardinalität ist auf 500 Serien gedeckelt, danach landet alles unter route="/other".

Frontend: Login-Pflicht & UI-Struktur

Alle Seiten außer /login sind hinter einer Session-Cookie-Prüfung (middleware.ts, Cookie archivdms_session). Ohne gültiges Cookie wird sofort (ohne sichtbares Flackern) zu /login umgeleitet; die vollständige JWT-Prüfung bleibt Aufgabe des Go-Backends bei jedem echten API-Call. Eingeloggte Bereiche laufen unter der Routegruppe src/app/(app)/ mit gemeinsamer App-Shell (Sidebar, TopBar, Cmd+K-Befehlspalette, Dark Mode). /documents und /reminders sind Server Components mit serverseitigem Datenfetch (src/lib/session.ts reicht das Session-Cookie manuell an die Go-API weiter) statt der bisherigen "use client" + useEffect-Ladeschleife. Die Dokumenten-Detail-/Vorschauseite /documents/{id} (Server Component src/app/(app)/documents/[id]/page.tsx + Client-Island DocumentPreview) zeigt die Datei-Vorschau in einem <iframe src="/api/documents/{id}/file"> (same-origin, Cookie-Auth greift automatisch, Browser-natives PDF/Bild-Rendering). Die Metadaten-Seitenleiste (Reiter Details/Inhalt/Verlauf) ist in die Unterkomponente DocumentDetailsTab ausgelagert und in einem kompakten 2-Spalten-Raster angeordnet (kurze Felder Belegdatum/Dokumenttyp/Korrespondent/Erstellt nebeneinander, Titel/Akte/Tags in voller Breite). Editierbar: Inline-Titel, Belegdatum (<input type="date">, PUT /api/documents/{id}/document-date), Dokumenttyp/Korrespondent/Akte (Command-Popover, Auswahl wird direkt beim Klick gespeichert) sowie Tags hinzufügen (POST /api/documents/{id}/tags/{tagId}) / entfernen (X am Badge, DELETE ...). Heuristische Vorschlags-Chips (inkl. erkanntem Belegdatum aus dem OCR-Text) lassen sich per Klick übernehmen. In der Kopfzeile der Detailseite liegen die Aktionen "Export" (Download-Icon, <a href="/api/documents/{id}/export" download> — ZIP mit Originaldatei, metadata.json und ocr_text.txt, same-origin per Session-Cookie, kein Blob-Umweg) und "Neu verarbeiten". Erreichbar über den "Vorschau"-Button bzw. das Auge-Icon in der Dokumentenliste.

Basis-Komponenten & Design-Tokens (FDN-06)

Alle wiederverwendbaren UI-Bausteine liegen ausschließlich unter src/components/ui/ (shadcn/ui-Stand, Radix-basiert). Neue Features benutzen diese Komponenten, statt eigene Varianten zu bauen; Anpassungen erfolgen in der Datei selbst (nicht per Kopie), Dateinamen werden nie umbenannt.

Komponente Datei Einsatzzweck
Table ui/table.tsx Standard-Listenansicht (Dokumente, Wiedervorlage, Admin-Tabellen). Wrapper hat overflow-auto → horizontal scrollbar auf schmalen Viewports
Dialog / Sheet ui/dialog.tsx, ui/sheet.tsx Modale Formulare bzw. mobile Drawer (Sidebar auf < md)
Formularelemente ui/input.tsx, ui/textarea.tsx, ui/label.tsx, ui/calendar.tsx Eingaben; Datumsauswahl über Calendar + Popover
Button ui/button.tsx Varianten (default/ghost/outline/destructive) + Größen über cva
Badge ui/badge.tsx Status-Anzeigen (Wiedervorlage, Verarbeitungsstatus, Share-Status)
Card ui/card.tsx Dashboard-Kacheln, Sektionen in Einstellungen
Tabs ui/tabs.tsx Reiter in der Dokument-Detailansicht (Details/Inhalt/Verlauf)
DropdownMenu / Popover / Command ui/dropdown-menu.tsx, ui/popover.tsx, ui/command.tsx Benutzermenü, Auswahl-Popover, Cmd+K-Befehlspalette
Toast ui/sonner.tsx Rückmeldungen nach Mutationen (toast() aus sonner); <Toaster /> global in src/app/layout.tsx
Sidebar ui/sidebar.tsx Shell-Navigation inkl. Collapse-State und Mobile-Drawer
Weitere ui/avatar.tsx, ui/progress.tsx, ui/skeleton.tsx Benutzer-Avatar, Upload-Fortschritt, Skeleton-Loading in loading.tsx

Die App-Shell selbst liegt in src/components/shell/ (AppSidebar, TopBar, SearchBar, CommandPalette) und wird von src/app/(app)/layout.tsx zusammengesetzt (SidebarProviderAppSidebar + SidebarInsetTopBar + Inhaltsbereich).

Design-Tokens sind zentral gepflegt: src/app/globals.css definiert die HSL-Werte je Theme (:root = hell, .dark = dunkel) für background/foreground, card, popover, primary, secondary, muted, accent, destructive, border, input, ring, die sidebar-*-Familie sowie --radius. tailwind.config.ts mappt sie auf Tailwind-Utilities (bg-background, text-muted-foreground, border-border, rounded-lg …). Abstände und Typografie kommen unverändert aus der Tailwind-Standardskala — bewusst kein eigener Satz, um Sonderwege zu vermeiden. Regel: keine Hex-/RGB-Literale und keine Inline-Styles für Farben in Komponenten. Einzige Ausnahme sind semantische Statusfarben (emerald/amber/red) an Badges und Farbbalken; Inline-style ist nur für berechnete Geometrie erlaubt (Progress-Balken, Crop-Rechtecke, OCR-Overlay-Boxen).

Projektstatus

Scaffold + erstes fachliches Feature (Wiedervorlage/Reminder, PROJ-1) als Code geschrieben, noch nicht gebaut/getestet (kein Go/Node-Toolchain in dieser Umgebung ausgeführt). Vor Inbetriebnahme: go build ./..., npm install && npm run build, Migrationen gegen echte PostgreSQL-Instanz prüfen.

Upload/OCR-Pipeline (POST /api/documents/upload, WORM-Storage, Duplikaterkennung per Content-Hash) ist implementiert, siehe Abschnitt "Storage-Struktur & OCR" unten. Nicht enthalten: Tests, LDAP/TOTP.

Hardware-Anforderungen

Grobe Schätzung aus Architektur (Go+PostgreSQL+Manticore+Next.js, analog archivmail-Stack) — nicht durch Lasttests validiert, Code noch nicht gebaut/getestet.

Betriebssystem/Abhängigkeiten:

  • Debian 13 (trixie)
  • Go ≥ 1.24 (empfohlen: 1.26.x, aktuell 1.26.5, Stand Juli 2026)
  • Node.js ≥ 20 + npm (empfohlen: 24.x Active LTS, Stand Juli 2026 — Node 26 noch Current, LTS erst ab Oktober 2026)
  • PostgreSQL ≥ 14 (empfohlen: 18.x, aktuell stabil 18.4, Stand Mai 2026 — Version 19 noch in Beta)
  • Manticore Search ≥ 27.x (aktuell 27.1.5, Stand Juni 2026 — Built-in Auth, Sharded Tables, Vektor-/Hybrid-Suche)
  • Tesseract OCR + poppler-utils (für Scan/OCR-Pipeline; ohne diese Pakete läuft der Server weiter, OCR wird dann übersprungen): apt install tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng poppler-utils
  • zbar-tools (für Barcode-Erkennung auf Trennblättern/Scans, internal/barcode; ohne dieses Paket läuft der Server weiter, Barcode-Decoding wird dann übersprungen): apt install zbar-tools
Szenario vCPU RAM Disk
Minimal (Kleinbetrieb, <5 User, wenig Volumen) 2 4 GB 50 GB (wächst mit Archiv)
Empfohlen (KMU, OCR aktiv, Volltextindex) 4 8 GB nach Archivgröße¹
Enterprise/Multi-Tenant mit Last 8+ 16+ GB SSD Pflicht, separates Storage-Volume²

¹ Faustregel: gescannte PDFs ~200KB2MB/Dokument, Manticore-Index zusätzlich ~2030% vom Rohtext-Volumen. ² WORM/Aufbewahrungsfristen bedeuten nur Wachstum, kein Löschen vor Fristablauf — Storage entsprechend planen.

Storage-Struktur & OCR

config.ymlstorage.base_path (ersetzt das alte store_path) ist die Wurzel für drei Unterverzeichnisse, die beim Start automatisch angelegt werden (os.MkdirAll):

<base_path>/inbox/<tenant_id>/<random>.<ext>              Roh-Upload, vor Hashing/Verschieben
<base_path>/store/<tenant_id>/<yyyy>/<mm>/<sha256>.<ext>   fertiges Archiv, WORM (chmod 0440)
<base_path>/ocr-tmp/<random>/                              pdftoppm-Zwischenbilder, nach Gebrauch gelöscht

Ablauf beim Upload (POST /api/documents/upload, multipart, Feld file + Pflichtfeld title):

  1. Datei wird nach inbox/<tenant_id>/ geschrieben, dabei SHA-256 berechnet.
  2. Zielpfad store/<tenant_id>/<yyyy>/<mm>/<hash>.<ext> wird ermittelt; existiert der Hash für den Mandanten bereits (Unique-Index idx_documents_tenant_hash), wird der Upload mit HTTP 409 als Duplikat abgelehnt.
  3. Datei wird per os.Rename (Fallback: Kopieren+Löschen bei Cross-Device) verschoben und mit chmod 0440 unveränderlich gemacht (WORM).
  4. Dokument-Metadaten und ein Verarbeitungsjob (processing_jobs) werden in einer Postgres-Transaktion angelegt, Audit-Log-Eintrag geschrieben, Antwort 201 an den Client. Das Dokument steht auf processing_status = 'queued'.
  5. OCR-Extraktion (internal/ocr), Taxonomie-Autozuordnung und on_upload-Workflows laufen asynchron über die Job-Queue (siehe unten) — nicht mehr im Request.

Weil OCR erst nachgelagert läuft, richtet sich der Archivordner <yyyy>/<mm> nach dem Upload-Zeitpunkt; das erkannte Belegdatum landet danach in documents.document_date, die archivierte Datei wird dabei nie verschoben (WORM).

Der Dateizugriff liegt hinter dem Interface objectstore.Store (internal/objectstore): Archive (Scratch-Datei → WORM-Ablage inkl. chmod 0440), Open, Stat, Delete, SignedURL, VerifySignedURL. Einzige Implementierung ist LocalStore (lokales Dateisystem) — bewusst kein S3-Treiber, weil die WORM-/GoBD-Garantie an POSIX-Rechten (0440) hängt. Das oben beschriebene Pfadschema bleibt unverändert; zusätzlich prüfen Open/Stat/Delete jetzt, dass der übergebene storage_path tatsächlich unter store/<tenant_id>/ liegt (Mandanten-Containment gegen Pfad-Traversal/IDOR). Verdrahtet wird der Treiber in Server.SetStorageConfig (Signierschlüssel per HKDF-SHA256 aus api.secret, Link-Basis aus server.fqdn).

Signierte, zeitlich begrenzte Download-URLs: POST /api/documents/{id}/signed-url (authentifiziert, mandantengeprüft, Body optional {"ttl_minutes": 15}) liefert {url, expires_at}. Eingelöst wird der Link unter GET /public/files?t=&d=&exp=&sig= — ohne Session, die HMAC-SHA256-Signatur über tenant|dokument|ablauf ist das Credential. Gleiche Schutzmechanik wie die Share-Links (harter Pflicht-Ablauf, per-IP-Rate-Limit, Audit-Trail signed_url_created/signed_url_accessed inkl. Fehlschlägen), aber zustandslos, ohne DB-Zeile und ohne Widerruf — für kurzlebigen Maschinenzugriff. Für Weitergabe an Dritte mit eigenem Lebenszyklus (Widerruf, Passwort, Zugriffslimit) bleiben die Share-Links das Mittel der Wahl. Abgelaufener Link → HTTP 410, gefälschter → 403, fehlende Datei → 404. Gültigkeit: storage.signed_url_ttl_minutes (Default 15), pro Anfrage überschreibbar, hart gedeckelt auf 24 h.

Trennseiten-Split (Barcode-Trennblätter)

internal/pagesplit zerlegt mehrseitige PDF-Scans beim Ingest an Barcode-Trennblättern in Einzeldokumente (Vorbild: Paperless-ngx, an die archivdms-Pipeline angepasst). Ablauf im synchronen Staging-Schritt, bevor irgendetwas archiviert wird (Schritt 1a in storeUploadedFile): pdfinfo liefert die Seitenzahl, pdftoppm -r 150 rastert jede Seite einmal, zbarimg (via internal/barcode) dekodiert die Barcodes; Seiten mit dem konfigurierten Marker (pagesplit.marker, Default ARCHIVDMS-SPLIT, Vergleich case-insensitiv, optional Präfix-Match) gelten als Trennblatt. Die Segmente dazwischen werden mit pdfseparate + pdfunite (poppler-utils, kein qpdf/pdftk) zu je einem Teil-PDF zusammengesetzt; das Trennblatt selbst wird verworfen. Jedes Teildokument durchläuft danach exakt denselben Pfad wie ein normaler Einzel-Upload (eigener Hash + Duplikatprüfung, eigene WORM-Ablage mit chmod 0440, eigener Verarbeitungsjob mit OCR/Taxonomie/Workflows).

Fail-safe: Detektor aus, fehlende Binaries, Nicht-PDF, weniger als zwei Seiten, mehr als max_pages, kein Trennblatt gefunden oder ein Fehler beim Split → das Dokument wird ungesplittet wie bisher archiviert, ein Upload wird nie deswegen abgelehnt. Ein nur teilweise erzeugter Split wird nie ausgeliefert.

GoBD: Der hochgeladene Stapel wird bei einem Split nicht zusätzlich archiviert (sonst läge jede Seite doppelt im Archiv). Nachvollziehbar bleibt die Aufteilung über das Audit-Event document_split, das Originaldateinamen, SHA-256 des Originals, Seitenzahl, die verworfenen Trennblatt-Seitennummern sowie je Teildokument Seitenbereich und Dokument-ID festhält — Erfolg und Fehlschlag (success=false, wenn ein Split angefangen und abgebrochen wurde).

Voraussetzung: apt install poppler-utils zbar-tools. Default aus (pagesplit.enabled: false), bewusst konservativ wie ocr.binarize_ocr. Aktuell globaler Config-Schalter; eine Mandanten-Einstellung samt UI ist der nächste Schritt.

Job-Queue (Mandanten-Fairness)

internal/jobqueue arbeitet die Nachverarbeitung in Worker-Goroutinen im selben Prozess ab (kein separater Dienst, kein Redis — die Queue ist die Postgres-Tabelle processing_jobs). Dispatch läuft Round-Robin über die Mandanten: pro Runde wird je Mandant genau ein Job gezogen (FOR UPDATE SKIP LOCKED), damit ein Massen-Upload eines Mandanten die anderen nicht aushungert. Fehlgeschlagene Jobs werden mit exponentiellem Backoff (2^retry_count Sekunden) neu eingeplant und bleiben ab max_retries dauerhaft auf failed. Ein Reaper setzt hängende processing-Jobs nach job_timeout_seconds zurück. Erfolg und Fehlschlag werden als Audit-Event document_processed protokolliert.

Sichtbarkeit in der UI: documents.processing_status (queued/processing/done/failed) wird mit der Dokumentenliste servergerendert ausgeliefert. Die Liste (DocumentsTable) und die Vorschau zeigen für alles außer done ein Badge („Warteschlange" / „Wird verarbeitet …" / „Fehlgeschlagen"); bei failed gibt es einen Button „Erneut versuchen". Endpunkte: GET /api/documents/{id}/processing-job (Status, retry_count, error_message) und POST /api/documents/{id}/processing-job/retry (nur aus failed heraus, sonst 409). Das Frontend pollt den GET-Endpunkt alle 3 s nur für Dokumente, die gerade nicht fertig sind — ist die Ansicht durchverarbeitet, läuft kein Timer und es entsteht kein Traffic.

Relevante Config-Keys (siehe config/config.yml.example):

storage:
  base_path: "/var/lib/archivdms"   # enthält inbox/, store/, ocr-tmp/
  retention_days: 3650
  max_upload_size_mb: 50
  signed_url_ttl_minutes: 15        # Default-Gültigkeit signierter Download-Links

ocr:
  tesseract_path: "tesseract"
  pdftoppm_path: "pdftoppm"
  languages: "deu+eng"
  timeout_seconds: 60

pagesplit:
  enabled: false            # Trennseiten-Split, Default AUS
  marker: "ARCHIVDMS-SPLIT"
  marker_prefix: false
  raster_dpi: 150
  max_pages: 200
  timeout_seconds: 120

jobqueue:
  disabled: false           # true = keine Nachverarbeitung, Dokumente bleiben "queued"
  workers: 2
  poll_interval_ms: 2000
  job_timeout_seconds: 600
  max_retries: 5

index:
  # Volltext-Index (Manticore, MySQL-Protokoll Port 9306). Leer = deaktiviert.
  # Phase 1: nur Schreib-/Sync-Layer, noch KEIN Such-Endpunkt.
  manticore_dsn: ""   # z.B. "archivdms@tcp(127.0.0.1:9306)/?charset=utf8mb4"

Volltext-Index (Manticore)

Sekundärer Volltext-Index (internal/index) über Manticore Search (MySQL-Protokoll, Port 9306, nur 127.0.0.1 gebunden, github.com/go-sql-driver/mysql, CGO-frei). PostgreSQL bleibt Source of Truth — der Index wird nur best-effort synchron gehalten: jeder Sync-Fehler wird geloggt, blockiert aber nie den auslösenden Request. Ist index.manticore_dsn leer, ist der Indexer nil und alle Sync-/Suchaufrufe sind No-ops (reproduzierbarer Start ohne manuelle Zusatzschritte: DSN in /etc/archivdms/config.yml setzen, Backend/Systemd-Dienst startet den Rest selbst — RT-Tabellen werden lazy beim ersten Tenant-Zugriff angelegt).

Pro Mandant existiert eine eigene RT-Tabelle documents_tenant_<tenant_id> (Tabellenname gegen Injection validiert, Mandantentrennung über getrennte Tabellen statt Row-Filter). Synchronisiert wird nach Upload/Create, ACL-/Tag-/Dokumenttyp-Änderung (RecomputeVisibility), Korrespondent- und Custom-Field-Änderung; beim Verschieben in den Papierkorb sowie bei finaler Löschung (Vier-Augen-bestätigt) wird der Eintrag aus dem Index entfernt (GoBD: endgültig gelöschte Dokumente dürfen nicht mehr auffindbar sein), beim Wiederherstellen neu indexiert. Ein manueller Neuaufbau des kompletten Index aus dem Postgres-Bestand ist jederzeit über archivdms reindex [-tenant N] möglich (streamt in Batches von 500 Dokumenten, bricht laut ohne exit(1) ab statt einen fehlenden manticore_dsn als stillen No-op zu behandeln). Such-Endpunkt: GET /api/documents/search (ACL-gefiltert; Manticore liefert nur IDs+Score, die vollständigen Dokumentzeilen kommen aus Postgres).

Index-Schema (internal/index/manticore.go, ensureTable):

Feld Typ Zweck
doc_id string Anzeige-ID (String-Form von id)
title text Volltext-durchsucht
doc_type text Volltext-durchsucht (Legacy-Freitext)
correspondent text Volltext-durchsucht (Legacy-Freitext)
ocr_text text Volltext-durchsucht, OCR-Ergebnis
tags text Volltext-durchsucht, Tag-Namen als Leerzeichen-separierter String
tag_ids multi (MVA) Attribut-Filter (Tag-Facette)
doc_type_id / correspondent_id bigint Attribut-Filter (Taxonomie-IDs)
acl_group_ids multi (MVA) Attribut-Filter für ACL (Sichtbarkeitsgruppen)
retain_until_ts / created_ts / updated_ts bigint (Unix) Zeitstempel-Attribute
deleted uint Soft-Delete-Flag, Search filtert immer deleted = 0

Gewichtung: Die Volltext-Suche matcht gleichgewichtet über @(title,ocr_text,tags,correspondent,doc_type) (kein field_weights-Boost) — Ranking erfolgt rein über Manticores BM25 (WEIGHT()), sortiert nach WEIGHT() DESC, created_ts DESC. Bewusst MVP: keine Feldgewichtung, um keine Suchsyntax-Erwartungshaltung/Tuning-Aufwand vor dem ersten echten Nutzungs-Feedback aufzubauen. Eine spätere Gewichtung (z. B. title stärker als ocr_text) ist eine reine Query-Änderung in Search, kein Schema-Umbau.

Deutsche Sprachbehandlung: Die Tabelle wird mit morphology='lemmatize_de_all,stem_en' angelegt — deutsche Lemmatisierung (u. a. Kompositazerlegung, Umlaut-Normalisierung über Manticores eingebautes de-Wörterbuch) plus englisches Stemming für Mischtexte. Keine explizite Stoppwortliste konfiguriert (Manticore filtert ohne stopwords=-Option keine Füllwörter heraus) — bei BM25-Ranking wirkt sich das nur moderat auf die Relevanz aus, da seltene Terme ohnehin höher gewichtet werden; bei Bedarf lässt sich eine deutsche Stoppwortliste (stopwords = de) nachrüsten, das erfordert danach einen vollen Reindex (archivdms reindex).

OCR-Wortkoordinaten & Vorschau-Overlay

Tesseract liefert pro Wort eine Bounding-Box (TSV), die in der Tabelle ocr_words persistiert und über GET /api/documents/{id}/ocr-words ausgeliefert wird (JSON-Array, Keys text/left/top/width/height/confidence/page/block/par/line, leeres Ergebnis []). Die Koordinaten sind Pixelwerte im Koordinatenraum des Original-Bildes.

Im Frontend legt DocumentImagePreview einen absolut positionierten Overlay-Layer über die Bildvorschau und rechnet die Boxen per min(clientWidth/naturalWidth, clientHeight/naturalHeight) inkl. object-contain-Letterbox-Versatz um; ein ResizeObserver hält das Overlay bei Sidebar-Drag, Resize und Vollbildwechsel deckungsgleich. Auf dieser Berechnung liegen drei Schichten: der Debug-Umschalter „OCR-Boxen" (Rahmen je Wort, rein visuell), das Suchtreffer-Highlight (amberfarbene Marker, ohne Toggle sichtbar) und die Copy/Select-Textebene (pro Wort ein transparentes, exakt auf die Box skaliertes <span> mit echtem Text — Markieren und Kopieren funktioniert damit wie in einer durchsuchbaren PDF; nur diese Spans haben pointer-events, die Bildfläche dazwischen bleibt normal bedienbar). Den Suchbegriff fürs Highlight reicht die Suchergebnisseite über ?q= an /documents/{id} durch (DocumentsTable-Prop highlightQuery → Vorschau-Links → searchParamsDocumentPreviewDocumentImagePreview); Matching ist ein case-insensitiver Teilstring-Vergleich pro Token, serverseitig hat Manticore bereits gefiltert. Nur für Bilddokumente — PDFs werden im <iframe> gerendert und bringen ihre eigene Textebene mit.

SFTP-Zugang pro Mandant

Alternative zum Web-Upload: ein eingebetteter SFTP-Server (internal/sftpserver, golang.org/x/crypto/ssh + github.com/pkg/sftp) läuft direkt im archivdms-Binary — kein separater OpenSSH-Prozess, keine echten Linux-System-User pro Mandant. Ein Mandant, der per SFTP eine Datei ablegt, landet virtuell "eingesperrt" in seinem eigenen inbox/<tenant_id>/-Ordner (Software-Chroot, keine OS-Chroot-Mechanik); ein Watcher (Polling alle 5s) holt neue Dateien dort ab und schickt sie durch dieselbe Pipeline wie POST /api/documents/upload (Hash, Duplikaterkennung, Move nach store/, chmod 0440, Job-Anlage, Audit-Log) — OCR/Taxonomie/Workflows laufen anschließend über dieselbe Job-Queue.

Aktivierung in config.yml:

sftp:
  enabled: true
  bind: ":2222"
  host_key_path: ""   # leer = <storage.base_path>/.ssh/host_key, wird beim ersten Start generiert

Zugangsdaten sind vom normalen Login getrennt — ein SFTP-Zugang ist ein eigenständiges, jederzeit widerrufbares Credential (Tabelle sftp_credentials, bcrypt-gehashtes Passwort), kein volles Benutzerkonto. Ein Admin (domain_admin/superadmin) legt einen Zugang über die Admin-API an:

POST   /api/admin/sftp-credentials   { "username": "mandant1-sftp" }
       -> Antwort enthält das Passwort im Klartext, aber nur dieses eine Mal
GET    /api/admin/sftp-credentials
DELETE /api/admin/sftp-credentials/{id}

Danach kann der Mandant sich direkt per SFTP-Client verbinden und Dateien flach in sein Wurzelverzeichnis hochladen (v1: kein Anlegen/Navigieren von Unterordnern):

sftp -P 2222 mandant1-sftp@dms.example.com
put rechnung.pdf

Tenant-Verwaltung (Mandanten)

Mandanten werden über eine superadmin-only API verwaltet (internal/api/tenant_handlers.go):

POST /api/tenants   { "name": "...", "slug": "...", "domain": "..." }   -> legt einen Mandanten an
GET  /api/tenants                                                       -> listet alle Mandanten

Frontend: /settings/tenants (nur für Rolle superadmin sichtbar/aufrufbar, Server-Component-Rollencheck über GET /api/auth/me; für andere Rollen nur ein Hinweistext statt Formular). Beim Anlegen eines Users (POST /api/users) darf ein Superadmin tenant_id explizit im Body angeben (auch null für einen weiteren Superadmin); für domain_admin wird tenant_id serverseitig immer hart auf den eigenen Tenant erzwungen — ein im Body mitgeschickter abweichender Wert wird ignoriert (IDOR-Schutz gegen Cross-Tenant-User-Anlage).

Benutzerverwaltung: /settings/users (für domain_admin und superadmin; gleicher Server-Component-Rollencheck, andernfalls nur Hinweistext). Liste aller Konten des eigenen Mandanten mit Anlegen (Dialog: Benutzername, E-Mail, Passwort, Rolle), Bearbeiten (E-Mail, Rolle, Aktiv-Status, optionaler Passwort-Reset) und Löschen mit Bestätigungsdialog. Ein domain_admin kann nur die Rollen user/domain_admin vergeben, ein superadmin zusätzlich superadmin. Der Menüpunkt "Benutzer" in der Sidebar erscheint nur bei passender Rolle; die eigentliche Autorisierung erzwingt weiterhin das Backend (requireRole).

Benutzerdefinierte Felder: /settings/custom-fields (admin-only, Server-Component-Rollencheck). Definition von Zusatzfeldern mit Typ text/number/date/boolean/enum/monetary (enum mit Optionsliste, monetary mit optionaler Währung). Technischer Name und Typ sind nach dem Anlegen unveränderlich (Backend erzwingt), Bearbeiten betrifft nur Label/Optionen/Währung; Löschen ist blockiert, solange noch Werte referenzieren (409). Die Zuordnung welcher Felder bei welchem Dokumenttyp erscheinen (inkl. required/visible/Reihenfolge) erfolgt als Sektion unter /settings/document-types (Bulk-PUT /api/document-types/{id}/fields). Pro Dokument werden die zugeordneten (sichtbaren) Feldwerte über den Button "Felder" in der Dokumentenliste bearbeitet (GET/PUT /api/documents/{id}/fields, typgerechte Eingaben, Pflichtfeld-Validierung serverseitig).

Klassifizierungsvorlagen: /settings/classification-templates (admin-only, Server-Component-Rollencheck). Eine Vorlage bündelt Dokumenttyp, Tags, Custom-Field-Vorgaben (je Feld mit optionalem Overwrite-Flag) und eine Aufbewahrungsdauer (retain_years), ohne persistent an Dokumente gekoppelt zu sein — spätere Vorlagen-Änderungen wirken nie rückwirkend, jede Anwendung wird nur im Audit-Log festgehalten (GoBD-Nachvollziehbarkeit). Verwaltung (CRUD + Bulk-Replace von Tags/Feld-Vorgaben) über GET/POST/PUT/DELETE /api/classification-templates[/{id}] sowie PUT .../{id}/tags und PUT .../{id}/field-defaults. Anwendung auf ein Dokument über den Button "Vorlage anwenden" in der Dokumentenliste (POST /api/documents/{id}/apply-template): eine Dry-Run-Vorschau zeigt zunächst das Diff (Tags hinzufügen/vorhanden, Felder setzen/überschreiben/überspringen, Aufbewahrung vorher→nachher). Eine Aufbewahrungsfrist wird nie verkürzt (auch nicht mit Overwrite — dann Hinweis "blockiert"); bereits gesetzte Feldwerte werden nur bei explizit angehaktem Overwrite überschrieben.

Aufbewahrungsregeln (Retention Rules): /settings/retention-rules (admin-only, Server-Component-Rollencheck über GET /api/auth/me, sonst Hinweistext). Verwaltung GoBD-konformer Aufbewahrungsfristen je Dokumenttyp (doc_type_id = null = mandantenweiter Default). Pro Regel: Auslöser (document_date/upload_date/fixed_date/event; bei fixed_date ein Datum YYYY-MM-DD), Frist in Jahren + zusätzlichen Tagen, Rechtsgrundlage (z.B. §147 AO), Freigabepflicht für die Vernichtung, DSGVO-Konflikt-Flag und Aktiv-Status. CRUD über GET /api/retention-rules, POST /api/retention-rules, PATCH /api/retention-rules/{id}, DELETE /api/retention-rules/{id} (Mutationen domain_admin/superadmin). Dashboard-Kachel "Warten auf Löschfreigabe" zählt Dokumente mit abgelaufener Frist, die noch nicht im Papierkorb sind (GET /api/retention-rules/eligible); Dry-Run-Vorschau über GET /api/retention-rules/preview.

Verfahrensdokumentation (GoBD, Entwurf): GET /api/compliance/procedure-documentation liefert einen live aus dem Datenbankstand erzeugten Markdown-Baustein einer Verfahrensdokumentation als Download (text/markdown, verfahrensdokumentation-entwurf-<mandant>-<datum>.md). Erfordert domain_admin/superadmin; ein superadmin kann über ?tenant_id=N einen fremden Mandanten exportieren, alle anderen Rollen erhalten dabei 403. Automatisch befüllt werden Aufbewahrungsfristen, Zugriffsschutz (Rollen, Gruppen, Grants), Löschkonzept (WORM + Vier-Augen-Prinzip), Erfassungsautomatisierung (Workflows/Klassifizierungsvorlagen) und Nachvollziehbarkeit (Audit-Log); Organisationsbeschreibung, Verantwortliche und Server-/Backup-/Notfallkonzept sind als [MANUELL ZU ERGÄNZEN] gekennzeichnete Platzhalter. Das Ergebnis ist ausdrücklich ein Entwurf und kein rechtsverbindliches Fertigdokument. Frontend: Abschnitt "GoBD-Verfahrensdokumentation" am Fuß von /settings/retention-rules mit Button "Verfahrensdokumentation (Entwurf) herunterladen" (ProcedureDocumentationDownload, Blob-Download mit Session-Cookie) und Entwurfs-Warnhinweis.

Installation

Native Installation auf Debian 13 (trixie), z.B. in einem unprivilegierten Proxmox-LXC-Container — kein Docker. Es gibt (noch) kein Git-Remote-Repository für archivdms; install.sh/update.sh erwarten daher, dass der Quellcode bereits lokal auf dem Zielserver liegt (z.B. per rsync/scp kopiert), statt per git clone/git pull zu arbeiten.

scp -r . root@<server>:/opt/archivdms-src
ssh root@<server>
cd /opt/archivdms-src
ARCHIVDMS_SRC=/opt/archivdms-src bash install.sh

install.sh installiert Go/Node/PostgreSQL/Manticore/Tesseract, legt Systembenutzer und Storage-Struktur an, generiert config.yml sowie ein selbstsigniertes TLS-Zertifikat (Let's-Encrypt optional per certbot --nginx), richtet nginx und die systemd-Dienste archivdms/archivdms-web ein und ruft für den ersten Build update.sh auf.

Für spätere Deploys aus einem neuen lokalen Codestand genügt danach:

bash update.sh

update.sh baut aus dem lokalen Quellverzeichnis neu (kein git pull), synchronisiert nach /opt/archivdms und startet die Dienste neu. Details und Kommentare zum "Warum" einzelner Entscheidungen stehen direkt in den Skripten.

Beleg-Erfassung per Handy

Route /scan (in der Sidebar als "Beleg erfassen") öffnet auf mobilen Browsern direkt die Rückkamera (<input type="file" capture="environment">), kein natives App nötig. Ein Foto genügt, Titel wird automatisch aus Datum/Uhrzeit gesetzt, Upload läuft über dieselbe Pipeline wie /documents (Hash, WORM-Ablage, OCR, automatisches Tag-/Korrespondent-Matching). Dokumenttyp/Korrespondent lassen sich danach unter /documents nachträglich ergänzen.

Migrationen & Entwicklungs-Seed (FDN-02)

Schemaänderungen laufen weiterhin über die idempotenten initSchema()-Funktionen der Store-Pakete (kein Migrationstool). Ergänzend gilt: zu jeder neuen internal/storage/migrations/NNN_name.sql gehört eine NNN_name.down.sql mit reviewtem Rückbau-SQL (manuell per psql -f auszuführen, nie automatisch). Details und Anforderungen: internal/storage/migrations/README.md.

Für eine frische lokale Datenbank legt

archivdms seed dev [-config PATH] [-name NAME] [-slug SLUG] [-email EMAIL] [-role ROLE] [-reset-password]

einen Test-Mandanten ("Testfirma") und einen tenant-gebundenen Test-Benutzer (testuser@testfirma.local, Rolle domain_admin) an. Der Befehl ist idempotent (Mandant über Slug, Benutzer über E-Mail); das Passwort wird zufällig erzeugt und einmalig auf der Konsole ausgegeben — es steht nirgends im Code oder in der Config.

Struktur

cmd/archivdms/       CLI-Einstiegspunkt (serve, reminders notify, seed dev)
config/               YAML-Konfiguration
internal/api/         HTTP-Handler (auth, user, audit, document, reminder, sftp-credentials)
internal/audit/       Append-only Audit-Log (GoBD-Nachvollziehbarkeit)
internal/auth/        JWT-Session-Handling
internal/mailer/      SMTP-Versand (Benachrichtigungen)
internal/sftpserver/  Eingebetteter Per-Mandant-SFTP-Server + Inbox-Watcher
internal/storage/     Postgres-Schema (documents, reminders, sftp_credentials) + Migrations-Doku
internal/tenantstore/ Mandantenverwaltung
internal/userstore/   Benutzerverwaltung
deploy/cron.d/        Cron-Jobs (Wiedervorlage-Benachrichtigung)
features/             Feature-Spezifikationen (PROJ-N-name.md)
src/                  Next.js-Frontend

Features (Ziel, siehe dms-featureliste-prompt.md)

Umgesetzt: Grundgerüst (Auth, Tenant, Audit, Mailer), Dokument-Kernmodell, Dashboard-Startseite (Kennzahlen-Kacheln + Dokumente-nach-Typ, Server-Component), Wiedervorlage (PROJ-1), Papierkorb mit gestaffelter Löschung (Soft-Delete + Vier-Augen-Prinzip, Aufbewahrungsfrist-Prüfung), Berechtigungsmodell (gruppenaufgelöste Dokument-ACL).

Geplant: GoBD-Compliance (WORM, Aufbewahrungsfristen-Engine), ZUGFeRD/XRechnung-Parser, Hybrid-Suche, Workflow-Engine, DATEV-Schnittstelle, OIDC/SSO, Kanban-Wiedervorlage-Ansicht, später nativer Linux-Client (nutzt dieselbe REST-API).

Berechtigungsmodell (Dokument-ACL)

Zugriff auf Dokumente wird über Berechtigungsgruppen (nie direkt pro User) vergeben und über drei Ebenen aufgelöst, spezifischer schlägt allgemeiner: (1) Einzeldokument-Grant (document_grants, inkl. deny), (2) Tag-Grant (tag_grants), (3) Dokumenttyp-Default (document_type_grants). Das Ergebnis wird nach document_visibility materialisiert (RecomputeVisibility, neu berechnet bei Grant-, Tag- oder Dokumenttyp-Änderung). Rollen bleiben Außengrenze: superadmin sieht alles mandantenübergreifend, domain_admin sieht per Default alles im eigenen Mandanten (bypasst die ACL), nur Rolle user wird gegen document_visibility gefiltert.

API (alle domain_admin+): POST/GET/DELETE /api/permission-groups, GET/POST/DELETE /api/permission-groups/{id}/members, GET/POST/DELETE /api/document-types/{id}/grants, GET/POST/DELETE /api/tags/{id}/grants, GET/POST/DELETE /api/documents/{id}/grants (access read|write|deny). Die GET-Endpunkte liefern Mitglieder ({user_id,username,email}) bzw. aufgelöste Grants ({group_id,group_name,access}). Schema-Doku: internal/storage/migrations/008_permissions.sql.

Frontend (admin-only, Server-Component-Rollencheck + Sidebar-Eintrag "Berechtigungsgruppen"): /settings/permission-groups verwaltet Gruppen (anlegen/löschen) und ihre Mitglieder (User-Picker aus dem Mandanten). Default-Grants je Dokumenttyp bzw. Tag werden als Sektion "Zugriffsrechte" unter /settings/document-types und /settings/tags gesetzt (Gruppe + Lesen/Schreiben). Einzeldokument-Freigaben inkl. deny (Sperren) über den Button "Freigaben" in der Dokumentenliste. Die UIs laden den tatsächlichen Serverstand (Mitglieder beim Öffnen des Dialogs, Grants beim Auswählen der Entität) und halten ihn nach jeder Mutation synchron.

Ablaufende, optional passwortgeschützte öffentliche Links auf ein Dokument. Der Token (32 Byte, base64url) wird nur beim Erzeugen genau einmal zurückgegeben; gespeichert wird ausschließlich sein SHA-256-Hash. expires_at ist Pflicht (kein unbegrenzter Share), password (bcrypt) und max_accesses optional. Kein Hard-Delete — Revoke setzt nur revoked_at/revoked_by. Jeder öffentliche Zugriffsversuch wird in document_share_accesses protokolliert (success/expired/revoked/max_reached/bad_password/rate_limited) und zusätzlich im Audit-Log (EventShareAccessed).

Authentifiziert (tenant-scoped): POST /api/documents/{id}/shares (Body: expires_at Pflicht, optional max_accesses, password), GET /api/documents/{id}/shares, DELETE /api/shares/{share_id}, GET /api/shares (domain_admin+, alle Shares des Mandanten). Öffentlich (ohne Auth, per-IP rate-limited): GET /public/share/{token} (Metadaten: Titel, ob Passwort nötig, Ablauf), POST /public/share/{token}/download (Body optional {password}, streamt die Datei serverseitig aus dem WORM-Store — storage_path/content_hash werden nie exponiert). Prüfreihenfolge beim Abruf: revoked → expired → max_accesses → Passwort → Auslieferung (access_count++ atomar). Schema-Doku: internal/storage/migrations/009_shares.sql.

Frontend: Button "Teilen" in der Dokumentenliste öffnet ShareDialog — listet bestehende Freigaben (Status-Badge grün=aktiv / rot=abgelaufen bzw. Limit erreicht / blass=widerrufen, Ablauf, Zugriffszähler, Widerrufen-Button) und erzeugt neue (Ablaufdatum Pflicht via datetime-local, optional max. Zugriffe + Passwort). Der Klartext-Token wird nach dem Erzeugen einmalig prominent als kopierbarer Link (/public/share/<token>) angezeigt. Admin-Übersicht aller Mandanten-Shares unter /settings/shares. Die öffentliche, login-freie Seite liegt außerhalb der (app)-Route-Gruppe unter src/app/public/share/[token]/page.tsx (kein App-Shell) und ist in middleware.ts via PUBLIC_PATHS = ["/login", "/public"] vom Auth-Gate ausgenommen. Da die Backend-/public/*-Routen nicht unter /api liegen, proxyt next.config.ts sie über den kollisionsfreien Prefix /public/api/* → <backend>/public/*.

Buchhaltungs-Pull-API

Lesender Maschinen-Zugang, damit ein Buchhaltungssystem belegdatum-bewertete Dokumente abholen kann.

API-Keys je Mandant (accounting_api_keys): Rohkey adms_ + 32 Byte base64url, wird genau einmal beim Erzeugen zurückgegeben, gespeichert wird nur der SHA-256-Hash (Muster wie Share-Links). Kein Hard-Delete — Revoke setzt revoked_at. Verwaltung mit normaler Session, domain_admin+: POST /api/accounting/api-keys (Body {label}{key, plaintext_key}), GET /api/accounting/api-keys (ohne Klartext/Hash), DELETE /api/accounting/api-keys/{id}. Frontend: /settings/accounting-keys (admin-only, Server-Component-Rollencheck über GET /api/auth/me) — Tabelle mit Bezeichnung, Erstellt, Zuletzt verwendet und Status-Badge (grün=aktiv, blass=widerrufen), Anlege-Dialog mit Bezeichnung und Widerruf-Dialog mit Warnung. Der Klartext-Key wird nach dem Anlegen einmalig in einem eigenen Dialog mit Warnhinweis und Kopieren-Button angezeigt und beim Schließen aus dem Client-State verworfen.

Pull-Endpunkte (Auth ausschließlich per Authorization: Bearer <key>, keine Cookie-Session, per-IP rate-limited): GET /api/v1/accounting/documents?since=&until=&doc_type_id=&min_date_score=&cursor=&limit= liefert {documents, next_cursor, has_more} mit Cursor-Pagination über (created_at, id) und nur reduzierten Metadaten (id, title, document_date, document_date_score, doc_type(_id), correspondent(_id), created_at) — storage_path/content_hash/ocr_text werden nie exponiert. GET /api/v1/accounting/documents/{id}/file streamt die WORM-Datei serverseitig. since/until grenzen document_date ein (YYYY-MM-DD oder RFC3339), min_date_score ist der Konfidenz-Filter (z.B. 0.75), limit max. 500 (Default 100).

Mandantentrennung: Der Mandant wird ausschließlich aus dem aufgelösten API-Key abgeleitet und in den Request-Context gelegt; ein ?tenant_id= wird ignoriert. Fremde/unbekannte Dokumente liefern 404 (kein 403). Jeder Datei-Download, jede Listenabfrage (mit count/id-Range) und jeder abgelehnte Auth-Versuch landen im Audit-Log (accounting_pull, accounting_key_created, accounting_key_revoked). Schema-Doku: internal/storage/migrations/026_accounting_api_keys.sql.

Bewusst nicht enthalten (erster Schritt): Changes-Feed für geänderte/gelöschte Belege, Betragserkennung.

Zukunftsnotiz

Linux-Desktop-Client ist geplant, aber nachrangig — erst Web-UI/API vollständig, Client greift dann auf dieselbe API zu (Token-Auth von Anfang an vorgesehen).

S
Description
No description provided
Readme
784 KiB
Languages
Go 63.7%
TypeScript 33.1%
Shell 2.6%
Python 0.3%
PLpgSQL 0.2%
Other 0.1%