diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..42d5148e --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI + +on: + push: + pull_request: + +jobs: + go-core: + name: Core-Modul (Build/Vet/Test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build, Vet, Test + run: | + go build ./... + go vet ./... + go test ./... -count=1 -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -1 + + dms-security-gate: + name: DMS Security-Test-Gate (AC5, Merge-Gate) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Gate-Selbsttest (beweist, dass fehlende Tests abgelehnt werden) + working-directory: dms + run: bash scripts/check-security-test-coverage_selftest.sh + - name: Gate gegen aktuellen Stand ausfuehren + working-directory: dms + run: bash scripts/check-security-test-coverage.sh + + dms-build-test: + name: DMS-Modul (Lint/Build/Test/Coverage/Artefakt) + runs-on: ubuntu-latest + needs: dms-security-gate + steps: + - uses: actions/checkout@v4 + - name: Lint + working-directory: dms + run: golangci-lint run ./... + - name: Testumgebung zuruecksetzen + working-directory: dms + env: + NEXARCH_DMS_TEST_DB_PASSWORD: ${{ secrets.NEXARCH_DMS_TEST_DB_PASSWORD }} + run: bash scripts/reset-test-env.sh + - name: Build, Vet, Test mit Testabdeckung + working-directory: dms + env: + NEXARCH_DMS_TEST_DB_PASSWORD: ${{ secrets.NEXARCH_DMS_TEST_DB_PASSWORD }} + run: | + go build ./... + go vet ./... + go test ./... -p 1 -count=1 -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -1 + - name: Artefakt bauen (Reproduzierbarkeits-Nachweis) + working-directory: dms + run: | + go build -trimpath -o /tmp/dms-app-artifact-1 ./cmd/app + go build -trimpath -o /tmp/dms-app-artifact-2 ./cmd/app + cmp /tmp/dms-app-artifact-1 /tmp/dms-app-artifact-2 + + dms-govulncheck: + name: DMS-Modul Supply-Chain-Scan (govulncheck) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: govulncheck installieren + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Schwachstellen-Scan + working-directory: dms + run: "$(go env GOPATH)/bin/govulncheck ./..." + + web: + name: Web (${{ matrix.app }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: [shl, dms-browser, pdf-viewer] + steps: + - uses: actions/checkout@v4 + - name: Abhaengigkeiten installieren + working-directory: web/${{ matrix.app }} + run: npm install + - name: Typpruefung + working-directory: web/${{ matrix.app }} + run: npx tsc --noEmit + - name: Tests mit Testabdeckung + working-directory: web/${{ matrix.app }} + run: npx vitest run --coverage + - name: Lint + if: matrix.app != 'shl' + working-directory: web/${{ matrix.app }} + run: npm run lint + - name: Build + if: matrix.app != 'shl' + working-directory: web/${{ matrix.app }} + run: npm run build + - name: Supply-Chain-Scan (npm audit, kritische Schwachstellen) + working-directory: web/${{ matrix.app }} + run: npm audit --audit-level=critical + + web-a11y: + name: Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) + runs-on: ubuntu-latest + concurrency: + group: a11y-audit-testhost + cancel-in-progress: false + steps: + - uses: actions/checkout@v4 + - name: Barrierefreiheitspruefung (web/a11y-audit/run.sh) + env: + NEXARCH_DMS_TEST_DB_PASSWORD: ${{ secrets.NEXARCH_DMS_TEST_DB_PASSWORD }} + A11Y_CHROME: ${{ vars.A11Y_CHROME }} + run: | + export A11Y_DSN="postgres://${TEST_TENANT_ROLE:-nexarch_dms_test}:${NEXARCH_DMS_TEST_DB_PASSWORD}@localhost:5432/${TEST_TENANT_DB:-dms_tenant_test}?sslmode=disable" + export OUT="$RUNNER_TEMP/a11y-out" + bash web/a11y-audit/run.sh + - name: Berichte sichern + if: always() + run: | + mkdir -p "$RUNNER_TEMP/a11y-out" && ls -la "$RUNNER_TEMP/a11y-out" | head -30 diff --git a/.gitignore b/.gitignore index a5e59d50..cf841490 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ .env web/*/node_modules/ web/*/.next/ +web/*/next-env.d.ts +web/*/tsconfig.tsbuildinfo +web/pdf-viewer/public/pdf.worker.min.mjs diff --git a/DEVLOG.md b/DEVLOG.md index f3f5fa31..fef03f49 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -228,6 +228,101 @@ Keine Commits in dieser Session. --- ## 2026-08-28 23:37 – 23:43 (5m) +## 2026-09-18 13:02 – 13:03 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code +## 2026-09-18 12:07 – 12:09 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch +## 2026-09-18 13:52 – 13:53 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/DOC-18-PRUEFPROTOKOLL.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- dms/internal/export/export.go | 62 +++++++++++++++++++++++++++++++++++++++++++++---- +- dms/internal/export/export_test.go | 8 +++---- +- dms/internal/export/limits_test.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-18 13:12 – 13:12 (0m) +## 2026-09-18 14:00 – 14:00 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/DOC-06-PRUEFPROTOKOLL.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- DEVLOG.md | 13 +++++++++++++ +- web/shl/vitest.setup.ts | 8 ++++++++ +- dms/deploy/manticore/schema/0001_dms_documents.sql | 34 +++++++++++++++++ +- dms/go.mod | 1 + +- dms/internal/searchindex/query.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- dms/internal/searchindex/schema.go | 70 +++++++++++++++++++++++++++++++++++ +- dms/internal/searchindex/searchindex_test.go | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-18 14:03 – 14:03 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/deploy/manticore/schema/0001_dms_documents.sql | 34 +++++++++++++++++ +- dms/go.mod | 1 + +- dms/internal/searchindex/query.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- dms/internal/searchindex/schema.go | 70 +++++++++++++++++++++++++++++++++++ +- dms/internal/searchindex/searchindex_test.go | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-18 14:06 – 14:07 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/SRC-01-PRUEFPROTOKOLL.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-18 14:11 – 14:12 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/SRC-01-PRUEFPROTOKOLL.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-20 00:52 – 00:53 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** banking + +### Commits +- f45cf29 Integration: gofmt-Bereinigung und Pruefprotokoll + +### Geänderte Dateien +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 32 ++++++++++++++++++++++++++++++++ +- dms/internal/ocr/engine.go | 2 +- +- dms/internal/searchsync/searchsync.go | 8 ++++---- +- dms/internal/searchsync/searchsync_test.go | 2 +- +- dms/internal/testcorpus/office.go | 8 ++++---- +- dms/internal/testcorpus/tiff.go | 14 +++++++------- +- dms/internal/thumbnail/thumbnail_test.go | 18 +++++++++--------- +- dms/internal/versioningapi/handler_test.go | 1 + + +--- +## 2026-09-20 00:54 – 00:54 (0m) **Beschreibung:** Claude Code Session **Projekt:** nexarch @@ -246,6 +341,22 @@ Keine Commits in dieser Session. ## 2026-08-29 00:00 – 00:00 (0m) **Beschreibung:** Claude Code Session **Projekt:** code +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 32 ++++++++++++++++++++++++++++++++ +- dms/internal/ocr/engine.go | 2 +- +- dms/internal/searchsync/searchsync.go | 8 ++++---- +- dms/internal/searchsync/searchsync_test.go | 2 +- +- dms/internal/testcorpus/office.go | 8 ++++---- +- dms/internal/testcorpus/tiff.go | 14 +++++++------- +- dms/internal/thumbnail/thumbnail_test.go | 18 +++++++++--------- +- dms/internal/versioningapi/handler_test.go | 1 + + +--- +## 2026-09-20 00:55 – 00:56 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code-integ ### Commits Keine Commits in dieser Session. @@ -261,6 +372,19 @@ Keine Commits in dieser Session. ## 2026-08-29 00:03 – 00:06 (3m) **Beschreibung:** Claude Code Session **Projekt:** code +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 32 ++++++++++++++++++++++++++++++++ +- dms/internal/ocr/engine.go | 2 +- +- dms/internal/searchsync/searchsync.go | 8 ++++---- +- dms/internal/searchsync/searchsync_test.go | 2 +- +- dms/internal/testcorpus/office.go | 8 ++++---- +- dms/internal/testcorpus/tiff.go | 14 +++++++------- +- dms/internal/thumbnail/thumbnail_test.go | 18 +++++++++--------- +- dms/internal/versioningapi/handler_test.go | 1 + + +--- +## 2026-09-20 01:01 – 01:01 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** banking ### Commits Keine Commits in dieser Session. @@ -273,6 +397,68 @@ Keine Commits in dieser Session. --- ## 2026-08-29 00:06 – 00:07 (0m) +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 32 ++++++++++++++++++++++++++++++++ +- dms/internal/ocr/engine.go | 2 +- +- dms/internal/searchsync/searchsync.go | 8 ++++---- +- dms/internal/searchsync/searchsync_test.go | 2 +- +- dms/internal/testcorpus/office.go | 8 ++++---- +- dms/internal/testcorpus/tiff.go | 14 +++++++------- +- dms/internal/thumbnail/thumbnail_test.go | 18 +++++++++--------- +- dms/internal/versioningapi/handler_test.go | 1 + + +--- +## 2026-09-20 01:03 – 01:03 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code-integ + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 32 ++++++++++++++++++++++++++++++++ +- dms/internal/ocr/engine.go | 2 +- +- dms/internal/searchsync/searchsync.go | 8 ++++---- +- dms/internal/searchsync/searchsync_test.go | 2 +- +- dms/internal/testcorpus/office.go | 8 ++++---- +- dms/internal/testcorpus/tiff.go | 14 +++++++------- +- dms/internal/thumbnail/thumbnail_test.go | 18 +++++++++--------- +- dms/internal/versioningapi/handler_test.go | 1 + + +--- +## 2026-09-20 20:12 – 20:14 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** banking + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 2 ++ + +--- +## 2026-09-20 20:22 – 20:22 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code-integ + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 2 ++ + +--- +## 2026-09-20 20:23 – 20:24 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** banking + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/docs/INTEGRATION-PRUEFPROTOKOLL.md | 2 ++ + +--- +## 2026-09-20 23:02 – 23:02 (0m) **Beschreibung:** Claude Code Session **Projekt:** nexarch @@ -297,6 +483,16 @@ Keine Commits in dieser Session. ## 2026-08-28 23:44 – 23:44 (0m) **Beschreibung:** Claude Code Session **Projekt:** code +Keine Commits in dieser Session. + +### Geänderte Dateien +- dms/internal/workerruntime/workerruntime_test.go | 6 +++--- +- scripts/qa02-foundation-gate.sh | 2 +- + +--- +## 2026-09-20 23:03 – 23:03 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** code-integ ### Commits Keine Commits in dieser Session. @@ -404,5 +600,7 @@ Keine Commits in dieser Session. - internal/pflichttestgate/gate_test.go | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - internal/testdbisolation/isolation.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ - internal/testdbisolation/isolation_test.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- dms/internal/workerruntime/workerruntime_test.go | 6 +++--- +- scripts/qa02-foundation-gate.sh | 2 +- --- diff --git a/deploy/kosit/README.md b/deploy/kosit/README.md new file mode 100644 index 00000000..e1f5c74d --- /dev/null +++ b/deploy/kosit/README.md @@ -0,0 +1,57 @@ +# KoSIT-Validator als Sidecar (MET-07) + +Prüft E-Rechnungen (XRechnung, ZUGFeRD/Factur-X) gegen XSD und Schematron. Läuft als +eigener Dienst, nur auf `127.0.0.1:8610` (DMS-Portbereich 8600-8699, `deploy/ports.tsv`). +Der DMS-Worker spricht ihn über `NEXARCH_DMS_WORKER_KOSIT_ADDR` an. Ohne diese Variable +ist das Ergebnis immer `not_checked`. + +## Bestandteile (feste Versionen, Prüfsummen SHA-256) + +| Datei | Version | SHA-256 | +|---|---|---| +| Temurin JRE (Linux x64) | 21.0.12.1 | `2413149700df0f7d440500a84a8f764c535f21e5a5e87d38328b64eec2c5b500` | +| `validator-1.6.3-standalone.jar` | 1.6.3 | `799e64befca97d4080e03608c80b85dd5a5ecc5f4ae4f35d1116ec2855b9a7c9` | +| `xrechnung-3.0.2-validator-configuration-2026-08-31.zip` | XRechnung 3.0.2, Regelwerk 2026-08-31 | `2530cd107c414511c5d0462ec10f886910395abfca820db82e83d70bf01221a8` | + +Quellen: `github.com/adoptium/temurin21-binaries`, `github.com/itplr-kosit/validator`, +`github.com/itplr-kosit/validator-configuration-xrechnung`. Versionen unter 1.6.3 nicht +verwenden (Lücke bei der URI-Auflösung im STRICT_LOCAL-Modus). + +## Installation (nur unter /opt/nexarch-kosit, nichts am System ändern) + +``` +useradd --system --no-create-home --home-dir /opt/nexarch-kosit --shell /usr/sbin/nologin nexarch-kosit +mkdir -p /opt/nexarch-kosit/{dl,jre,validator,config} +# Downloads nach dl/, Prüfsummen mit sha256sum -c prüfen, dann: +tar xzf dl/jre.tgz -C /opt/nexarch-kosit/jre --strip-components=1 +cp dl/validator-standalone.jar /opt/nexarch-kosit/validator/ +unzip -q dl/config.zip -d /opt/nexarch-kosit/config +chown -R root:root /opt/nexarch-kosit && chmod -R go-w /opt/nexarch-kosit +cp nexarch-dms-kosit.service /etc/systemd/system/ +systemctl daemon-reload && systemctl enable --now nexarch-dms-kosit +``` + +Der Start dauert etwa 20 Sekunden (Laden der Szenarien). Der Dienst hat `MemoryMax=768M`, +`-Xmx384m` und läuft ohne Rechte außerhalb von `/opt/nexarch-kosit`. + +## Betrieb + +- Worker: `NEXARCH_DMS_WORKER_KOSIT_ADDR=http://127.0.0.1:8610`, + `NEXARCH_DMS_WORKER_KOSIT_RULESET=xrechnung-3.0.2-2026-08-31` (Bezeichnung des installierten + Regelwerks, wird im Ergebnis gespeichert), optional `NEXARCH_DMS_WORKER_KOSIT_TIMEOUT_SECONDS`. +- Regelwerk-Update: Konfigurationspaket austauschen, Dienst neu starten, `..._RULESET` ändern, + Worker neu starten. Der Worker reiht beim Start die Neuprüfung aller Rechnungen ein, deren + letztes Ergebnis nicht zum neuen Regelwerk passt oder `not_checked` ist. Alte Ergebnisse bleiben. +- Der Daemon hat keine Zugriffskontrolle: nie an eine andere Adresse als Loopback binden. + +## Rückbau + +``` +systemctl disable --now nexarch-dms-kosit +rm /etc/systemd/system/nexarch-dms-kosit.service && systemctl daemon-reload +rm -rf /opt/nexarch-kosit +userdel nexarch-kosit +``` + +Bereits abgelegte Ergebnisse (`revision_einvoice_conformance`) bleiben erhalten; ohne Validator +entstehen neue Ergebnisse mit `not_checked`. diff --git a/deploy/kosit/nexarch-dms-kosit.service b/deploy/kosit/nexarch-dms-kosit.service new file mode 100644 index 00000000..f784a3aa --- /dev/null +++ b/deploy/kosit/nexarch-dms-kosit.service @@ -0,0 +1,25 @@ +[Unit] +Description=NEXARCH DMS KoSIT Validator (E-Rechnung, nur Loopback) +After=network.target + +[Service] +Type=simple +User=nexarch-kosit +Group=nexarch-kosit +WorkingDirectory=/opt/nexarch-kosit +ExecStart=/opt/nexarch-kosit/jre/bin/java -Xmx384m -jar /opt/nexarch-kosit/validator/validator-standalone.jar \ + -s /opt/nexarch-kosit/config/scenarios.xml -r /opt/nexarch-kosit/config \ + -D -G -H 127.0.0.1 -P 8610 -T 2 +Restart=on-failure +RestartSec=5 +MemoryMax=768M +TasksMax=128 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadOnlyPaths=/opt/nexarch-kosit +RestrictAddressFamilies=AF_INET AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/deploy/ports.tsv b/deploy/ports.tsv new file mode 100644 index 00000000..d0f73375 --- /dev/null +++ b/deploy/ports.tsv @@ -0,0 +1,15 @@ +# module first last (Standard-Ports der Binaries, Betriebsstufe 1: alle Module auf einem Host) +# Ein Modul darf mehrere Zeilen haben; Bereiche verschiedener Module duerfen sich nicht ueberschneiden. +# core/archive entsprechen den auf dem Referenz-Host (192.168.1.131) real belegten Ports (Stand 2026-09-18). +core 8080 8089 +core 8093 8094 +core 8098 8109 +archive 8090 8092 +archive 8095 8097 +dms 8600 8699 +mail 8700 8799 +workflow 8800 8899 +ai 8900 8999 +connect 9500 9599 +# dms-Port 8610: KoSIT-Validator (MET-07, nur 127.0.0.1), siehe deploy/kosit/README.md +# dms-Port 8611: Core-Stellvertreter des FDN-19-Smoke-Tests (cmd/smoke-tool, nur 127.0.0.1, nur Testhost) diff --git a/deploy/routes.tsv b/deploy/routes.tsv new file mode 100644 index 00000000..bad54521 --- /dev/null +++ b/deploy/routes.tsv @@ -0,0 +1,8 @@ +# module prefix (HTTP-Routenpraefix je Modul hinter dem gemeinsamen Reverse Proxy) +core /api/core/v1 +archive /api/archive/v1 +dms /api/dms/v1 +mail /api/mail/v1 +workflow /api/workflow/v1 +ai /api/ai/v1 +connect /api/connect/v1 diff --git a/dms/.golangci.yml b/dms/.golangci.yml new file mode 100644 index 00000000..0270bbce --- /dev/null +++ b/dms/.golangci.yml @@ -0,0 +1,18 @@ +version: "2" + +run: + timeout: 3m + +linters: + default: none + enable: + - govet + - staticcheck + - errcheck + - unused + - ineffassign + +formatters: + enable: + - gofmt + - goimports diff --git a/dms/Makefile b/dms/Makefile new file mode 100644 index 00000000..52b34b28 --- /dev/null +++ b/dms/Makefile @@ -0,0 +1,43 @@ +.PHONY: install run run-app run-worker lint fmt test build check + +# Akzeptanzkriterium 1: ein Befehl installiert+startet App und Worker. +install: build + +build: + go build ./... + +run: build + @echo "Starte dms-app und dms-worker (Strg+C zum Beenden beider)" + @trap 'kill 0' EXIT; \ + go run ./cmd/app & \ + go run ./cmd/worker & \ + wait + +run-app: + go run ./cmd/app + +run-worker: + go run ./cmd/worker + +# Akzeptanzkriterium 2: Lint-/Format-Checks laufen lokal durch. +lint: + golangci-lint run ./... + +fmt: + gofmt -l . + @test -z "$$(gofmt -l .)" || (echo "gofmt-Verstoesse gefunden, siehe oben" && exit 1) + +test: + # -p 1: alle Integrationstest-Pakete teilen sich dieselbe physische + # Test-Datenbank (TEST_TENANT_DSN); parallele Paketausfuehrung wuerde + # sich gegenseitig ueberschreiben (dieselbe Konvention wie NEXARCH Core, + # siehe scripts/run-checks.sh im Core-Modul). + go test ./... -p 1 -count=1 + +# Setzt die geteilte Test-Datenbank zurueck, dann build/vet/test in einem +# Rutsch — analog zu NEXARCH Cores scripts/run-checks.sh. +check: build + NEXARCH_DMS_TEST_DB_PASSWORD="$${NEXARCH_DMS_TEST_DB_PASSWORD:?Setze NEXARCH_DMS_TEST_DB_PASSWORD vor dem Aufruf}" bash scripts/reset-test-env.sh + go vet ./... + golangci-lint run ./... + go test ./... -p 1 -count=1 diff --git a/dms/README.md b/dms/README.md new file mode 100644 index 00000000..4e7d6238 --- /dev/null +++ b/dms/README.md @@ -0,0 +1,59 @@ +# NEXARCH DMS + +Dokumentenmanagement-Modul von NEXARCH. Vereint die Stärken von +paperless-ngx, Alfresco, Docspell und ecoDMS, vermeidet deren bekannte +Schwächen (siehe `known-issues-archivdms.md` im `dms-kanban/`-Ordner). + +Identität, Rechte, Mandantenverwaltung, Authentifizierung, UI-Shell, +API-Grundgerüst und Benachrichtigungen kommen aus NEXARCH Core (siehe +`../` bzw. `../../core-kanban/`) — dieses Modul implementiert nur die +DMS-eigene Logik. + +## Setup + +Voraussetzung: Go 1.22+. + +```bash +cd dms +make install # baut App und Worker +make run # startet beide (Strg+C beendet beide) +``` + +App läuft danach auf `:8600` (überschreibbar über +`NEXARCH_DMS_APP_LISTEN_ADDR`), `GET /healthz` liefert den Status. Der Worker +liefert Metriken auf `:8601` (`NEXARCH_DMS_WORKER_METRICS_ADDR`). Portbereich des +DMS laut `deploy/ports.tsv`: 8600–8699; Prüfung: `scripts/check-port-defaults.sh`. + +Fachliche Routen (z. B. `POST /retention/destroy-callback`) liegen unter dem Präfix +`/api/dms/v1` (`NEXARCH_DMS_APP_ROUTE_PREFIX`, Register `deploy/routes.tsv`, Prüfung +`scripts/check-route-prefixes.sh`); nur `/healthz` und `/metrics` bleiben auf Wurzelebene. Die bei archive +registrierte Rückruf-URL (`NEXARCH_DMS_APP_RETENTION_CALLBACK_URL`) muss den Präfix enthalten. + +## Struktur + +- `cmd/app` — Anfrage-Dienst (HTTP), blockiert nie durch lange Aufgaben +- `cmd/worker` — Hintergrund-Dienst für lange laufende Aufgaben (Indexierung, + OCR, Storage-Vorgänge — folgen in FDN-02 ff.) +- `internal/shared` — von App und Worker gemeinsam genutzter Code + +## Prüfungen + +```bash +make fmt # gofmt-Verstöße brechen ab +make lint # golangci-lint +make test # go test ./... +``` + +## Branch- und Commit-Konvention + +Gleiche Konvention wie NEXARCH Core: + +- Branch je Ticket: `feature/-`, z. B. + `feature/fdn-02-datenmodell-migrationen` +- Commit-Nachricht beginnt mit dem Ticket-Code, z. B. + `FDN-02: datenmodell & migrationen` +- Ein Ticket = ein Branch. Schrittweise committen, Branch pushen, dann + anhalten (kein Merge, kein Deploy durch die bearbeitende Person selbst). +- Deutschsprachige Oberflächentexte, englischsprachige Bezeichner im Code. +- Keine Zugangsdaten/Schlüssel/Verbindungszeichenfolgen im Code — + ausschließlich über Umgebungsvariablen. diff --git a/dms/cmd/a11y-fixture/main.go b/dms/cmd/a11y-fixture/main.go new file mode 100644 index 00000000..b8b73f60 --- /dev/null +++ b/dms/cmd/a11y-fixture/main.go @@ -0,0 +1,223 @@ +// a11y-fixture ist ein reines Pruef-/Entwicklungswerkzeug (QA-11): es startet +// die echten DMS-Handler gegen ein frisch migriertes Schema mit synthetischen +// Daten (Referenzkorpus), damit die Frontends im Browser mit realen +// Antworten geprueft werden koennen. NICHT fuer den Betrieb: statischer +// Test-KEK, offenes CORS, Schema wird beim Beenden entfernt. +// +// A11Y_DSN=... A11Y_LISTEN=127.0.0.1:8299 A11Y_IDS_FILE=/tmp/ids.json go run ./cmd/a11y-fixture +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/auditapi" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/bulkedit" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/bulkeditapi" + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/documentapi" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/folder" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/folderapi" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold/legalholdtest" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/metadataapi" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/metadatafield" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/tag" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/tagapi" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testcorpus" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/trash" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/trashapi" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/upload" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/versioning" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/versioningapi" +) + +const tenant = "a11y" + +type staticKEK struct{} + +func (staticKEK) TenantKEK(context.Context, string) ([]byte, error) { + return bytes.Repeat([]byte{0x5a}, dmscrypto.KEKSize), nil +} + +type noopUsage struct{} + +func (noopUsage) Report(context.Context, string, string, int64) error { return nil } + +func main() { + dsn := os.Getenv("A11Y_DSN") + if dsn == "" { + log.Fatal("A11Y_DSN nicht gesetzt") + } + listen := os.Getenv("A11Y_LISTEN") + if listen == "" { + listen = "127.0.0.1:8299" + } + ctx := context.Background() + + admin, err := pgxpool.New(ctx, dsn) + must(err) + schema := fmt.Sprintf("a11yfix_%d", os.Getpid()) + _, err = admin.Exec(ctx, "CREATE SCHEMA "+schema) + must(err) + defer func() { _, _ = admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") }() + + cfg, err := pgxpool.ParseConfig(dsn) + must(err) + cfg.ConnConfig.RuntimeParams["search_path"] = schema + ",public" + pool, err := pgxpool.NewWithConfig(ctx, cfg) + must(err) + defer pool.Close() + + migrations, err := migrate.Load(filepath.Join("migrations", "tenant")) + must(err) + _, err = migrate.Up(ctx, pool, migrations) + must(err) + + var userID string + must(pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'Test Prueferin') RETURNING id`, + fmt.Sprintf("a11y-%d@example.test", os.Getpid())).Scan(&userID)) + defer func() { + _, _ = admin.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) + }() + + dir, err := os.MkdirTemp("", "a11y-fixture-*") + must(err) + defer func() { _ = os.RemoveAll(dir) }() + storageSvc := storage.NewService(storage.NewLocalDriver(dir, []byte("fixture"), "http://127.0.0.1", 0), noopUsage{}, tenant) + cryptoSvc := dmscrypto.NewService(staticKEK{}) + + ids := seed(ctx, pool, storageSvc, cryptoSvc, userID) + ids["user_id"] = userID + if f := os.Getenv("A11Y_IDS_FILE"); f != "" { + b, _ := json.MarshalIndent(ids, "", " ") + must(os.WriteFile(f, b, 0o600)) + } + + mux := http.NewServeMux() + documentapi.NewServer(pool, storageSvc, cryptoSvc, tenant).RegisterRoutes(mux) + folderapi.NewServer(pool).RegisterRoutes(mux) + trashSrv := trashapi.NewServer(pool, storageSvc, 30*24*time.Hour) + trashSrv.SetLegalHoldChecker(legalholdtest.Allow{}) + trashSrv.RegisterRoutes(mux) + tagapi.NewServer(pool).RegisterRoutes(mux) + metadataapi.NewServer(pool).RegisterRoutes(mux) + versioningapi.NewServer(pool, storageSvc, cryptoSvc, tenant, &versioningapi.OwnerChecker{Pool: pool}).RegisterRoutes(mux) + auditapi.NewServer(pool, auditapi.NewAllowlistRoleChecker([]string{userID})).RegisterRoutes(mux) + bulkeditapi.NewServer(pool, &bulkedit.VersioningLockChecker{Pool: pool}).RegisterRoutes(mux) + + srv := &http.Server{Addr: listen, Handler: cors(mux), ReadHeaderTimeout: 10 * time.Second} + go func() { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal(err) + } + }() + log.Printf("a11y-fixture bereit auf %s (schema %s)", listen, schema) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + _ = srv.Shutdown(context.Background()) +} + +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} + +var mimeByExt = map[string]string{ + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".tiff": "image/tiff", +} + +func seed(ctx context.Context, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, userID string) map[string]string { + ids := map[string]string{} + up := upload.NewService(pool, upload.NewSessionStore(pool), upload.NewStaging(os.TempDir()), storageSvc, cryptoSvc, + upload.NewValidator(10<<20, []string{"text/plain", "application/pdf", "image/tiff", mimeByExt[".docx"], mimeByExt[".xlsx"]}), tenant) + + put := func(name, mime string, content []byte, folderID *string) string { + sid, err := up.StartUpload(ctx, name, mime, int64(len(content)), folderID, userID) + must(err) + _, err = up.UploadChunk(ctx, sid, 0, bytes.NewReader(content)) + must(err) + docID, _, err := up.Complete(ctx, sid, false) + must(err) + return docID + } + + inbox, err := folder.Create(ctx, pool, "Eingang", nil, userID) + must(err) + invoices, err := folder.Create(ctx, pool, "Rechnungen", nil, userID) + must(err) + y2026, err := folder.Create(ctx, pool, "2026", &invoices.ID, userID) + must(err) + ids["folder_inbox"], ids["folder_invoices"], ids["folder_2026"] = inbox.ID, invoices.ID, y2026.ID + + files, err := testcorpus.Generate() + must(err) + for i, f := range files { + name := f.Path[strings.LastIndex(f.Path, "/")+1:] + mime := mimeByExt[name[strings.LastIndex(name, "."):]] + target := &inbox.ID + if strings.HasSuffix(name, ".pdf") { + target = &y2026.ID + } + id := put(name, mime, f.Content, target) + ids[fmt.Sprintf("doc_%d", i)] = id + if strings.HasSuffix(name, ".pdf") && ids["pdf_doc"] == "" { + ids["pdf_doc"] = id + } + } + + txt := put("vertrag.txt", "text/plain", []byte("Vertrag\nZeile eins\nZeile zwei\n"), &inbox.ID) + _, err = versioning.CheckOut(ctx, pool, txt, userID, time.Hour) + must(err) + _, err = versioning.CheckIn(ctx, pool, storageSvc, cryptoSvc, tenant, txt, userID, "Zeile drei ergaenzt", "text/plain", strings.NewReader("Vertrag\nZeile eins\nZeile zwei geaendert\nZeile drei\n")) + must(err) + ids["text_doc"] = txt + + rechnung, err := tag.Create(ctx, pool, "rechnung", "#1d4ed8", nil) + must(err) + vertrag, err := tag.Create(ctx, pool, "vertrag", "#047857", nil) + must(err) + must(tag.Assign(ctx, pool, ids["pdf_doc"], rechnung.ID)) + must(tag.Assign(ctx, pool, txt, vertrag.ID)) + _, err = metadatafield.Create(ctx, pool, "betrag", "Betrag", "number", false, nil, nil) + must(err) + _, err = metadatafield.Create(ctx, pool, "faellig", "Faelligkeit", "date", false, nil, nil) + must(err) + + trashed := put("alt.txt", "text/plain", []byte("wird geloescht"), &inbox.ID) + must(trash.SoftDeleteDocument(ctx, pool, nil, trashed, userID)) + ids["trashed_doc"] = trashed + return ids +} diff --git a/dms/cmd/app/api.go b/dms/cmd/app/api.go new file mode 100644 index 00000000..187e5a18 --- /dev/null +++ b/dms/cmd/app/api.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/apiwiring" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/cmis" + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/policyclient" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentiondestroy" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchsync" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/shared" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/upload" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/workerruntime" +) + +type uploadStack struct { + svc *upload.Service + storage *storage.Service + crypto *dmscrypto.Service +} + +func newUploadStack(pool *pgxpool.Pool, stagingDir string) uploadStack { + storageDir := os.Getenv("NEXARCH_DMS_APP_STORAGE_DIR") + signingSecret := os.Getenv("NEXARCH_DMS_APP_STORAGE_SIGNING_SECRET") + publicBaseURL := os.Getenv("NEXARCH_DMS_APP_STORAGE_PUBLIC_BASE_URL") + driver := storage.NewLocalDriver(storageDir, []byte(signingSecret), publicBaseURL, signedURLMaxTTL(os.Getenv("NEXARCH_DMS_APP_SIGNED_URL_MAX_TTL"))) + usageReporter := storage.NewHTTPUsageReporter( + os.Getenv("NEXARCH_DMS_APP_USAGE_ENDPOINT_URL"), + os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_ID"), + os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_SECRET"), + nil, + ) + tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG") + storageSvc := storage.NewService(driver, usageReporter, tenantSlug) + + kekProvider := dmscrypto.NewHTTPKEKProvider( + os.Getenv("NEXARCH_DMS_APP_KEK_ENDPOINT_URL"), + os.Getenv("NEXARCH_DMS_APP_KEK_CLIENT_ID"), + os.Getenv("NEXARCH_DMS_APP_KEK_CLIENT_SECRET"), + nil, + ) + cryptoSvc := dmscrypto.NewService(kekProvider) + + maxSize := int64(0) + if raw := os.Getenv("NEXARCH_DMS_APP_UPLOAD_MAX_SIZE_BYTES"); raw != "" { + if v, err := strconv.ParseInt(raw, 10, 64); err == nil { + maxSize = v + } + } + var allowedMimeTypes []string + if raw := os.Getenv("NEXARCH_DMS_APP_UPLOAD_ALLOWED_MIME_TYPES"); raw != "" { + allowedMimeTypes = strings.Split(raw, ",") + } + validator := upload.NewValidator(maxSize, allowedMimeTypes) + + sessions := upload.NewSessionStore(pool) + staging := upload.NewStaging(stagingDir) + svc := upload.NewService(pool, sessions, staging, storageSvc, cryptoSvc, validator, tenantSlug) + enqueueTypes := []string{"thumbnail.generate", "ocr.plan", "einvoice.read"} + if os.Getenv("NEXARCH_DMS_APP_MANTICORE_ADDR") != "" { + enqueueTypes = append(enqueueTypes, searchsync.JobType) + } + if raw := os.Getenv("NEXARCH_DMS_APP_ENQUEUE_JOB_TYPES"); raw != "" { + enqueueTypes = strings.Split(raw, ",") + } + workerruntime.AttachEnqueuers(svc, pool, jobqueue.NewQueue(pool, 5*time.Minute), enqueueTypes) + hasScan := false + for _, jt := range enqueueTypes { + hasScan = hasScan || jt == "malwarescan.scan" + } + if !hasScan { + log.Print("dms-app: Virenscan wird beim Import nicht eingereiht (NEXARCH_DMS_APP_ENQUEUE_JOB_TYPES ohne malwarescan.scan): importierte Dokumente werden ungescannt angezeigt") + } + return uploadStack{svc: svc, storage: storageSvc, crypto: cryptoSvc} +} + +func envSeconds(name string, def time.Duration) time.Duration { + if v, err := strconv.Atoi(os.Getenv(name)); err == nil && v > 0 { + return time.Duration(v) * time.Second + } + return def +} + +func splitIDs(raw string) []string { + var out []string + for _, id := range strings.Split(raw, ",") { + if id = strings.TrimSpace(id); id != "" { + out = append(out, id) + } + } + return out +} + +// setupAPI haengt alle dokument-eigenen Endpunkte ein (INT-01). +func setupAPI(api *http.ServeMux, dsn, prefix string) error { + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return fmt.Errorf("datenbankverbindung: %w", err) + } + stagingDir := os.Getenv("NEXARCH_DMS_APP_STAGING_DIR") + if stagingDir == "" { + stagingDir = os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_STAGING_DIR") + } + if stagingDir == "" { + return fmt.Errorf("NEXARCH_DMS_APP_STAGING_DIR nicht gesetzt") + } + stack := newUploadStack(pool, stagingDir) + return apiwiring.Mount(api, apiwiring.Deps{ + Pool: pool, + Storage: stack.storage, + Crypto: stack.crypto, + TenantSlug: os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), + Upload: stack.svc, + DownloadSecret: []byte(os.Getenv("NEXARCH_DMS_APP_DOWNLOAD_SIGNING_SECRET")), + DownloadTTL: envSeconds("NEXARCH_DMS_APP_DOWNLOAD_URL_TTL_SECONDS", 5*time.Minute), + TrashRetention: envSeconds("NEXARCH_DMS_APP_TRASH_RETENTION_SECONDS", 30*24*time.Hour), + Auditors: splitIDs(os.Getenv("NEXARCH_DMS_APP_AUDITOR_USER_IDS")), + SearchEnqueuer: searchEnqueuerFor(pool), + LegalHolds: legalHoldCheckerFor(os.Getenv("NEXARCH_DMS_APP_RETENTION_BASE_URL"), os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG")), + PathPrefix: prefix, + }) +} + +func legalHoldCheckerFor(baseURL, tenant string) legalhold.Checker { + if baseURL == "" { + log.Print("dms-app: NEXARCH_DMS_APP_RETENTION_BASE_URL nicht gesetzt, endgueltiges Loeschen wird verweigert (fail closed, INT-11)") + return nil + } + return &legalhold.ArchiveChecker{Client: retentionclient.New(baseURL, tenant)} +} + +// setupCMIS haengt die optionale CMIS-Schnittstelle ein (NEXARCH_DMS_APP_CMIS_ENABLED=true); +// der Backend-Handler wird erst gesetzt, wenn die Identitaetskette steht. +func setupCMIS(api *http.ServeMux, prefix string) *cmis.LateHandler { + if os.Getenv("NEXARCH_DMS_APP_CMIS_ENABLED") != "true" { + log.Print("dms-app: CMIS-Schnittstelle deaktiviert (NEXARCH_DMS_APP_CMIS_ENABLED != true)") + return nil + } + repoID := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG") + if repoID == "" { + log.Print("dms-app: CMIS uebersprungen (NEXARCH_DMS_APP_TENANT_SLUG nicht gesetzt)") + return nil + } + maxBytes, _ := strconv.ParseInt(os.Getenv("NEXARCH_DMS_APP_CMIS_MAX_CONTENT_BYTES"), 10, 64) + late := &cmis.LateHandler{} + if err := cmis.Mount(api, cmis.Config{ + RepositoryID: repoID, Version: shared.Version, RESTPrefix: prefix, Backend: late, + PublicBaseURL: os.Getenv("NEXARCH_DMS_APP_CMIS_PUBLIC_BASE_URL"), MaxContentBytes: maxBytes, + }); err != nil { + log.Fatalf("dms-app: cmis: %v", err) + } + log.Printf("dms-app: CMIS-Browser-Binding unter %s%s/browser eingebunden", prefix, "/cmis") + return late +} + +func searchEnqueuerFor(pool *pgxpool.Pool) *searchsync.Enqueuer { + if os.Getenv("NEXARCH_DMS_APP_MANTICORE_ADDR") == "" { + return nil + } + return &searchsync.Enqueuer{Queue: jobqueue.NewQueue(pool, 5*time.Minute)} +} + +// identityChain baut aus der Umgebung die Authentifizierung und optional die +// Rechteentscheidung; ohne Identitaetsquelle startet der Dienst nicht. +func identityChain(next http.Handler, prefix string) (http.Handler, error) { + jwksURL := os.Getenv("NEXARCH_DMS_APP_CORE_JWKS_URL") + allowCaller := os.Getenv("NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY") == "true" + if jwksURL == "" && !allowCaller { + return nil, fmt.Errorf("keine identitaetsquelle: NEXARCH_DMS_APP_CORE_JWKS_URL setzen (oder uebergangsweise NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true)") + } + cfg := apiwiring.ChainConfig{Prefix: prefix, AllowCaller: allowCaller} + if policyURL := os.Getenv("NEXARCH_DMS_APP_POLICY_URL"); policyURL != "" { + token := os.Getenv("NEXARCH_DMS_APP_POLICY_SERVICE_TOKEN") + if token == "" { + return nil, fmt.Errorf("NEXARCH_DMS_APP_POLICY_URL ohne NEXARCH_DMS_APP_POLICY_SERVICE_TOKEN") + } + role := os.Getenv("NEXARCH_DMS_APP_POLICY_DEFAULT_ROLE") + if role == "" { + role = "user" + } + admins := map[string]bool{} + for _, id := range splitIDs(os.Getenv("NEXARCH_DMS_APP_ADMIN_USER_IDS")) { + admins[id] = true + } + cfg.Policy = &policyclient.EnforceConfig{ + Client: policyclient.New(policyURL, token, envSeconds("NEXARCH_DMS_APP_POLICY_TTL_SECONDS", 30*time.Second)), + Prefix: prefix, DefaultRole: role, AdminRole: "tenant_admin", Admins: admins, + } + } else { + log.Print("dms-app: NEXARCH_DMS_APP_POLICY_URL nicht gesetzt, keine zentrale Rechteentscheidung (nur Eigentuemer-/Admin-Regeln des Guards)") + } + if jwksURL != "" { + cfg.Verifier = moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute), + os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG"), moduleauth.HTTPKeyFetcher(jwksURL, nil)) + } + if allowCaller { + log.Print("dms-app: WARNUNG: caller-Parameter als Identitaet zugelassen (NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true), nicht authentifiziert") + } + return apiwiring.Chain(next, cfg), nil +} + +func callbackOptions(tenantSlug string) retentiondestroy.CallbackOptions { + opts := retentiondestroy.CallbackOptions{ + InstanceTenant: tenantSlug, + RequireServiceToken: os.Getenv("NEXARCH_DMS_APP_CALLBACK_REQUIRE_SERVICE_TOKEN") == "true", + } + if jwksURL := os.Getenv("NEXARCH_DMS_APP_CORE_JWKS_URL"); jwksURL != "" { + opts.Verifier = moduleauth.NewVerifier(envSeconds("NEXARCH_DMS_APP_JWKS_TTL_SECONDS", 5*time.Minute), tenantSlug, moduleauth.HTTPKeyFetcher(jwksURL, nil)) + } + return opts +} diff --git a/dms/cmd/app/cmis_test.go b/dms/cmd/app/cmis_test.go new file mode 100644 index 00000000..f0f84136 --- /dev/null +++ b/dms/cmd/app/cmis_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/authz" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/metrics" +) + +func cmisStatus(root http.Handler) int { + rec := httptest.NewRecorder() + root.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/dms/v1/cmis/browser", nil)) + return rec.Code +} + +func TestSetupCMISIsOptIn(t *testing.T) { + const prefix = "/api/dms/v1" + t.Setenv("NEXARCH_DMS_APP_TENANT_SLUG", "acme") + + t.Setenv("NEXARCH_DMS_APP_CMIS_ENABLED", "") + root, api := newMux(prefix, metrics.NewRegistry()) + if setupCMIS(api, prefix) != nil || cmisStatus(root) != http.StatusNotFound { + t.Error("ohne Schalter darf keine CMIS-Route existieren") + } + + t.Setenv("NEXARCH_DMS_APP_CMIS_ENABLED", "false") + root, api = newMux(prefix, metrics.NewRegistry()) + if setupCMIS(api, prefix) != nil || cmisStatus(root) != http.StatusNotFound { + t.Error("mit Schalter false darf keine CMIS-Route existieren") + } + + t.Setenv("NEXARCH_DMS_APP_CMIS_ENABLED", "true") + t.Setenv("NEXARCH_DMS_APP_TENANT_SLUG", "") + root, api = newMux(prefix, metrics.NewRegistry()) + if setupCMIS(api, prefix) != nil || cmisStatus(root) != http.StatusNotFound { + t.Error("ohne Mandantenkennung darf CMIS nicht starten") + } + + t.Setenv("NEXARCH_DMS_APP_TENANT_SLUG", "acme") + root, api = newMux(prefix, metrics.NewRegistry()) + late := setupCMIS(api, prefix) + if late == nil { + t.Fatal("CMIS haette starten muessen") + } + guarded := authz.Guard(root, nil, prefix) + if got := cmisStatus(guarded); got != http.StatusUnauthorized { + t.Errorf("CMIS ohne Identitaet hinter dem gemeinsamen Guard: %d, erwartet 401", got) + } + withIdentity := httptest.NewRequest(http.MethodGet, prefix+"/cmis/browser", nil) + withIdentity.Header.Set("X-User-Id", "u1") + if rec := httptest.NewRecorder(); true { + guarded.ServeHTTP(rec, withIdentity) + if rec.Code != http.StatusOK { + t.Errorf("CMIS mit Identitaet hinter dem gemeinsamen Guard: %d, erwartet 200", rec.Code) + } + } + if rec := httptest.NewRecorder(); true { + root.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Errorf("healthz: %d", rec.Code) + } + } +} diff --git a/dms/cmd/app/legalhold_test.go b/dms/cmd/app/legalhold_test.go new file mode 100644 index 00000000..4276a2be --- /dev/null +++ b/dms/cmd/app/legalhold_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold" +) + +func TestLegalHoldCheckerFor_MissingBaseURLRefusesPurge(t *testing.T) { + c := legalHoldCheckerFor("", "acme") + if err := legalhold.Verify(context.Background(), c, "doc"); !errors.Is(err, legalhold.ErrUnverifiable) { + t.Fatalf("ohne archive-anbindung muss geloescht werden verweigert werden: %v", err) + } +} + +func TestLegalHoldCheckerFor_ConfiguredUsesArchiveAndTenant(t *testing.T) { + var tenant string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tenant = r.Header.Get("X-Nexarch-Tenant") + _, _ = w.Write([]byte(`{"on_hold":true,"reason":"test"}`)) + })) + defer srv.Close() + c := legalHoldCheckerFor(srv.URL, "acme") + if err := legalhold.Verify(context.Background(), c, "doc"); !errors.Is(err, legalhold.ErrOnHold) { + t.Fatalf("gesperrtes dokument muss abgelehnt werden: %v", err) + } + if tenant != "acme" { + t.Fatalf("tenant-kontext fehlt in der sperrabfrage: %q", tenant) + } +} diff --git a/dms/cmd/app/main.go b/dms/cmd/app/main.go new file mode 100644 index 00000000..99471ad6 --- /dev/null +++ b/dms/cmd/app/main.go @@ -0,0 +1,335 @@ +// app ist der Anfrage-Dienst (Request-Path) des DMS — getrennt vom Worker, +// damit lange Hintergrundaufgaben nie eine HTTP-Anfrage blockieren +// (Akzeptanzkriterium/Produkt-DNA: paperless-ngx-Trennung Dienst/Worker). +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/authz" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/batchsplit" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/cmis" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/errortracking" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/importdir" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/logging" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/metadatafield" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/metrics" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentiondestroy" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/routeprefix" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchwiring" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/shared" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/tenantregistryclient" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/watchfolder" +) + +func main() { + addr := os.Getenv("NEXARCH_DMS_APP_LISTEN_ADDR") + if addr == "" { + addr = ":8600" + } + + logger := logging.NewDefault() + metricsReg := metrics.NewRegistry() + tracker := errortracking.New(logger, 5, time.Minute) + + prefix, err := routeprefix.FromEnv() + if err != nil { + log.Fatalf("dms-app: %v", err) + } + mux, api := newMux(prefix, metricsReg) + + // DOC-16: RET-05-Client (Registrierung + Vernichtungs-Rückruf-Empfang). + // Optional, um cmd/app auch ohne diese Umgebungsvariablen weiter + // startbar zu halten (z. B. isolierte Tests anderer Endpunkte). + if dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN"); dsn != "" { + setupRetention(api, dsn) + } else { + log.Print("dms-app: NEXARCH_DMS_APP_TENANT_DSN nicht gesetzt, RET-05-Anbindung (DOC-16) deaktiviert") + } + + if dsn, addr := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN"), os.Getenv("NEXARCH_DMS_APP_MANTICORE_ADDR"); dsn != "" && addr != "" { + if err := searchindex.ValidateTableName(searchIndexName(os.Getenv("NEXARCH_DMS_APP_SEARCH_INDEX_NAME"))); err != nil { + log.Fatalf("dms-app: NEXARCH_DMS_APP_SEARCH_INDEX_NAME: %v", err) + } + if err := setupSearch(api, dsn, addr); err != nil { + log.Printf("dms-app: Suche deaktiviert: %v", err) + } + } else { + log.Print("dms-app: NEXARCH_DMS_APP_TENANT_DSN/NEXARCH_DMS_APP_MANTICORE_ADDR nicht gesetzt, Such-Routen (/search, /saved-searches, /ocr) deaktiviert") + } + + var cmisBackend *cmis.LateHandler + if dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN"); dsn != "" { + if err := setupAPI(api, dsn, prefix); err != nil { + log.Fatalf("dms-app: api-verdrahtung: %v", err) + } + cmisBackend = setupCMIS(api, prefix) + } + + watchCtx, stopWatch := context.WithCancel(context.Background()) + defer stopWatch() + if dir := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_DIR"); dir != "" { + setupWatchFolder(watchCtx, dir) + } else { + log.Print("dms-app: NEXARCH_DMS_APP_WATCHFOLDER_DIR nicht gesetzt, INT-08-Consume-Ordner deaktiviert") + } + + var routes http.Handler = mux + if dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN"); dsn != "" { + guarded, err := guardMux(mux, dsn, prefix, os.Getenv("NEXARCH_DMS_APP_ADMIN_USER_IDS")) + if err != nil { + log.Fatalf("dms-app: berechtigungs-guard: %v", err) + } + routes, err = identityChain(guarded, prefix) + if err != nil { + log.Fatalf("dms-app: identitaet: %v", err) + } + if cmisBackend != nil { + cmisBackend.Set(routes) + } + } + handler := logging.Middleware(logger)(metricsReg.Middleware(recoverMiddleware(tracker, routes))) + + log.Printf("dms-app hoert auf %s (version %s)", addr, shared.Version) + if err := http.ListenAndServe(addr, handler); err != nil { + log.Fatal(err) + } +} + +// newMux liefert den Wurzel-Mux (/healthz, /metrics) und den Untermux +// unter prefix, in den alle fachlichen Routen gehoeren. +func newMux(prefix string, metricsReg *metrics.Registry) (root, api *http.ServeMux) { + root = http.NewServeMux() + root.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","service":"dms-app","version":"` + shared.Version + `"}`)) + }) + root.Handle("/metrics", metricsReg.Handler()) + return root, routeprefix.Mount(root, prefix) +} + +func guardMux(next http.Handler, dsn, prefix, adminIDs string) (http.Handler, error) { + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return nil, fmt.Errorf("datenbankverbindung: %w", err) + } + admins := map[string]bool{} + for _, id := range strings.Split(adminIDs, ",") { + if id = strings.TrimSpace(id); id != "" { + admins[id] = true + } + } + return authz.Guard(next, &authz.PoolChecker{Pool: pool, Admins: admins}, prefix), nil +} + +// recoverMiddleware faengt Panics in Handlern ab und meldet sie zentral +// (Akzeptanzkriterium 3), statt den Prozess abstuerzen zu lassen. +func recoverMiddleware(tracker *errortracking.Tracker, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + err, ok := rec.(error) + if !ok { + err = fmt.Errorf("%v", rec) + } + tracker.Capture(r.Context(), "http", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} + +func searchIndexName(configured string) string { + if configured == "" { + return searchindex.DefaultTableName + } + return configured +} + +func setupSearch(api *http.ServeMux, dsn, manticoreAddr string) error { + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return fmt.Errorf("datenbankverbindung: %w", err) + } + idx, err := searchindex.OpenNamed(manticoreAddr, searchIndexName(os.Getenv("NEXARCH_DMS_APP_SEARCH_INDEX_NAME"))) + if err == nil { + err = searchindex.EnsureSchema(ctx, idx) + } + if err != nil { + pool.Close() + return fmt.Errorf("suchindex: %w", err) + } + searchwiring.Mount(api, pool, idx) + log.Print("dms-app: Such-Routen (/search, /saved-searches, /ocr) eingebunden") + return nil +} + +func setupRetention(mux *http.ServeMux, dsn string) { + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("dms-app: datenbankverbindung: %v", err) + } + + storageDir := os.Getenv("NEXARCH_DMS_APP_STORAGE_DIR") + signingSecret := os.Getenv("NEXARCH_DMS_APP_STORAGE_SIGNING_SECRET") + publicBaseURL := os.Getenv("NEXARCH_DMS_APP_STORAGE_PUBLIC_BASE_URL") + driver := storage.NewLocalDriver(storageDir, []byte(signingSecret), publicBaseURL, signedURLMaxTTL(os.Getenv("NEXARCH_DMS_APP_SIGNED_URL_MAX_TTL"))) + + usageEndpoint := os.Getenv("NEXARCH_DMS_APP_USAGE_ENDPOINT_URL") + usageClientID := os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_ID") + usageClientSecret := os.Getenv("NEXARCH_DMS_APP_USAGE_CLIENT_SECRET") + tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG") + usageReporter := storage.NewHTTPUsageReporter(usageEndpoint, usageClientID, usageClientSecret, nil) + storageSvc := storage.NewService(driver, usageReporter, tenantSlug) + + mux.HandleFunc("POST /retention/destroy-callback", retentiondestroy.DestroyCallbackHandler(pool, storageSvc, callbackOptions(tenantSlug))) + + retentionBaseURL := os.Getenv("NEXARCH_DMS_APP_RETENTION_BASE_URL") + retentionClass := os.Getenv("NEXARCH_DMS_APP_RETENTION_CLASS") + callbackURL := os.Getenv("NEXARCH_DMS_APP_RETENTION_CALLBACK_URL") + if retentionBaseURL == "" || retentionClass == "" || callbackURL == "" { + log.Print("dms-app: RET-05-Registrierung uebersprungen (RETENTION_BASE_URL/CLASS/CALLBACK_URL unvollstaendig)") + return + } + + client := retentionclient.New(retentionBaseURL, tenantSlug) + queue := jobqueue.NewQueue(pool, 5*time.Minute) + cfg := retentiondestroy.RegisterConfig{ + ObjectType: retentiondestroy.ObjectTypeDocument, + RetentionClass: retentionClass, + CallbackURL: callbackURL, + } + + regCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + queued, err := retentiondestroy.RegisterOrRequeue(regCtx, client, queue, cfg) + if err != nil { + log.Printf("dms-app: RET-05-registrierung fehlgeschlagen (requeued=%t): %v", queued, err) + } else { + log.Print("dms-app: bei archive RET-05 registriert") + } +} + +// setupWatchFolder startet INT-08s Consume-Ordner-Beobachtung im +// Hintergrund und mündet in dieselbe upload.Service-Pipeline wie DOC-01 +// (Hash/Verschlüsselung/Dokument+Revision) — es wird kein eigener Pfad +// dupliziert. Läuft nur, wenn Tenant-DSN, Staging-Verzeichnis und ein +// bereits angelegter System-Benutzer (created_by) konfiguriert sind. +func setupWatchFolder(ctx context.Context, dir string) { + dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN") + systemUserID := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_SYSTEM_USER_ID") + stagingDir := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_STAGING_DIR") + if dsn == "" || systemUserID == "" || stagingDir == "" { + log.Print("dms-app: INT-08-Consume-Ordner uebersprungen (TENANT_DSN/WATCHFOLDER_SYSTEM_USER_ID/WATCHFOLDER_STAGING_DIR unvollstaendig)") + return + } + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("dms-app: watchfolder-datenbankverbindung: %v", err) + } + + tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG") + svc := newUploadStack(pool, stagingDir).svc + + pollInterval := 5 * time.Second + if raw := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_POLL_INTERVAL_SECONDS"); raw != "" { + if v, err := strconv.Atoi(raw); err == nil && v > 0 { + pollInterval = time.Duration(v) * time.Second + } + } + + registryFile := os.Getenv("NEXARCH_DMS_APP_IMPORT_REGISTRY_FILE") + if registryFile == "" { + log.Print("dms-app: NEXARCH_DMS_APP_IMPORT_REGISTRY_FILE nicht gesetzt, Consume-Ordner bleibt deaktiviert (INT-14: Import nur mit Tenant-Zuordnung)") + return + } + guard := &importdir.Guard{ + Registry: importdir.FileRegistry{Path: registryFile}, + InstanceTenant: tenantSlug, + Policy: importdir.Policy{Owner: os.Getenv("NEXARCH_DMS_APP_IMPORT_OWNER"), Group: os.Getenv("NEXARCH_DMS_APP_IMPORT_GROUP")}, + } + if coreURL := os.Getenv("NEXARCH_DMS_APP_CORE_BASE_URL"); coreURL != "" { + guard.Verifier = tenantregistryclient.New(coreURL, os.Getenv("NEXARCH_DMS_APP_CORE_SERVICE_TOKEN")) + } else { + log.Print("dms-app: NEXARCH_DMS_APP_CORE_BASE_URL nicht gesetzt, Tenant wird nicht gegen die Core-Registry bestaetigt") + } + + var splitter watchfolder.Splitter + var onPartValue func(ctx context.Context, documentID, value string) error + if os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_SPLIT_BATCHES") == "true" { + classifier := batchsplit.Chain{batchsplit.DetectorClassifier{Detector: batchsplit.MarkerDetector{Marker: os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_SEPARATOR_MARKER")}}} + if prefix := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_BARCODE_PREFIX"); prefix != "" { + classifier = append(classifier, batchsplit.BarcodeClassifier{Prefix: prefix}) + } + if os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_BLANK_PAGES") == "true" { + classifier = append(classifier, batchsplit.BlankClassifier{}) + } + renderer := batchsplit.PopplerRenderer{Binary: os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_PDFTOPPM")} + splitter = func(content []byte) ([]batchsplit.Part, error) { + return batchsplit.SplitParts(content, classifier, renderer) + } + if key := os.Getenv("NEXARCH_DMS_APP_WATCHFOLDER_BARCODE_FIELD_KEY"); key != "" { + fields, err := metadatafield.ListAll(ctx, pool) + fieldID := "" + for _, f := range fields { + if f.Key == key { + fieldID = f.ID + } + } + if err != nil || fieldID == "" { + log.Printf("dms-app: metadatenfeld %q fuer barcode-werte nicht gefunden, werte werden nicht uebernommen (err=%v)", key, err) + } else { + onPartValue = func(ctx context.Context, documentID, value string) error { + return metadatafield.SetValue(ctx, pool, documentID, fieldID, value) + } + } + } + } + + watcher := watchfolder.New(svc, watchfolder.Config{ + Splitter: splitter, + OnPartValue: onPartValue, + Deliveries: batchsplit.NewStore(pool), + Dir: dir, + PollInterval: pollInterval, + CreatedBy: systemUserID, + Guard: guard, + OnReject: func(ctx context.Context, dir string, reason error) { + if _, err := audittrail.Append(ctx, pool, "system", "watchfolder_import_rejected", "import_directory", dir, map[string]any{"reason": reason.Error()}); err != nil { + log.Printf("dms-app: ablehnung des imports aus %q nicht protokolliert: %v", dir, err) + } + }, + }) + go func() { + log.Printf("dms-app: INT-08-Consume-Ordner beobachtet %q", dir) + if err := watcher.Run(ctx); err != nil { + log.Printf("dms-app: watchfolder beendet: %v", err) + } + }() +} + +func signedURLMaxTTL(raw string) time.Duration { + ttl, err := storage.ParseMaxSignedURLTTL(raw) + if err != nil { + log.Fatalf("dms-app: %v", err) + } + return ttl +} diff --git a/dms/cmd/app/main_test.go b/dms/cmd/app/main_test.go new file mode 100644 index 00000000..a592d635 --- /dev/null +++ b/dms/cmd/app/main_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/metrics" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex" +) + +// TestHealthz ist der Nachweis, dass der App-Dienst tatsaechlich startet und +// antwortet (Akzeptanzkriterium 1: "mit einem Befehl installieren und +// starten") — geprueft ueber den Handler direkt statt einen echten Port zu +// binden, damit der Test parallel und ohne Portkonflikte laufen kann. +func TestHealthz(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok","service":"dms-app","version":"test"}`)) + }) + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if !strings.Contains(rec.Body.String(), `"status":"ok"`) { + t.Fatalf("unerwarteter body: %s", rec.Body.String()) + } +} + +func TestNewMux_BusinessRoutesOnlyUnderPrefix(t *testing.T) { + root, api := newMux("/api/dms/v1", metrics.NewRegistry()) + api.HandleFunc("POST /retention/destroy-callback", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) + + do := func(method, path string) int { + rec := httptest.NewRecorder() + root.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec.Code + } + if got := do(http.MethodPost, "/api/dms/v1/retention/destroy-callback"); got != http.StatusNoContent { + t.Errorf("mit praefix: %d, want 204", got) + } + if got := do(http.MethodPost, "/retention/destroy-callback"); got != http.StatusNotFound { + t.Errorf("ohne praefix: %d, want 404", got) + } + for _, p := range []string{"/healthz", "/metrics"} { + if got := do(http.MethodGet, p); got != http.StatusOK { + t.Errorf("%s: %d, want 200", p, got) + } + } +} + +func TestSetupSearch_MountsRoutesUnderPrefix(t *testing.T) { + dsn, addr := os.Getenv("TEST_TENANT_DSN"), os.Getenv("TEST_MANTICORE_ADDR") + if dsn == "" || addr == "" { + t.Skip("TEST_TENANT_DSN/TEST_MANTICORE_ADDR nicht gesetzt") + } + root, api := newMux("/api/dms/v1", metrics.NewRegistry()) + if err := setupSearch(api, dsn, addr); err != nil { + t.Fatal(err) + } + for _, path := range []string{"/search?q=x", "/saved-searches", "/ocr/settings"} { + rec := httptest.NewRecorder() + root.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/dms/v1"+path, nil)) + if rec.Code == http.StatusNotFound { + t.Errorf("%s unter dem praefix nicht eingebunden", path) + } + rec = httptest.NewRecorder() + root.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("%s ohne praefix erreichbar (%d)", path, rec.Code) + } + } +} + +func TestSetupSearch_UnreachableIndexReturnsError(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt") + } + _, api := newMux("/api/dms/v1", metrics.NewRegistry()) + if err := setupSearch(api, dsn, "127.0.0.1:1"); err == nil { + t.Fatal("erreichbarkeitsfehler des suchindex nicht gemeldet") + } +} + +func TestGuardMux_RejectsUnauthorizedRequestsOnWiredRoutes(t *testing.T) { + dsn, addr := os.Getenv("TEST_TENANT_DSN"), os.Getenv("TEST_MANTICORE_ADDR") + if dsn == "" || addr == "" { + t.Skip("TEST_TENANT_DSN/TEST_MANTICORE_ADDR nicht gesetzt") + } + const prefix = "/api/dms/v1" + root, api := newMux(prefix, metrics.NewRegistry()) + if err := setupSearch(api, dsn, addr); err != nil { + t.Fatal(err) + } + handler, err := guardMux(root, dsn, prefix, "admin-1") + if err != nil { + t.Fatal(err) + } + do := func(method, path, header string) int { + req := httptest.NewRequest(method, path, nil) + if header != "" { + req.Header.Set("X-User-Id", header) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code + } + + for _, path := range []string{"/search?q=x", "/saved-searches", "/ocr/review", "/ocr/settings"} { + if code := do(http.MethodGet, prefix+path, ""); code != http.StatusUnauthorized { + t.Errorf("%s ohne identitaet: %d, erwartet 401", path, code) + } + } + if code := do(http.MethodGet, prefix+"/search?q=x&caller=a", "b"); code != http.StatusUnauthorized { + t.Errorf("widerspruechliche identitaet: %d, erwartet 401", code) + } + if code := do(http.MethodGet, prefix+"/ocr/review?caller=user-1", ""); code != http.StatusForbidden { + t.Errorf("ocr/review als nicht-admin: %d, erwartet 403", code) + } + if code := do(http.MethodPut, prefix+"/ocr/settings?caller=user-1", ""); code != http.StatusForbidden { + t.Errorf("ocr/settings schreiben als nicht-admin: %d, erwartet 403", code) + } + if code := do(http.MethodGet, prefix+"/ocr/review?caller=admin-1", ""); code == http.StatusUnauthorized || code == http.StatusForbidden { + t.Errorf("ocr/review als admin abgewiesen: %d", code) + } + for _, path := range []string{"/healthz", "/metrics"} { + if code := do(http.MethodGet, path, ""); code != http.StatusOK { + t.Errorf("%s ohne identitaet: %d, erwartet 200", path, code) + } + } +} + +func TestSearchIndexName_DefaultsAndValidation(t *testing.T) { + if got := searchIndexName(""); got != searchindex.DefaultTableName { + t.Fatalf("standardname: %q", got) + } + if got := searchIndexName("dms_acme"); got != "dms_acme" { + t.Fatalf("konfigurierter name: %q", got) + } + if err := searchindex.ValidateTableName(searchIndexName("dms acme")); err == nil { + t.Fatal("ungueltiger name nicht abgelehnt") + } +} + +func TestSetupSearch_InvalidIndexNameIsRejected(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt") + } + t.Setenv("NEXARCH_DMS_APP_SEARCH_INDEX_NAME", "x; DROP TABLE y") + _, api := newMux("/api/dms/v1", metrics.NewRegistry()) + if err := setupSearch(api, dsn, "127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "indexname") { + t.Fatalf("erwartet fehler zum indexnamen, habe %v", err) + } +} diff --git a/dms/cmd/gen-test-corpus/main.go b/dms/cmd/gen-test-corpus/main.go new file mode 100644 index 00000000..05dbeb3c --- /dev/null +++ b/dms/cmd/gen-test-corpus/main.go @@ -0,0 +1,33 @@ +// gen-test-corpus erzeugt den QA-01-Referenzkorpus reproduzierbar unter +// dms/testdata/corpus. Aufruf: go run ./cmd/gen-test-corpus +package main + +import ( + "fmt" + "os" + "path/filepath" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testcorpus" +) + +func main() { + files, err := testcorpus.Generate() + if err != nil { + fmt.Fprintln(os.Stderr, "Korpus-Erzeugung fehlgeschlagen:", err) + os.Exit(1) + } + + root := "testdata/corpus" + for _, f := range files { + dst := filepath.Join(root, f.Path) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + fmt.Fprintln(os.Stderr, "Verzeichnis anlegen fehlgeschlagen:", err) + os.Exit(1) + } + if err := os.WriteFile(dst, f.Content, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "Datei schreiben fehlgeschlagen:", err) + os.Exit(1) + } + fmt.Println("erzeugt:", dst) + } +} diff --git a/dms/cmd/importdir-check/main.go b/dms/cmd/importdir-check/main.go new file mode 100644 index 00000000..6e3f3ad0 --- /dev/null +++ b/dms/cmd/importdir-check/main.go @@ -0,0 +1,39 @@ +// importdir-check prueft ein Import-Verzeichnis gegen Zuordnung und +// Betriebsvorgabe (INT-14), z. B. nach der Einrichtung oder im Betrieb. +// +// importdir-check -dir /srv/nexarch/import/acme -tenant acme -registry /etc/nexarch/import-registry.json [-owner nexarch-dms] [-group nexarch-import-acme] +// +// Exit 0 = in Ordnung, 1 = Verstoss (Grund auf stderr), 2 = falsche Aufrufparameter. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/importdir" +) + +func main() { + dir := flag.String("dir", "", "Import-Verzeichnis") + tenant := flag.String("tenant", "", "Tenant dieser DMS-Instanz") + registry := flag.String("registry", "", "Registry-Datei (JSON)") + owner := flag.String("owner", "", "erwarteter Eigentuemer (DMS-Dienstbenutzer)") + group := flag.String("group", "", "einzige schreibberechtigte Gruppe (Scanner-/Sync-Dienst)") + flag.Parse() + if *dir == "" || *tenant == "" || *registry == "" { + fmt.Fprintln(os.Stderr, "verwendung: importdir-check -dir D -tenant T -registry R [-owner U] [-group G]") + os.Exit(2) + } + g := &importdir.Guard{ + Registry: importdir.FileRegistry{Path: *registry}, + InstanceTenant: *tenant, + Policy: importdir.Policy{Owner: *owner, Group: *group}, + } + if err := g.Check(context.Background(), *dir); err != nil { + fmt.Fprintln(os.Stderr, "ABGELEHNT:", err) + os.Exit(1) + } + fmt.Println("in ordnung:", *dir) +} diff --git a/dms/cmd/migrate/main.go b/dms/cmd/migrate/main.go new file mode 100644 index 00000000..72a01662 --- /dev/null +++ b/dms/cmd/migrate/main.go @@ -0,0 +1,62 @@ +// migrate wendet die DMS-Tenant-Migrationen auf die per Umgebung gesetzte +// Datenbank an; die users-Tabelle stammt aus den Core-Migrationen und muss +// vorher existieren. +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "os" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate" +) + +var errNoUsersTable = errors.New("tabelle users fehlt: zuerst die Core-Migrationen anwenden") + +func run(ctx context.Context, pool *pgxpool.Pool, dir string, out io.Writer) error { + var present bool + if err := pool.QueryRow(ctx, `SELECT to_regclass('users') IS NOT NULL`).Scan(&present); err != nil { + return fmt.Errorf("users pruefen: %w", err) + } + if !present { + return errNoUsersTable + } + migrations, err := migrate.Load(dir) + if err != nil { + return fmt.Errorf("migrationen laden: %w", err) + } + applied, err := migrate.Up(ctx, pool, migrations) + for _, v := range applied { + _, _ = fmt.Fprintf(out, "angewendet: %s\n", v) + } + if err != nil { + return err + } + _, _ = fmt.Fprintf(out, "fertig: %d neu angewendet, %d gesamt\n", len(applied), len(migrations)) + return nil +} + +func main() { + dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN") + if dsn == "" { + log.Fatal("NEXARCH_DMS_APP_TENANT_DSN nicht gesetzt") + } + dir := os.Getenv("NEXARCH_DMS_MIGRATIONS_DIR") + if dir == "" { + dir = "migrations/tenant" + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + if err := run(ctx, pool, dir, os.Stdout); err != nil { + log.Fatalf("migrate: %v", err) + } +} diff --git a/dms/cmd/migrate/main_test.go b/dms/cmd/migrate/main_test.go new file mode 100644 index 00000000..4e276ea7 --- /dev/null +++ b/dms/cmd/migrate/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "bytes" + "context" + "errors" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb" +) + +func TestRun_FreshSchemaAppliesAllAndIsIdempotent(t *testing.T) { + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + pool, _ := testdb.PoolWithSchema(t, dsn, func(c *pgxpool.Config) { + rp := c.ConnConfig.RuntimeParams + rp["search_path"] = strings.TrimSuffix(rp["search_path"], ",public") + }) + + var out bytes.Buffer + if err := run(ctx, pool, "../../migrations/tenant", &out); !errors.Is(err, errNoUsersTable) { + t.Fatalf("ohne users-tabelle: %v", err) + } + + if _, err := pool.Exec(ctx, `CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE users (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil { + t.Fatal(err) + } + if err := run(ctx, pool, "../../migrations/tenant", &out); err != nil { + t.Fatalf("erster lauf: %v", err) + } + if !strings.Contains(out.String(), "angewendet: 0001_documents") { + t.Errorf("0001 nicht angewendet: %s", out.String()) + } + var tables int + _ = pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('documents','file_revisions','folders','audit_log','processing_jobs','upload_sessions')`).Scan(&tables) + if tables != 6 { + t.Errorf("kerntabellen: %d von 6", tables) + } + + out.Reset() + if err := run(ctx, pool, "../../migrations/tenant", &out); err != nil { + t.Fatalf("zweiter lauf: %v", err) + } + if strings.Contains(out.String(), "angewendet:") || !strings.Contains(out.String(), "0 neu angewendet") { + t.Errorf("zweiter lauf nicht idempotent: %s", out.String()) + } +} diff --git a/dms/cmd/search-e2e-fixture/main.go b/dms/cmd/search-e2e-fixture/main.go new file mode 100644 index 00000000..d27394ae --- /dev/null +++ b/dms/cmd/search-e2e-fixture/main.go @@ -0,0 +1,197 @@ +// search-e2e-fixture ist ein reines Pruefwerkzeug (FDN-18): es faehrt Worker-Runtime +// und Such-Routen mit derselben Verdrahtung wie cmd/worker und cmd/app gegen ein +// frisch migriertes Schema und das echte Manticore, laedt synthetische Dokumente +// ueber den Upload-Dienst hoch und wartet, bis sie ueber die Suche auffindbar sind. +// NICHT fuer den Betrieb: statischer Test-KEK, offenes CORS. +// +// E2E_DSN=... E2E_MANTICORE_ADDR=127.0.0.1:9306 E2E_LISTEN=127.0.0.1:8298 E2E_IDS_FILE=/tmp/ids.json go run ./cmd/search-e2e-fixture +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/routeprefix" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchsync" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchwiring" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testcorpus" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/upload" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/workerruntime" +) + +const ( + tenant = "e2e" + term = "Fixturebeleg" + docCount = 25 + prefix = "/api/dms/v1" +) + +type staticKEK struct{} + +func (staticKEK) TenantKEK(context.Context, string) ([]byte, error) { + return bytes.Repeat([]byte{0x5a}, dmscrypto.KEKSize), nil +} + +type noopUsage struct{} + +func (noopUsage) Report(context.Context, string, string, int64) error { return nil } + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} + +func main() { + dsn, addr := os.Getenv("E2E_DSN"), os.Getenv("E2E_MANTICORE_ADDR") + if dsn == "" || addr == "" { + log.Fatal("E2E_DSN und E2E_MANTICORE_ADDR muessen gesetzt sein") + } + listen := os.Getenv("E2E_LISTEN") + if listen == "" { + listen = "127.0.0.1:8298" + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + admin, err := pgxpool.New(ctx, dsn) + must(err) + schema := fmt.Sprintf("searche2e_%d", os.Getpid()) + _, err = admin.Exec(ctx, "CREATE SCHEMA "+schema) + must(err) + defer func() { _, _ = admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") }() + + cfg, err := pgxpool.ParseConfig(dsn) + must(err) + cfg.ConnConfig.RuntimeParams["search_path"] = schema + ",public" + pool, err := pgxpool.NewWithConfig(ctx, cfg) + must(err) + defer pool.Close() + + _, err = pool.Exec(ctx, ` + CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`) + must(err) + + migrations, err := migrate.Load(filepath.Join("migrations", "tenant")) + must(err) + _, err = migrate.Up(ctx, pool, migrations) + must(err) + + var userID string + must(pool.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, 'Test Prueferin') RETURNING id`, + fmt.Sprintf("search-e2e-%d@example.test", os.Getpid())).Scan(&userID)) + + idx, err := searchindex.Open(addr) + must(err) + defer idx.Close() + must(searchindex.EnsureSchema(ctx, idx)) + + dir, err := os.MkdirTemp("", "search-e2e-*") + must(err) + defer func() { _ = os.RemoveAll(dir) }() + storageSvc := storage.NewService(storage.NewLocalDriver(dir, []byte("fixture"), "http://127.0.0.1", 0), noopUsage{}, tenant) + cryptoSvc := dmscrypto.NewService(staticKEK{}) + queue := jobqueue.NewQueue(pool, 5*time.Minute) + + rt := workerruntime.New(workerruntime.Deps{Pool: pool, Storage: storageSvc, Crypto: cryptoSvc, TenantSlug: tenant, Queue: queue, SearchIndex: idx}) + for _, n := range rt.Notices { + log.Print("search-e2e-fixture: ", n) + } + go rt.Run(ctx, 3, "search-e2e", 50*time.Millisecond) + + svc := upload.NewService(pool, upload.NewSessionStore(pool), upload.NewStaging(dir), storageSvc, cryptoSvc, upload.NewValidator(10<<20, []string{"application/pdf"}), tenant) + workerruntime.AttachEnqueuers(svc, pool, queue, rt.JobTypes()) + + docIDs := make([]string, 0, docCount) + defer func() { + for _, id := range docIDs { + _ = searchindex.Delete(context.Background(), idx, searchsync.DocIndexID(id)) + } + }() + for i := 0; i < docCount; i++ { + content := testcorpus.GeneratePDF("Beleg", []string{fmt.Sprintf("Rechnung %s Nummer %d", term, i), "Musterhandel Nordlicht GmbH"}) + sid, err := svc.StartUpload(ctx, fmt.Sprintf("beleg-%02d.pdf", i), "application/pdf", int64(len(content)), nil, userID) + must(err) + _, err = svc.UploadChunk(ctx, sid, 0, bytes.NewReader(content)) + must(err) + docID, _, err := svc.Complete(ctx, sid, false) + must(err) + docIDs = append(docIDs, docID) + } + + root := http.NewServeMux() + api := routeprefix.Mount(root, prefix) + searchwiring.Mount(api, pool, idx) + srv := &http.Server{Addr: listen, Handler: cors(root), ReadHeaderTimeout: 10 * time.Second} + go func() { + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + must(waitIndexed(ctx, listen, userID, docCount)) + if f := os.Getenv("E2E_IDS_FILE"); f != "" { + b, _ := json.MarshalIndent(map[string]any{"user_id": userID, "term": term, "total": docCount, "doc_id": docIDs[0]}, "", " ") + must(os.WriteFile(f, b, 0o600)) + } + log.Printf("search-e2e-fixture bereit auf %s%s (schema %s, %d dokumente auffindbar)", listen, prefix, schema, docCount) + <-ctx.Done() + _ = srv.Shutdown(context.Background()) +} + +func waitIndexed(ctx context.Context, listen, userID string, want int) error { + deadline := time.Now().Add(2 * time.Minute) + u := fmt.Sprintf("http://%s%s/search?q=%s&caller=%s&limit=1", listen, prefix, url.QueryEscape(term), url.QueryEscape(userID)) + for time.Now().Before(deadline) { + resp, err := http.Get(u) + if err == nil { + var body struct { + Total int `json:"total"` + } + _ = json.NewDecoder(resp.Body).Decode(&body) + resp.Body.Close() + if body.Total >= want { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } + return fmt.Errorf("nicht alle %d dokumente wurden ueber die suche auffindbar", want) +} + +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/dms/cmd/smoke-tool/main.go b/dms/cmd/smoke-tool/main.go new file mode 100644 index 00000000..c2315790 --- /dev/null +++ b/dms/cmd/smoke-tool/main.go @@ -0,0 +1,147 @@ +// smoke-tool ist ein reines Pruefwerkzeug fuer FDN-19 (Gesamtsystem-Start): ein lokaler +// Stellvertreter fuer Core (KEK, Nutzungsmeldung, JWKS, Testtoken), ein Generator fuer +// synthetische Testdokumente und eine Pruefung der Audit-Kette. NICHT fuer den Betrieb. +// +// smoke-tool stub (Env FDN19_STUB_SEED, FDN19_STUB_LISTEN, Standard 127.0.0.1:8611) +// smoke-tool corpus +// smoke-tool verify-chain (Env NEXARCH_DMS_APP_TENANT_DSN) +package main + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/testcorpus" +) + +func main() { + if len(os.Args) < 2 { + log.Fatal("usage: smoke-tool stub | corpus | verify-chain") + } + switch os.Args[1] { + case "stub": + runStub() + case "corpus": + if len(os.Args) != 4 { + log.Fatal("usage: smoke-tool corpus ") + } + runCorpus(os.Args[2], os.Args[3]) + case "verify-chain": + runVerifyChain() + default: + log.Fatalf("unbekanntes Unterkommando %q", os.Args[1]) + } +} + +func runStub() { + seed := os.Getenv("FDN19_STUB_SEED") + if seed == "" { + log.Fatal("FDN19_STUB_SEED nicht gesetzt") + } + listen := os.Getenv("FDN19_STUB_LISTEN") + if listen == "" { + listen = "127.0.0.1:8611" + } + host, _, err := net.SplitHostPort(listen) + if err != nil || (host != "127.0.0.1" && host != "::1" && host != "localhost") { + log.Fatalf("nur Loopback erlaubt: %q", listen) + } + keySeed := sha256.Sum256([]byte("jwt:" + seed)) + priv := ed25519.NewKeyFromSeed(keySeed[:]) + pub := priv.Public().(ed25519.PublicKey) + + mux := http.NewServeMux() + mux.HandleFunc("GET /kek", func(w http.ResponseWriter, r *http.Request) { + kek := sha256.Sum256([]byte("kek:" + seed + ":" + r.URL.Query().Get("tenant"))) + writeJSON(w, map[string]string{"tenant_kek_base64": base64.StdEncoding.EncodeToString(kek[:])}) + }) + mux.HandleFunc("POST /usage", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("GET /jwks", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"keys": []map[string]string{{"kid": "smoke-1", "public_key": base64.StdEncoding.EncodeToString(pub)}}}) + }) + mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + ttl := time.Hour + if v, err := strconv.Atoi(q.Get("ttl")); err == nil { + ttl = time.Duration(v) * time.Second + } + now := time.Now() + claims := map[string]any{"sub": q.Get("sub"), "tenant": q.Get("tenant"), "iat": now.Unix(), "exp": now.Add(ttl).Unix()} + if role := q.Get("role"); role != "" { + claims["role"] = role + } + _, _ = w.Write([]byte(signJWT(priv, claims))) + }) + log.Printf("smoke-tool stub hoert auf %s", listen) + log.Fatal(http.ListenAndServe(listen, mux)) +} + +func signJWT(priv ed25519.PrivateKey, claims map[string]any) string { + enc := base64.RawURLEncoding + h, _ := json.Marshal(map[string]string{"alg": "EdDSA", "kid": "smoke-1", "typ": "JWT"}) + p, _ := json.Marshal(claims) + signing := enc.EncodeToString(h) + "." + enc.EncodeToString(p) + return signing + "." + enc.EncodeToString(ed25519.Sign(priv, []byte(signing))) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func runCorpus(dir, term string) { + must(os.MkdirAll(dir, 0o755)) + write := func(name string, data []byte, err error) { + must(err) + must(os.WriteFile(filepath.Join(dir, name), data, 0o644)) + } + write("text.pdf", testcorpus.GeneratePDF("Smokebeleg", []string{"Beleg " + term, "Musterhandel Nordlicht GmbH", "Betrag 119 Euro"}), nil) + write("scan-source.pdf", testcorpus.GeneratePDF("Scan", []string{"Kaffeemuehle Lieferschein", "Projekt " + term + " Nachweis", "Fjordweg Musterstadt"}), nil) + + spec := testcorpus.DefaultInvoice() + spec.Number = "SMOKE-" + term + write("invoice-ubl.xml", testcorpus.GenerateUBLInvoice(spec), nil) + zugferd, err := testcorpus.GenerateZUGFeRD(spec) + write("zugferd.pdf", zugferd, err) + fmt.Println(spec.Number) +} + +func runVerifyChain() { + dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN") + if dsn == "" { + log.Fatal("NEXARCH_DMS_APP_TENANT_DSN nicht gesetzt") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + must(err) + defer pool.Close() + res, err := audittrail.VerifyChain(ctx, pool) + must(err) + var n int + must(pool.QueryRow(ctx, `SELECT count(*) FROM audit_log`).Scan(&n)) + fmt.Printf("valid=%t eintraege=%d\n", res.Valid, n) + if !res.Valid { + fmt.Printf("gebrochen bei id=%d: %s\n", res.BrokenAtID, res.BrokenReason) + os.Exit(1) + } +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/dms/cmd/worker/main.go b/dms/cmd/worker/main.go new file mode 100644 index 00000000..cd1be1fd --- /dev/null +++ b/dms/cmd/worker/main.go @@ -0,0 +1,258 @@ +// worker ist der Hintergrund-Dienst des DMS — verarbeitet lange laufende +// Aufgaben (Indexierung, OCR, Storage-Vorgaenge in spaeteren Kacheln), +// getrennt vom App-Prozess (siehe cmd/app). DOC-16: verarbeitet zusaetzlich +// Requeue-Jobs fuer fehlgeschlagene RET-05-Registrierungen. +package main + +import ( + "context" + "crypto/x509" + "errors" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/errortracking" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/logging" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/mailimport" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/malwarescan" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/metrics" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentiondestroy" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/shared" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/upload" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/workerruntime" +) + +func main() { + log.Printf("dms-worker gestartet (version %s)", shared.Version) + + logger := logging.NewDefault() + metricsReg := metrics.NewRegistry() + tracker := errortracking.New(logger, 5, time.Minute) + + metricsAddr := os.Getenv("NEXARCH_DMS_WORKER_METRICS_ADDR") + if metricsAddr == "" { + metricsAddr = ":8601" + } + go func() { + if err := http.ListenAndServe(metricsAddr, metricsReg.Handler()); err != nil { + log.Printf("dms-worker: /metrics-server beendet: %v", err) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + lease, err := jobqueue.ParseLeaseSeconds(os.Getenv("NEXARCH_DMS_WORKER_JOB_LEASE_SECONDS")) + if err != nil { + log.Fatalf("dms-worker: NEXARCH_DMS_WORKER_JOB_LEASE_SECONDS: %v", err) + } + + var ( + queue *jobqueue.Queue + client *retentionclient.Client + ) + if dsn := os.Getenv("NEXARCH_DMS_WORKER_TENANT_DSN"); dsn != "" { + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("dms-worker: datenbankverbindung: %v", err) + } + queue = jobqueue.NewQueue(pool, lease) + client = retentionclient.New(os.Getenv("NEXARCH_DMS_WORKER_RETENTION_BASE_URL"), os.Getenv("NEXARCH_DMS_WORKER_TENANT_SLUG")) + startJobWorkers(ctx, pool, queue, tracker) + } else { + log.Print("dms-worker: NEXARCH_DMS_WORKER_TENANT_DSN nicht gesetzt, DOC-16-Requeue-Verarbeitung deaktiviert") + } + + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + log.Println("dms-worker beendet") + return + case <-ticker.C: + if queue != nil { + if n, err := queue.QueueLength(ctx); err == nil { + metricsReg.SetQueueLength(n) + } + processRetentionRegisterJobs(ctx, queue, client, tracker) + } + } + } +} + +// processRetentionRegisterJobs holt und verarbeitet ALLE aktuell +// abholbaren doc16_retention_register-Jobs (Akzeptanzkriterium 3: Requeue +// statt Absturz/Verwerfen — Fail() haengt selbst nie ab, sondern setzt +// pending mit Backoff oder dead_letter, siehe FDN-04). +func processRetentionRegisterJobs(ctx context.Context, queue *jobqueue.Queue, client *retentionclient.Client, tracker *errortracking.Tracker) { + for { + job, err := queue.Dequeue(ctx, "dms-worker", []string{retentiondestroy.JobTypeRegister}) + if err != nil { + if !errors.Is(err, jobqueue.ErrNoJobAvailable) { + log.Printf("dms-worker: dequeue fehlgeschlagen: %v", err) + } + return + } + // FDN-08 Akzeptanzkriterium 1: Job-ID als Korrelations-ID, analog zu + // X-Request-Id bei HTTP-Anfragen (siehe internal/jobqueue.Worker). + jobCtx := logging.WithRequestID(ctx, job.ID) + if err := retentiondestroy.ProcessRegisterJob(jobCtx, client, job.Payload); err != nil { + log.Printf("dms-worker: retention-registrierung erneut fehlgeschlagen (job %s): %v", job.ID, err) + tracker.Capture(jobCtx, "worker.retention-register", err) + if failErr := queue.Fail(ctx, job.ID, err); failErr != nil { + log.Printf("dms-worker: job als fehlgeschlagen markieren: %v", failErr) + } + continue + } + if err := queue.Complete(ctx, job.ID); err != nil { + log.Printf("dms-worker: job abschliessen: %v", err) + } else { + log.Printf("dms-worker: retention-registrierung erfolgreich nachgeholt (job %s)", job.ID) + } + } +} + +// startJobWorkers verdrahtet alle Fachjobs (FDN-17). Fehlt die Storage-/Schluessel- +// Konfiguration, werden sie nicht gestartet und das wird gemeldet. +func startJobWorkers(ctx context.Context, pool *pgxpool.Pool, queue *jobqueue.Queue, tracker *errortracking.Tracker) { + storageDir := os.Getenv("NEXARCH_DMS_WORKER_STORAGE_DIR") + tenantSlug := os.Getenv("NEXARCH_DMS_WORKER_TENANT_SLUG") + kekURL := os.Getenv("NEXARCH_DMS_WORKER_KEK_ENDPOINT_URL") + if storageDir == "" || tenantSlug == "" || kekURL == "" { + log.Print("dms-worker: NEXARCH_DMS_WORKER_STORAGE_DIR/_TENANT_SLUG/_KEK_ENDPOINT_URL unvollstaendig, Fachjobs (Vorschau, Konvertierung, Virenscan, Export, Texterkennung) werden NICHT verarbeitet") + return + } + driver := storage.NewLocalDriver(storageDir, []byte(os.Getenv("NEXARCH_DMS_WORKER_STORAGE_SIGNING_SECRET")), os.Getenv("NEXARCH_DMS_WORKER_STORAGE_PUBLIC_BASE_URL"), signedURLMaxTTL(os.Getenv("NEXARCH_DMS_WORKER_SIGNED_URL_MAX_TTL"))) + usage := storage.NewHTTPUsageReporter(os.Getenv("NEXARCH_DMS_WORKER_USAGE_ENDPOINT_URL"), os.Getenv("NEXARCH_DMS_WORKER_USAGE_CLIENT_ID"), os.Getenv("NEXARCH_DMS_WORKER_USAGE_CLIENT_SECRET"), nil) + cryptoSvc := dmscrypto.NewService(dmscrypto.NewHTTPKEKProvider(kekURL, os.Getenv("NEXARCH_DMS_WORKER_KEK_CLIENT_ID"), os.Getenv("NEXARCH_DMS_WORKER_KEK_CLIENT_SECRET"), nil)) + + deps := workerruntime.Deps{Pool: pool, Storage: storage.NewService(driver, usage, tenantSlug), Crypto: cryptoSvc, TenantSlug: tenantSlug, Queue: queue} + if addr := os.Getenv("NEXARCH_DMS_WORKER_CLAMD_ADDRESS"); addr != "" { + network := os.Getenv("NEXARCH_DMS_WORKER_CLAMD_NETWORK") + if network == "" { + network = "unix" + } + deps.Scanner = &malwarescan.ClamdScanner{Network: network, Address: addr} + } + if addr := os.Getenv("NEXARCH_DMS_WORKER_MANTICORE_ADDR"); addr != "" { + name := os.Getenv("NEXARCH_DMS_WORKER_SEARCH_INDEX_NAME") + if name == "" { + name = searchindex.DefaultTableName + } + if err := searchindex.ValidateTableName(name); err != nil { + log.Fatalf("dms-worker: NEXARCH_DMS_WORKER_SEARCH_INDEX_NAME: %v", err) + } + idx, err := searchindex.OpenNamed(addr, name) + if err == nil { + err = searchindex.EnsureSchema(ctx, idx) + } + if err != nil { + log.Printf("dms-worker: suchindex nicht verfuegbar, searchindex.sync wird NICHT verarbeitet: %v", err) + } else { + deps.SearchIndex = idx + } + } + if v, err := strconv.ParseInt(os.Getenv("NEXARCH_DMS_WORKER_PLAINTEXT_MAX_BYTES"), 10, 64); err == nil && v > 0 { + deps.PlaintextMaxBytes = v + } + if v, err := strconv.Atoi(os.Getenv("NEXARCH_DMS_WORKER_PLAINTEXT_MAX_CHARS")); err == nil && v > 0 { + deps.PlaintextMaxChars = v + } + var mailSvc *upload.Service + if stagingDir := os.Getenv("NEXARCH_DMS_WORKER_MAILIMPORT_STAGING_DIR"); stagingDir != "" { + maxBytes := int64(mailimport.DefaultMaxMessageBytes) + if v, err := strconv.ParseInt(os.Getenv("NEXARCH_DMS_WORKER_MAILIMPORT_MAX_MESSAGE_BYTES"), 10, 64); err == nil && v > 0 { + maxBytes = v + } + mailSvc = upload.NewService(pool, upload.NewSessionStore(pool), upload.NewStaging(stagingDir), deps.Storage, cryptoSvc, upload.NewValidator(maxBytes, nil), tenantSlug) + dialer := mailimport.Dialer{} + if caFile := os.Getenv("NEXARCH_DMS_WORKER_MAILIMPORT_CA_FILE"); caFile != "" { + pem, err := os.ReadFile(caFile) + roots := x509.NewCertPool() + if err != nil || !roots.AppendCertsFromPEM(pem) { + log.Printf("dms-worker: NEXARCH_DMS_WORKER_MAILIMPORT_CA_FILE nicht nutzbar, System-Zertifikate werden verwendet: %v", err) + } else { + dialer.RootCAs = roots + } + } + deps.MailImport = &mailimport.Importer{ + Pool: pool, + Store: &mailimport.Store{Pool: pool, Crypto: cryptoSvc, TenantSlug: tenantSlug}, + Upload: mailSvc, + Dialer: dialer, + MaxMessageBytes: maxBytes, + } + } + if addr := os.Getenv("NEXARCH_DMS_WORKER_KOSIT_ADDR"); addr != "" { + timeout := 30 * time.Second + if v, err := strconv.Atoi(os.Getenv("NEXARCH_DMS_WORKER_KOSIT_TIMEOUT_SECONDS")); err == nil && v > 0 { + timeout = time.Duration(v) * time.Second + } + val, err := einvoice.NewValidator(addr, os.Getenv("NEXARCH_DMS_WORKER_KOSIT_RULESET"), timeout) + if err != nil { + log.Printf("dms-worker: e-rechnungs-validator ungueltig konfiguriert, Ergebnis bleibt not_checked: %v", err) + } else { + deps.EInvoiceValidator = val + go func() { + rulesets, err := val.CurrentRulesets(ctx) + if err != nil { + log.Printf("dms-worker: regelwerk des validators nicht ermittelbar, nutze Angabe %q: %v", val.Ruleset, err) + rulesets = []string{val.Ruleset} + } + n, err := einvoice.EnqueueRecheck(ctx, pool, queue, rulesets...) + if err != nil { + log.Printf("dms-worker: neupruefung von e-rechnungen nicht eingereiht: %v", err) + } else if n > 0 { + log.Printf("dms-worker: %d e-rechnungen zur neupruefung gegen regelwerk %v eingereiht", n, rulesets) + } + }() + } + } else { + log.Print("dms-worker: kein e-rechnungs-validator (NEXARCH_DMS_WORKER_KOSIT_ADDR) konfiguriert, Konformitaet bleibt not_checked") + } + rt := workerruntime.New(deps) + rt.Middleware = func(next jobqueue.Handler) jobqueue.Handler { + return func(ctx context.Context, job *jobqueue.Job) error { + err := next(ctx, job) + if err != nil { + tracker.Capture(ctx, "worker."+job.JobType, err) + } + return err + } + } + for _, n := range rt.Notices { + log.Print("dms-worker: ", n) + } + concurrency := 2 + if v, err := strconv.Atoi(os.Getenv("NEXARCH_DMS_WORKER_CONCURRENCY")); err == nil && v > 0 { + concurrency = v + } + log.Printf("dms-worker: verarbeitet jobtypen %v mit %d parallelen workern", rt.JobTypes(), concurrency) + if mailSvc != nil { + workerruntime.AttachEnqueuers(mailSvc, pool, queue, rt.JobTypes()) + go mailimport.RunScheduler(ctx, pool, queue, 15*time.Second) + } + go rt.Run(ctx, concurrency, workerruntime.HostName(), time.Second) +} + +func signedURLMaxTTL(raw string) time.Duration { + ttl, err := storage.ParseMaxSignedURLTTL(raw) + if err != nil { + log.Fatalf("dms-worker: %v", err) + } + return ttl +} diff --git a/dms/deploy/manticore/schema/0001_dms_documents.sql b/dms/deploy/manticore/schema/0001_dms_documents.sql new file mode 100644 index 00000000..fe1b5665 --- /dev/null +++ b/dms/deploy/manticore/schema/0001_dms_documents.sql @@ -0,0 +1,39 @@ +-- SRC-01: Manticore-Schema fuer den DMS-Volltextindex. Wird von +-- internal/searchindex.EnsureSchema idempotent angewendet (CREATE TABLE +-- IF NOT EXISTS) — diese Datei ist die dokumentierte Referenz derselben +-- DDL, keine separat auszufuehrende Migration. +-- +-- Gewichtung (Akzeptanzkriterium 2), angewendet ueber OPTION field_weights +-- in internal/searchindex.BuildSearchQuery statt hier im Schema: +-- title = 10, tags = 5, body = 1 +-- +-- attrs (SRC-08): JSON mit tags[] (Tag-Namen), f{} (numerische/Datums- +-- Werte benutzerdefinierter Felder, Datum als Epoche) und fs{} (Rohwert je +-- Feld als String) fuer Facetten und Filter. +-- +-- Deutsche Sprachbehandlung (Akzeptanzkriterium 3): +-- morphology='libstemmer_de' -- Snowball-Stemmer fuer Deutsch +-- charset_table erweitert um Umlaute/ß als Buchstaben (sonst als +-- Trenner behandelt, Suche nach "Überweisung" würde sonst fehlschlagen) +-- stopwords aus der von Manticore mitgelieferten deutschen Liste +-- min_infix_len=3 fuer Teilwort-/Kompositasuche (z.B. "*zahlung*" +-- findet "Rückzahlungsvereinbarung") + +CREATE TABLE IF NOT EXISTS dms_documents ( + id bigint, + document_id string attribute, + title text indexed stored, + body text indexed stored, + tags text indexed stored, + folder_path string attribute, + document_type string attribute, + created_by string attribute, + created_at timestamp, + updated_at timestamp, + attrs json +) +morphology='libstemmer_de' +charset_table='non_cjk, U+00DF, U+00E4, U+00F6, U+00FC, U+00C4, U+00D6, U+00DC' +stopwords='/usr/share/manticore/stopwords/de' +min_infix_len='3' +index_exact_words='1'; diff --git a/dms/docs/API.md b/dms/docs/API.md new file mode 100644 index 00000000..a2511bc7 --- /dev/null +++ b/dms/docs/API.md @@ -0,0 +1,135 @@ +# DMS-API v1 + +Schnittstellenbeschreibung: `GET /api/dms/v1/openapi.json` (OpenAPI 3.0, Quelle `internal/openapi/openapi.json`). Die Oberfläche nutzt ausschließlich diese Schnittstelle. + +## Versionierung und Routen + +Alle Endpunkte liegen unter `/api/dms/v1` (Registerpräfix aus `deploy/routes.tsv`, wie `/api/core/v1`). Es gibt keine DMS-eigene Versionierung: eine neue Version ist ein neues Präfix des zentralen Schemas, bestehende Pfade bleiben unberührt. + +## Authentifizierung + +`Authorization: Bearer ` (EdDSA, `kid`-Header, Claims `sub` und `tenant`; Schlüssel per JWKS von Core, `NEXARCH_DMS_APP_CORE_JWKS_URL`). Der Mandant im Token muss dem Mandanten der Instanz (`NEXARCH_DMS_APP_TENANT_SLUG`) entsprechen. Die Identität kommt ausschließlich aus dem Token; ein mitgeschickter `caller`-Parameter oder `X-User-Id`-Header wird überschrieben. + +Ohne Token: `401`. Ohne Erreichbarkeit von Core und ohne bekannten Schlüsselsatz: `503`. Bereits bekannte Schlüssel gelten bei einem Core-Ausfall weiter. + +Ausnahmen ohne Nutzeridentität: `GET /download` (Signatur der zuvor angeforderten URL), `POST /retention/destroy-callback` (Archive, Dienst-zu-Dienst) und `GET /openapi.json`. + +Übergangsmodus: mit `NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY=true` gilt ohne Token der Parameter `caller` als Identität (nicht authentifiziert, nur für Entwicklung). Ohne JWKS-URL und ohne diesen Schalter startet der Dienst nicht. + +## Rechte + +Objektzugriff (Eigentümer, Admin, Prüfer) erzwingt der Guard vor jedem Handler. Dokumentlisten eines Ordners (`GET /folders/documents`, `GET /folders/{id}/documents`) enthalten nur Dokumente des Aufrufers und verlangen eine Identität (`401` ohne). Mit `NEXARCH_DMS_APP_POLICY_URL` fragt das DMS zusätzlich je Route Core (`POST /authorize`, Header `X-Service-Token`) nach dem in der OpenAPI-Beschreibung als `x-permission` deklarierten Recht. Die Rolle stammt aus dem Token-Claim `role`, sonst gilt `NEXARCH_DMS_APP_POLICY_DEFAULT_ROLE` (Standard `user`), Admin-IDs erhalten `tenant_admin`. Fehler oder Ausfall von Core lehnen die Anfrage ab (`503`, nie stillschweigend erlaubt). + +## Fehlerformat + +Jede Fehlerantwort (`>= 400`, auch `404`/`405` des Routers) hat das Core-Schema: + +```json +{"error": {"code": "not_found", "message": "dokument nicht gefunden"}} +``` + +`code` ist stabil und maschinenlesbar (`invalid_request`, `unauthenticated`, `forbidden`, `not_found`, `method_not_allowed`, `conflict`, `unprocessable`, `payload_too_large`, `locked`, `unavailable`, `internal_error`). Zusatzfelder (z. B. `documentId` beim Duplikat) stehen daneben. + +## Beispiele + +Die folgenden Blöcke werden von `TestDocumentationExamples` gegen die verdrahtete Anwendung ausgeführt. `capture name=schluessel` merkt sich einen Wert der JSON-Antwort für spätere `{name}`-Platzhalter. + +Upload-Sitzung starten: + +```example +POST /uploads -> 201 +capture sessionId=sessionId +{"filename":"beleg.txt","mimeType":"text/plain","totalSize":16} +``` + +Datenblock senden (fortsetzbar über `offset`): + +```example +PUT /uploads/{sessionId}/chunk?offset=0 -> 200 +Rechnung 4711 ok +``` + +Stand abfragen: + +```example +GET /uploads/{sessionId} -> 200 +``` + +Abschließen, Dokument und erste Revision entstehen: + +```example +POST /uploads/{sessionId}/complete -> 200 +capture documentId=documentId +``` + +Dokument lesen: + +```example +GET /documents/{documentId} -> 200 +``` + +```example +GET /documents/{documentId}/content -> 200 +``` + +```example +GET /documents/{documentId}/revisions -> 200 +``` + +Tags und Metadaten: + +```example +GET /documents/{documentId}/tags -> 200 +``` + +```example +GET /documents/{documentId}/metadata -> 200 +``` + +```example +GET /metadata-fields -> 200 +``` + +Ordner und Papierkorb: + +```example +GET /folders/children -> 200 +``` + +```example +GET /trash -> 200 +``` + +Dokument in den Papierkorb verschieben (Auswahl von `document_ids` und/oder `folder_ids`; Antwort mit `trashed_*_ids` und `failed` je ID, ein von anderen ausgeliehenes Dokument und ein Ordner mit Dokumenten anderer Nutzer werden abgelehnt) und wiederherstellen: + +```example +POST /trash -> 200 +{"document_ids":["{documentId}"]} +``` + +```example +POST /trash/restore -> 200 +{"document_ids":["{documentId}"]} +``` + +Signierte Download-URL anfordern: + +```example +GET /documents/{documentId}/download-url -> 200 +``` + +Fehlerformat bei unbekanntem Dokument: + +```example +GET /documents/00000000-0000-0000-0000-000000000000/tags -> 403 +``` + +Schnittstellenbeschreibung (ohne Anmeldung): + +```example +GET /openapi.json -> 200 anonymous +``` + +## Optionale CMIS-Schnittstelle + +Neben der REST-API gibt es eine abschaltbare CMIS-1.1-Schnittstelle (Browser Binding) unter `/api/dms/v1/cmis/browser`. Sie nutzt intern diese REST-API und damit dieselben Rechte- und Mandantenregeln; Einzelheiten in [CMIS.md](CMIS.md). diff --git a/dms/docs/BETRIEB-IMPORT-VERZEICHNISSE.md b/dms/docs/BETRIEB-IMPORT-VERZEICHNISSE.md new file mode 100644 index 00000000..52477e9f --- /dev/null +++ b/dms/docs/BETRIEB-IMPORT-VERZEICHNISSE.md @@ -0,0 +1,62 @@ +# Betriebsanleitung: Import-Verzeichnisse (Consume-Ordner, Scanner-Ablage) + +Gilt für INT-08 (Consume-Ordner) und INT-06 (Scanner-Ablage). Diese Verzeichnisse importieren ohne API-Aufruf und damit ohne +Anwendungs-Authentifizierung: die einzige Absicherung sind Zuordnung und Dateisystemrechte. Beides wird vor jedem Scan technisch geprüft. + +## Aufbau + +- Ein Verzeichnis je Tenant, benannt nach dem Tenant-Slug: `/srv/nexarch/import//`. Nie mehrere Tenants in einem Verzeichnis. +- Das Verzeichnis wird **von Hand bereitgestellt**; das DMS legt es nicht an (nur den Unterordner `error/`). +- Eintrag in der Registry-Datei (`NEXARCH_DMS_APP_IMPORT_REGISTRY_FILE`, JSON, nur vom DMS-Dienstbenutzer lesbar): + + ```json + {"assignments":[{"dir":"/srv/nexarch/import/acme","tenant":"acme"}]} + ``` + + Ein Verzeichnis darf genau einmal vorkommen. Symbolische Links werden aufgelöst; maßgeblich ist der echte Pfad. + +## Rechte (Vorgabe, wird geprüft) + +| Was | Vorgabe | +|---|---| +| Eigentümer | DMS-Dienstbenutzer (`NEXARCH_DMS_APP_IMPORT_OWNER`, z. B. `nexarch-dms`) | +| Gruppe | eigene Gruppe je Tenant (`NEXARCH_DMS_APP_IMPORT_GROUP`, z. B. `nexarch-import-acme`) mit genau den Konten der vorgesehenen Quellen (Scanner-/Sync-Dienst) | +| Modus | `0770`: Eigentümer und genau diese Gruppe schreiben, **kein** Zugriff für andere | +| Verzeichnisart | echtes Verzeichnis, kein Symlink | +| Netzfreigabe (SMB/NFS) | nur für die Gruppe, schreibend; nicht anonym, nicht für alle | + +Ohne vorgesehene Gruppe (`NEXARCH_DMS_APP_IMPORT_GROUP` leer): Modus `0700`, kein Gruppenzugriff. + +Einrichtung (Beispiel, als root): + +``` +install -d -o nexarch-dms -g nexarch-import-acme -m 0770 /srv/nexarch/import/acme +``` + +## Was das DMS bei jedem Scan prüft + +1. Verzeichnis existiert, ist kein Symlink-Umweg (echter Pfad wird aufgelöst). +2. Die Registry ordnet den echten Pfad genau einem Tenant zu, und dieser ist der Tenant **dieser** Instanz (`NEXARCH_DMS_APP_TENANT_SLUG`). +3. Der Verzeichnisname entspricht dem Tenant-Slug. +4. Core bestätigt, dass der Tenant existiert (`NEXARCH_DMS_APP_CORE_BASE_URL`, `NEXARCH_DMS_APP_CORE_SERVICE_TOKEN`, TEN-01). +5. Eigentümer, Gruppe und Modus entsprechen der Tabelle oben. + +Jede Abweichung, auch ein nicht erreichbarer Core oder eine fehlende Registry, lehnt den Import ab (fail-closed): es wird nichts angelegt, die Dateien +bleiben unangetastet liegen, und ein Audit-Eintrag `watchfolder_import_rejected` mit Verzeichnis und Grund wird geschrieben (einmal je Grund, nicht bei +jedem Takt). Nach der Korrektur importiert der nächste Lauf die liegengebliebenen Dateien. + +## Manuelle Prüfung + +``` +importdir-check -dir /srv/nexarch/import/acme -tenant acme \ + -registry /etc/nexarch/import-registry.json -owner nexarch-dms -group nexarch-import-acme +``` + +Exit 0 = in Ordnung, 1 = abgelehnt (Grund auf stderr). Nach jeder Änderung an Freigaben oder Gruppen ausführen. + +## Grenzen + +- Die Zuordnung Verzeichnis → Tenant liegt in einer lokalen Registry-Datei; Core (TEN-01) bestätigt nur, dass der Tenant existiert. Eine Verankerung + der Verzeichniszuordnung selbst in Core braucht eine Core-Erweiterung (eigenes Ticket im Core-Board). +- Die Rechte prüft das DMS nur am Verzeichnis selbst, nicht an Dateien darin und nicht an der Freigabekonfiguration von SMB/NFS. +- INT-06 (Scanner-Ablage) ist im DMS noch nicht umgesetzt; dieselbe Prüfung gilt, sobald es sie nutzt. diff --git a/dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md b/dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md new file mode 100644 index 00000000..62b5dd7a --- /dev/null +++ b/dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md @@ -0,0 +1,89 @@ +# Betrieb: Umgebungsvariablen des DMS + +Aus dem Code abgeleitet (`cmd/app`, `cmd/worker`, `cmd/migrate`, `internal/routeprefix`, Stand Integrationsbranch). Secrets gehören ausschließlich in die Umgebung (z. B. `/etc/nexarch/dms-app.env` mit restriktiven Rechten), nie in Code, Logs oder Dokumentation. + +"Pflicht" heißt: ohne den Wert startet der Prozess nicht oder die genannte Funktion fällt aus. "Aus" bedeutet: leer = Funktion deaktiviert, mit Logzeile. + +## dms-app (Port-Bereich 8600 bis 8699) + +| Variable | Standard | Wirkung | +|---|---|---| +| `NEXARCH_DMS_APP_LISTEN_ADDR` | `:8600` | Lauschadresse | +| `NEXARCH_DMS_APP_ROUTE_PREFIX` | `/api/dms/v1` | Präfix aller fachlichen Routen (FDN-14); `/healthz` und `/metrics` bleiben außerhalb | +| `NEXARCH_DMS_APP_TENANT_DSN` | Pflicht für Fachfunktionen | Tenant-Datenbank; ohne sie: keine API-Routen, kein Guard, keine Retention-Anbindung, keine Suche | +| `NEXARCH_DMS_APP_TENANT_SLUG` | Pflicht | Mandantenkennung | +| `NEXARCH_DMS_APP_ADMIN_USER_IDS` | leer | Kommagetrennte Nutzer-IDs mit Admin-Rechten im Guard | +| `NEXARCH_DMS_APP_CORE_JWKS_URL` | Pflicht (oder Übergangsschalter) | JWKS-Endpunkt von Core; Bearer-Token werden lokal gegen diesen Schlüsselsatz geprüft (INT-01, `docs/API.md`). Ohne sie und ohne Übergangsschalter startet dms-app mit DSN nicht | +| `NEXARCH_DMS_APP_JWKS_TTL_SECONDS` | 300 | Cache-Dauer des Schlüsselsatzes; bei Core-Ausfall gilt der letzte bekannte Satz weiter | +| `NEXARCH_DMS_APP_ALLOW_CALLER_IDENTITY` | `false` | Übergang: `true` lässt Anfragen ohne Token mit `caller`-Parameter als Identität zu (nicht authentifiziert, nur Entwicklung) | +| `NEXARCH_DMS_APP_POLICY_URL` / `_POLICY_SERVICE_TOKEN` | Aus | Core-Policy-Endpunkt (RBAC-06, `POST /authorize`); aktiviert die Rechteprüfung je Route (`x-permission` der OpenAPI-Beschreibung). Token ist Secret; Ausfall lehnt ab | +| `NEXARCH_DMS_APP_POLICY_DEFAULT_ROLE` / `_POLICY_TTL_SECONDS` | `user` / 30 | Rolle ohne `role`-Claim im Token; Cache-Dauer der Entscheidungen | +| `NEXARCH_DMS_APP_STAGING_DIR` | Pflicht bei DSN (Rückfall: `NEXARCH_DMS_APP_WATCHFOLDER_STAGING_DIR`) | Staging der Upload-Blöcke | +| `NEXARCH_DMS_APP_DOWNLOAD_SIGNING_SECRET` | Pflicht bei DSN | Schlüssel für Download-URLs (Secret) | +| `NEXARCH_DMS_APP_DOWNLOAD_URL_TTL_SECONDS` | 300 | Gültigkeit signierter Download-URLs | +| `NEXARCH_DMS_APP_TRASH_RETENTION_SECONDS` | 2592000 (30 Tage) | Papierkorb-Frist | +| `NEXARCH_DMS_APP_AUDITOR_USER_IDS` | leer | Nutzer-IDs mit Zugriff auf `/audit-log` | +| `NEXARCH_DMS_APP_STORAGE_DIR` | Pflicht | Lokales Storage-Verzeichnis | +| `NEXARCH_DMS_APP_STORAGE_SIGNING_SECRET` | Pflicht | Schlüssel für signierte URLs (Secret) | +| `NEXARCH_DMS_APP_STORAGE_PUBLIC_BASE_URL` | Pflicht | Basis-URL signierter Links | +| `NEXARCH_DMS_APP_USAGE_ENDPOINT_URL` / `_CLIENT_ID` / `_CLIENT_SECRET` | Pflicht | Nutzungsmeldung an Core/Archive (Secret) | +| `NEXARCH_DMS_APP_KEK_ENDPOINT_URL` / `_CLIENT_ID` / `_CLIENT_SECRET` | Pflicht | KEK-Bezug (Secret); auf 131 derzeit nicht gesetzt | +| `NEXARCH_DMS_APP_UPLOAD_MAX_SIZE_BYTES` | 0 (kein Limit) | Maximale Uploadgröße | +| `NEXARCH_DMS_APP_UPLOAD_ALLOWED_MIME_TYPES` | leer (alle) | Kommagetrennte Whitelist | +| `NEXARCH_DMS_APP_ENQUEUE_JOB_TYPES` | `thumbnail.generate,ocr.plan,einvoice.read` (+ `searchindex.sync` bei Manticore) | Jobtypen, die nach Upload eingereiht werden; ohne `malwarescan.scan` warnt der Start | +| `NEXARCH_DMS_APP_MANTICORE_ADDR` | Aus | Suchindex; aktiviert `/search`, `/saved-searches`, `/ocr` | +| `NEXARCH_DMS_APP_SEARCH_INDEX_NAME` | `dms_documents` | FDN-21: Name des Manticore-Index dieser Instanz, `[a-z][a-z0-9_]{0,62}`, keine SQL-Schlüsselwörter; muss dem Wert von `NEXARCH_DMS_WORKER_SEARCH_INDEX_NAME` derselben Instanz entsprechen. Mehrere Instanzen auf demselben Manticore brauchen verschiedene Namen; ein ungültiger Name verhindert den Start | +| `NEXARCH_DMS_APP_RETENTION_BASE_URL` / `_RETENTION_CLASS` / `_RETENTION_CALLBACK_URL` | Aus (ohne DSN) | Registrierung bei Archive/RET-05 (DOC-16) | +| `NEXARCH_DMS_APP_CALLBACK_REQUIRE_SERVICE_TOKEN` | `false` | INT-16: Vernichtungs-Rückruf verlangt ein Core-Service-Token (Prüfung über `NEXARCH_DMS_APP_CORE_JWKS_URL`); ohne Verifier startet der Rückruf geschlossen (503). Der Header `X-Nexarch-Tenant` ist immer Pflicht und muss `NEXARCH_DMS_APP_TENANT_SLUG` entsprechen | +| `NEXARCH_DMS_APP_CORE_BASE_URL` / `_CORE_SERVICE_TOKEN` | Aus | Tenant-Bestätigung gegen die Core-Registry (INT-14); Token ist Secret | +| `NEXARCH_DMS_APP_WATCHFOLDER_DIR` | Aus | Consume-Ordner (INT-08/06) | +| `NEXARCH_DMS_APP_IMPORT_REGISTRY_FILE` | Aus | Verzeichnis-Tenant-Zuordnung; ohne sie bleibt der Consume-Ordner deaktiviert | +| `NEXARCH_DMS_APP_IMPORT_OWNER` / `_IMPORT_GROUP` | leer | Erwartete Dateisystem-Eigentümer | +| `NEXARCH_DMS_APP_WATCHFOLDER_STAGING_DIR` | Pflicht bei Consume-Ordner | Staging | +| `NEXARCH_DMS_APP_WATCHFOLDER_SYSTEM_USER_ID` | Pflicht bei Consume-Ordner | Akteur der Importe | +| `NEXARCH_DMS_APP_WATCHFOLDER_POLL_INTERVAL_SECONDS` | 5 | Abfrageintervall | +| `NEXARCH_DMS_APP_WATCHFOLDER_SPLIT_BATCHES` | `false` | Stapeltrennung (`true` aktiviert) | +| `NEXARCH_DMS_APP_WATCHFOLDER_SEPARATOR_MARKER` | Standardmarker | Textmarker der Trennseite | +| `NEXARCH_DMS_APP_WATCHFOLDER_BARCODE_PREFIX` / `_BARCODE_FIELD_KEY` | leer | Barcode-Trennung und Zielfeld für den Barcode-Wert | +| `NEXARCH_DMS_APP_WATCHFOLDER_BLANK_PAGES` | `false` | Leerseiten als Trenner | +| `NEXARCH_DMS_APP_WATCHFOLDER_PDFTOPPM` | `pdftoppm` im PATH | Rasterisierung für Barcode/Leerseiten | + +## dms-worker + +| Variable | Standard | Wirkung | +|---|---|---| +| `NEXARCH_DMS_WORKER_METRICS_ADDR` | `:8601` | Metrik-Endpunkt | +| `NEXARCH_DMS_WORKER_TENANT_DSN` | Pflicht | Datenbank; ohne sie keine Requeue-Verarbeitung | +| `NEXARCH_DMS_WORKER_TENANT_SLUG` | Pflicht für Fachjobs | Mandantenkennung | +| `NEXARCH_DMS_WORKER_STORAGE_DIR` / `_STORAGE_SIGNING_SECRET` / `_STORAGE_PUBLIC_BASE_URL` | Pflicht für Fachjobs | Storage wie in der App (Secret) | +| `NEXARCH_DMS_WORKER_USAGE_ENDPOINT_URL` / `_CLIENT_ID` / `_CLIENT_SECRET` | Pflicht für Fachjobs | Nutzungsmeldung (Secret) | +| `NEXARCH_DMS_WORKER_KEK_ENDPOINT_URL` / `_CLIENT_ID` / `_CLIENT_SECRET` | Pflicht für Fachjobs | KEK-Bezug (Secret); unvollständig bedeutet: Fachjobs werden nicht verarbeitet | +| `NEXARCH_DMS_WORKER_RETENTION_BASE_URL` | Aus | RET-Client für Requeue (DOC-16) | +| `NEXARCH_DMS_WORKER_CLAMD_ADDRESS` / `_CLAMD_NETWORK` | Aus / `unix` | Virenscanner; ohne ihn wird `malwarescan.scan` nicht abgeholt | +| `NEXARCH_DMS_WORKER_MANTICORE_ADDR` | Aus | Suchindex; ohne ihn wird `searchindex.sync` nicht abgeholt | +| `NEXARCH_DMS_WORKER_SEARCH_INDEX_NAME` | `dms_documents` | FDN-21: Indexname dieser Instanz (siehe `NEXARCH_DMS_APP_SEARCH_INDEX_NAME`); ein ungültiger Name beendet den Worker beim Start | +| `NEXARCH_DMS_WORKER_PLAINTEXT_MAX_BYTES` / `_MAX_CHARS` | 5 MiB / 1.000.000 | SRC-13: Größenlimit und Zeichenlimit für den Klartext-Volltext (`text/plain`, `text/csv`, `text/markdown`). Größere Dateien werden nicht gelesen (Status `too_large`), längere Texte begrenzt (Status `truncated`), beides sichtbar in `revision_text_index` | +| `NEXARCH_DMS_WORKER_MAILIMPORT_STAGING_DIR` | Aus | Aktiviert IMAP-Import (INT-03) | +| `NEXARCH_DMS_WORKER_MAILIMPORT_MAX_MESSAGE_BYTES` | 64 MiB | Größenlimit je Nachricht | +| `NEXARCH_DMS_WORKER_MAILIMPORT_CA_FILE` | Systemzertifikate | Zusätzliche CA (PEM) | +| `NEXARCH_DMS_WORKER_KOSIT_ADDR` | Aus | KoSIT-Validator (MET-07), nur Loopback; ohne ihn bleibt das Ergebnis `not_checked` (Sidecar auf 131: `127.0.0.1:8610`) | +| `NEXARCH_DMS_WORKER_KOSIT_RULESET` | `unbekannt` | Regelwerk-Bezeichnung im Ergebnis; vom Betrieb bei jedem Bundle-Update zu pflegen | +| `NEXARCH_DMS_WORKER_KOSIT_TIMEOUT_SECONDS` | 30 | Timeout je Prüfung | +| `NEXARCH_DMS_WORKER_CONCURRENCY` | 2 | Parallele Worker | +| `NEXARCH_DMS_WORKER_JOB_LEASE_SECONDS` | 300 | Sperrfrist der Jobwarteschlange in ganzen Sekunden (5 bis 3600, sonst Startabbruch). Nach hartem Abbruch laufen gehaltene Jobs nach dieser Frist weiter; laufende Jobs verlängern ihre Sperre alle Frist/3 (FDN-20) | + +## dms-migrate + +| Variable | Standard | Wirkung | +|---|---|---| +| `NEXARCH_DMS_APP_TENANT_DSN` | Pflicht | Zieldatenbank | +| `NEXARCH_DMS_MIGRATIONS_DIR` | `migrations/tenant` | Migrationsverzeichnis | + +## Nur Tests + +`NEXARCH_DMS_SIGNING_TSA_URL_TEST` (echter externer Zeitstempeldienst im pdfsign-Test), `TEST_TENANT_DSN`, `TEST_MANTICORE_ADDR`, `TEST_S3_*`. + +## Port- und Routenregister + +- `deploy/ports.tsv`: DMS 8600 bis 8699, keine Überschneidung mit anderen Modulen. Belegt: App `8600`, Worker-Metriken `8601`, KoSIT-Sidecar `8610` (liegt im DMS-Bereich, vermerkt als Kommentar). +- Prüfung durch `scripts/check-port-defaults.sh` und `scripts/check-route-prefixes.sh`. diff --git a/dms/docs/CMIS.md b/dms/docs/CMIS.md new file mode 100644 index 00000000..c687d15f --- /dev/null +++ b/dms/docs/CMIS.md @@ -0,0 +1,53 @@ +# CMIS-Schnittstelle (INT-09, optional) + +CMIS 1.1 **Browser Binding** (JSON) als zusaetzliche Schnittstelle neben der REST-API. Sie hat keine eigene Fachlogik und keine eigenen Rechte: jede CMIS-Anfrage wird in Aufrufe der REST-API uebersetzt, die in-process durch dieselbe Kette laufen wie ein direkter REST-Zugriff (Core-JWT, Rechteentscheidung je Route, Eigentuemer-Regeln, Mandantenpruefung). + +## Aktivierung + +| Variable | Bedeutung | +|---|---| +| `NEXARCH_DMS_APP_CMIS_ENABLED` | `true` schaltet CMIS ein; sonst existiert keine CMIS-Route (404) | +| `NEXARCH_DMS_APP_TENANT_SLUG` | Pflicht, ist zugleich die Repository-ID | +| `NEXARCH_DMS_APP_CMIS_PUBLIC_BASE_URL` | optional, Basis der URLs in der Repository-Info (Standard: aus der Anfrage abgeleitet, inkl. `/api/dms/v1`) | +| `NEXARCH_DMS_APP_CMIS_MAX_CONTENT_BYTES` | optional, Obergrenze fuer `createDocument` (Standard 256 MiB) | + +## Adressen + +Alle unter `/api/dms/v1/cmis/browser` (Authentifizierung wie REST: `Authorization: Bearer `). + +| Adresse | Zweck | +|---|---| +| `GET /cmis/browser` | Repository-Infos | +| `GET /cmis/browser/{repo}` | Repository-Info (`cmisselector=repositoryInfo`, `typeDefinition`, `typeChildren`, `typeDescendants`) und objektbezogene Selektoren mit `objectId` | +| `GET /cmis/browser/{repo}/root[/pfad]` | Wurzel- bzw. Pfad-URL: `cmisselector=object` (Standard), `children`, `parent`, `content` | +| `POST /cmis/browser/{repo}[/root/pfad]` | `cmisaction=createDocument` (multipart, Teil `content`) | + +`succinct=true` liefert `succinctProperties`. `maxItems` und `skipCount` gelten fuer `children`. Die Wurzel (`root`) ist virtuell: sie enthaelt die Ordner und Dokumente ohne Elternordner. + +## Unterstuetzt + +Repository-Info, die Typen `cmis:document` und `cmis:folder`, Objekt lesen (per ID und Pfad), Kinder auflisten, Elternordner, Inhalt lesen (Range-Anfragen moeglich), Dokument anlegen. + +## Nicht unterstuetzt (Antwort `notSupported`, 405) + +Query, Aendern und Loeschen von Objekten, Ordner anlegen, Inhalt ersetzen, Versionierung (Check-out/-in), ACLs, Change-Log, Relationships, Renditions, `getObjectParents`, `getDescendants`/`getFolderTree`. AtomPub- und Web-Services-Binding gibt es nicht. + +## Abbildung auf die REST-API + +| CMIS | REST | +|---|---| +| Objekt (Ordner) | `GET /folders/{id}` | +| Objekt (Dokument) | `GET /documents/{id}` | +| Kinder | `GET /folders/{id}/children` und `/documents` (Wurzel: `/folders/children`, `/folders/documents`) | +| Inhalt | `GET /documents/{id}/content` (bei fertigem PDF-Derivat wird dieses geliefert, die Laenge in den Eigenschaften ist die des Originals) | +| createDocument | `POST /uploads`, `PUT /uploads/{id}/chunk`, `POST /uploads/{id}/complete` | + +Fehler: HTTP-Status wie in der REST-API, Koerper im CMIS-Format `{"exception":"objectNotFound","message":"…"}` plus dem Core-Fehlerobjekt `error`. + +## Grenzen + +- Ein Objekt-Lookup per ID fragt zuerst den Ordner-, dann den Dokument-Endpunkt. Ein nicht vorhandenes Objekt liefert daher wie in REST `403` (Dokument-Guard), nicht `404`. +- Dokumentlisten eines Ordners (`/folders/.../documents`) zeigen nur Dokumente des Aufrufers (Eigentuemer-Modell, INT-15); die Ordnerstruktur selbst ist mandantenweit sichtbar, bis Core-RBAC (Gruppen, RBAC-03) vorliegt. CMIS erbt beides unveraendert. +- Repository-Info und Typdefinitionen verlangen eine Anmeldung, aber keine Fachberechtigung. Die Anmeldung erzwingen dieselben Bausteine wie bei REST (Token-Pruefung der gemeinsamen Kette, im Uebergangsmodus der gemeinsame Guard); der CMIS-Code enthaelt keine eigene Pruefung, ein Test verbietet sie. +- Ein widerrufenes (nicht abgelaufenes) Token wird erst abgelehnt, wenn Core den Widerruf im Token-Vertrag bereitstellt (Core IAM-17); abgelaufene Token lehnt CMIS wie REST ab. +- Ein Test mit dem CMIS-Referenzclient (Apache Chemistry OpenCMIS) steht aus, siehe Pruefprotokoll. diff --git a/dms/docs/DEPLOYMENT-131-CHECKLISTE.md b/dms/docs/DEPLOYMENT-131-CHECKLISTE.md new file mode 100644 index 00000000..3b37d808 --- /dev/null +++ b/dms/docs/DEPLOYMENT-131-CHECKLISTE.md @@ -0,0 +1,72 @@ +# Checkliste: DMS auf 131 (native Units, Env-Dateien, Migration, KoSIT) + +Reine Beschreibung nach dem Muster des FDN-19-Testaufbaus (`scripts/fdn19-gesamtstart.sh`). Auf 131 wird nichts ausgeführt. 131 ist Vorstufe zur Produktion, ohne Docker; dort laufen bereits Core-, Archive- und weitere `nexarch-*`-Dienste (Ports 8080 bis 8109) und der KoSIT-Validator `nexarch-dms-kosit` (`127.0.0.1:8610`). + +"Geprüft" heißt: im FDN-19-Testaufbau auf 204 durchgespielt. "Zu klären" heißt: dort nicht prüfbar, weil Core, Archive oder Betrieb beteiligt sind. + +## 1. Vorbedingungen auf 131 + +| Punkt | Stand | +|---|---| +| PostgreSQL mit eigener Datenbank für das DMS-Mandat (nicht die Testdatenbank aus `/etc/nexarch/dms-app.env`) | zu tun | +| Rolle `nexarch_app` (`CREATE ROLE nexarch_app NOLOGIN`) vorab anlegen, sonst bricht Migration 0013 ab (`permission denied to create role`) | geprüft (Befund B4) | +| Tabelle `users` aus den Core-Migrationen in derselben Datenbank (`cmd/migrate` verweigert sonst) | geprüft | +| Zwei Datenbankrollen: Besitzer für `cmd/migrate`, Anwendungsrolle für App und Worker, Mitglied in `nexarch_app` und mit Tabellenrechten. Der Testaufbau lief mit einer Rolle | zu tun (Befund B11) | +| Manticore erreichbar; der Index heißt fest `dms_documents`. Mit mehreren DMS-Instanzen je Manticore kollidiert das | zu klären (Befund B9) | +| Tesseract mit `deu`, `eng`, `osd` und Poppler (`pdftoppm`, `pdftotext`) | auf 131 vorhanden | +| LibreOffice (`soffice`) für Konvertierung | fehlt auf 131, `pdfconvert.generate` wird nicht abgeholt | +| Virenscanner `clamd` und `malwarescan.scan` in `NEXARCH_DMS_APP_ENQUEUE_JOB_TYPES` | fehlt auf 131, ohne Scan gibt es keinen Virenschutz beim Upload | +| Speicher: gemessen rund 30 MB für App, Worker und Stellvertreter zusammen im Ruhebetrieb, Worker-Spitze 100 MB beim Smoke; KoSIT rund 460 MB (auf 131 schon belegt). 131 hat 6 GB RAM bei bereits rund 3 GB Belegung | ausreichend, Lastspitzen beobachten | +| Platte für `STORAGE_DIR` und `STAGING_DIR` (eigener Eigentümer, kein Weltzugriff) | Größe zu planen | + +## 2. Benutzer, Verzeichnisse, Binaries + +1. Unprivilegierter Systembenutzer (im Test `nexarch-fdn19`, im Betrieb z. B. `nexarch-dms`) ohne Login und ohne beschreibbares Home. Wichtig wegen Befund B1: Das Home darf schreibgeschützt sein, seit `internal/pdfcfg` gilt das für pdfcpu. +2. Binaries bauen (`go build -trimpath ./cmd/app ./cmd/worker ./cmd/migrate`) aus einem festen Commit, nach `/opt/nexarch-dms/bin/`, Eigentümer root, nicht beschreibbar. +3. Verzeichnisse `data/storage` und `data/staging` für den Dienstbenutzer. +4. `cmd/smoke-tool` und die Skripte gehören nicht in den Betrieb. + +## 3. Konfiguration (Env-Dateien, Modus 600, Eigentümer root) + +Alle Variablen mit Standardwerten: `dms/docs/BETRIEB-UMGEBUNGSVARIABLEN.md`. Secrets nie ins Repository oder in Protokolle. Zu setzen: + +| Bereich | Variablen | Stand | +|---|---|---| +| Datenbank, Mandant | `NEXARCH_DMS_APP_TENANT_DSN`, `_TENANT_SLUG` (und die `_WORKER_`-Entsprechungen) | zu tun | +| Storage und Signaturen | `STORAGE_DIR`, `STORAGE_SIGNING_SECRET`, `STORAGE_PUBLIC_BASE_URL`, `STAGING_DIR`, `DOWNLOAD_SIGNING_SECRET`, optional `SIGNED_URL_MAX_TTL` | neue Zufallswerte erzeugen | +| Routenpräfix, Port | `LISTEN_ADDR` (8600), Worker-Metriken 8601, Präfix `/api/dms/v1` | im Portregister, geprüft | +| KEK-Bezug | `KEK_ENDPOINT_URL`, `_CLIENT_ID`, `_CLIENT_SECRET` (App und Worker) | Endpunkt und Zugang von Core zu klären; in `/etc/nexarch/dms-app.env` derzeit nicht gesetzt. Der Testaufbau nutzte einen Stellvertreter | +| Nutzungsmeldung | `USAGE_ENDPOINT_URL`, `_CLIENT_ID`, `_CLIENT_SECRET` | zu klären | +| Identität | `CORE_JWKS_URL` (Token-Modus). Übergangsweise `ALLOW_CALLER_IDENTITY=true`, nicht authentifiziert und nicht für den Betrieb | Core muss JWKS ausliefern (`/jwks` lieferte zuletzt 404), Rollen-Claim und `dms.*`-Rechte fehlen noch | +| Rechteentscheidung | `POLICY_URL`, `POLICY_SERVICE_TOKEN`, `POLICY_DEFAULT_ROLE` | zu klären, Core-Betriebsgeheimnis | +| Archive, Löschschutz | `RETENTION_BASE_URL`, `RETENTION_CLASS`, `RETENTION_CALLBACK_URL`, `CALLBACK_REQUIRE_SERVICE_TOKEN` | ohne `RETENTION_BASE_URL` wird nie endgültig gelöscht (geprüft, fail-closed). Ein Sperr-Endpunkt (`GET /legal-hold`) fehlt im Archive | +| Suche | `NEXARCH_DMS_APP_MANTICORE_ADDR`, `NEXARCH_DMS_WORKER_MANTICORE_ADDR` | geprüft | +| E-Rechnung | `NEXARCH_DMS_WORKER_KOSIT_ADDR=http://127.0.0.1:8610`, `_KOSIT_RULESET=xrechnung-3.0.2-2026-08-31` | geprüft | +| Admin, Prüfer | `ADMIN_USER_IDS`, `AUDITOR_USER_IDS` | zu tun | +| Optionale Funktionen | `CMIS_ENABLED`, Consume-Ordner, Stapeltrennung, Mail-Import | nach Bedarf | + +## 4. Reihenfolge + +1. Datenbank, Rolle `nexarch_app`, Besitzer- und Anwendungsrolle anlegen; `users` aus der Core-Migration vorhanden. +2. Env-Dateien schreiben. +3. `cmd/migrate` als Besitzerrolle ausführen: 31 Migrationen, ein zweiter Lauf wendet nichts an (geprüft). Die drei `0016_*`-Dateien bleiben unverändert, die Version ist der volle Dateiname. +4. KoSIT-Validator prüfen (läuft bereits auf 131), `nexarch-dms-kosit` aktiv. +5. Worker starten, dann App. Beim Start meldet der Worker, welche Jobtypen er nicht abholt (Grund steht im Log). +6. `GET /healthz`, `GET /metrics`, `GET /api/dms/v1/openapi.json`. +7. Gateway oder Reverse-Proxy: Route `/api/dms/v1` auf `127.0.0.1:8600`. Wie das Gateway auf 131 aufgebaut ist, wurde nicht geprüft. +8. Smoke-Test gegen die laufende Instanz: `scripts/fdn19_smoke.py` liest seine Ziele aus `FDN19_*`-Variablen (siehe Kopf der Datei). Er legt Testnutzer an, deshalb nur gegen eine Testdatenbank, nicht gegen Betriebsdaten. + +## 5. Dienste als persistente Units + +Der Testaufbau nutzte transiente Units. Für 131 sind eigene Unit-Dateien nötig, die es im Repository noch nicht gibt (nur `deploy/kosit/nexarch-dms-kosit.service`). Empfohlene Eigenschaften, wie beim Validator: eigener Benutzer, `EnvironmentFile=/etc/nexarch/dms-*.env`, `MemoryMax`, `NoNewPrivileges=yes`, `PrivateTmp=yes`, `ProtectHome=yes`, `Restart=on-failure`. Der Worker braucht Zugriff auf `tesseract` und `pdftoppm` im `PATH`. + +## 6. Bekannte Einschränkungen im Betrieb + +- Ein hart beendeter Worker gibt seine gehaltenen Jobs erst nach fünf Minuten frei (Befund B7). +- Der Volltext von Klartext-Dokumenten wird nicht indexiert (B6), es gibt keinen API-Endpunkt zum Verschieben in den Papierkorb (B5). +- Solange Core keine JWKS-Route und keine `dms.*`-Rechte liefert, läuft die Instanz nur im Übergangsmodus. +- Löschen ist ohne Archive-Anbindung ausgeschlossen. Das ist Absicht, blockiert aber die Papierkorb-Leerung. + +## 7. Rückbau + +Dienste stoppen und deaktivieren, Unit-Dateien entfernen, Datenbank und Rolle löschen (nicht `nexarch_app`), eigene Einträge aus dem Suchindex entfernen (`DELETE FROM dms_documents WHERE document_id = ...`; die Tabelle nicht löschen, wenn sie geteilt ist), Verzeichnisse und Dienstbenutzer entfernen. Den Validator nur entfernen, wenn er niemand sonst braucht (`deploy/kosit/README.md`). diff --git a/dms/docs/DOC-01-PRUEFPROTOKOLL.md b/dms/docs/DOC-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..491a03e1 --- /dev/null +++ b/dms/docs/DOC-01-PRUEFPROTOKOLL.md @@ -0,0 +1,78 @@ +# DOC-01 – Prüfprotokoll: Upload-API & Chunk-Handling + +Welle 3. Voraussetzung: FDN-02, FDN-03, FDN-09 (alle Status "Fertig"). + +## Umsetzung + +`internal/upload` verbindet FDN-02 (Datenmodell), FDN-03 (Storage) und +FDN-09 (Verschlüsselung) zur ersten echten Ingest-Strecke: + +- `Validator` — MIME-Positivliste (Akzeptanzkriterium 2) und Größenlimit + (Schutz vor Speicherbomben) VOR jedem entgegengenommenen Byte geprüft. +- `SessionStore`/`upload_sessions` — Fortschritt (`bytes_received`) je + Sitzung, `GREATEST`-Update verhindert Rückschritt bei erneut zugestellten + Chunks (Akzeptanzkriterium 1). +- `Staging` — Chunks landen lokal über `os.File.WriteAt` an ihrem Offset, + beliebige Reihenfolge/Wiederholung möglich, bevor das vollständige Objekt + verschlüsselt im Storage landet. +- `Service.Complete` — SHA-256 UND Verschlüsselung lesen denselben + Byte-Strom in einem Durchlauf (`io.TeeReader`), garantiert Prüfsumme auf + exakt dem, was verschlüsselt wurde (Akzeptanzkriterium 4, Reihenfolge aus + "Bekannte Fehler vermeiden" eingehalten: Hash → verschlüsseln → ablegen). + Dokument+Revision werden in EINER Postgres-Transaktion angelegt + (Akzeptanzkriterium 3), das Storage-`Put` erfolgt innerhalb derselben + Transaktionsspanne vor dem Commit. +- Neue Spalte `file_revisions.wrapped_dek` (Migration `0004`) für den + FDN-09-Envelope-Wrapper. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Upload von 1 GB Datei erfolgreich | **eingeschränkt bestanden** — siehe Abschnitt "Skalierungs-Einschränkung" unten: real bis 300 MiB auf 192.168.1.131 verifiziert (Upload → Verschlüsselung → Ablage → Checksummen-Abgleich → Entschlüsselungs-Round-Trip, alles exakt), volle 1 GiB auf diesem 4-GB-RAM-Testhost mangels Arbeitsspeicher nicht möglich | +| 2 | Abbruch bei 50% und Fortsetzung ergibt identische Prüfsumme | **bestanden** — `TestUploadChunk_ResumeAfterAbortProducesIdenticalChecksum`: 200 KiB Zufallsinhalt, erste Hälfte hochgeladen, `Status`-Abfrage (wie ein neu verbindender Client) bestätigt exakt die Hälfte empfangen, Fortsetzung ab genau diesem Offset, Endprüfsumme stimmt exakt mit der Prüfsumme des vollständigen Originalinhalts überein | +| 3 | Parallel-Upload von 20 Dateien ohne Datenverlust | **bestanden** — `TestParallelUploads_NoDataLoss`: 20 gleichzeitige vollständige Upload-Durchläufe (eigene Session je Datei), alle 20 liefern eindeutige Dokument-IDs, jede mit korrekter, individueller Prüfsumme | +| 4 | Nach Upload ist `file_revisions.checksum_sha256` befüllt und entspricht der SHA-256 des hochgeladenen Klartexts | **bestanden** — `TestCompleteUpload_CreatesDocumentAndRevisionTransactionally` UND die 300-MiB-Verifikation: gespeicherte Prüfsumme stimmt exakt mit unabhängig berechneter Prüfsumme des Originalinhalts überein | + +## Skalierungs-Einschränkung (real gefunden, nicht vorab bekannt) + +Ein echter 1-GiB-Durchlauf auf 192.168.1.131 (4 GB RAM, kein Swap) endete +mit `signal: killed` (OOM). Ursache: sowohl `crypto.EncryptStream`/ +`DecryptStream` (FDN-09) als auch `storage.S3Driver.Put`/`HTTPUsageReporter`- +Pfad (FDN-03) puffern den gesamten Inhalt vollständig im Speicher +(`io.ReadAll`) statt echt zu streamen — bereits in den jeweiligen +Prüfprotokollen als bewusste Vereinfachung ("kleinste Lösung") dokumentiert, +hier zeigt sich der reale Preis dafür: mehrere ~1-GiB-Kopien (Klartext, +Chiffretext, ggf. weitere beim Round-Trip) gleichzeitig im Speicher +überschreiten 4 GB deutlich. Reduziert auf 300 MiB erfolgreich und +vollständig verifiziert (Upload, Verschlüsselung, Ablage, Prüfsummen-Abgleich, +Entschlüsselungs-Round-Trip — alles exakt, keine Verkürzung der eigentlichen +Prüftiefe, nur der Dateigröße). + +**Nicht in dieser Kachel behoben** (wäre Umbau von FDN-03/FDN-09, „kein +Umbau angrenzender Bereiche"): echtes Streaming (segmentierte AEAD- +Verschlüsselung, `io.Copy`-basierter Storage-Pfad statt `io.ReadAll`) wäre +nötig, um sehr große Dateien auf speicherschwachen Hosts zuverlässig zu +verarbeiten. Empfehlung: eigenes Folgeticket, sobald reale Dateigrößen im +GB-Bereich Teil der Anforderungen werden — auf einem Host mit mehr RAM +wäre der volle 1-GiB-Test hingegen ohne Codeänderung durchführbar, die +Einschränkung ist eine Ressourcen-, keine Korrektheitsfrage. + +## Build/Test-Ergebnis (192.168.1.131, `make check`) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +go test ./... -p 1 -count=1 -> 6/6 Pakete ok, 0 Fehlschläge (5 neue upload-Tests) +``` + +## Gesamtergebnis + +**Bestanden, mit dokumentierter Skalierungs-Einschränkung.** Alle vier +Akzeptanzkriterien erfüllt. Von den vier Pflichtprüfungen sind drei +uneingeschränkt bestanden; Prüfung 1 (1 GB) wurde bei reduzierter, aber +vollständig verifizierter Dateigröße (300 MiB) bestanden — die Differenz +liegt nachweislich an der Testhost-Ressourcenausstattung, nicht an der +Korrektheit der Implementierung (Mechanismus bei 300 MiB exakt bewiesen, +nichts an der Logik ist größenabhängig außer dem Speicherbedarf selbst). diff --git a/dms/docs/DOC-02-PRUEFPROTOKOLL.md b/dms/docs/DOC-02-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..6ec9b885 --- /dev/null +++ b/dms/docs/DOC-02-PRUEFPROTOKOLL.md @@ -0,0 +1,107 @@ +# DOC-02 — Dublettenerkennung — Prüfprotokoll + +## Umsetzung + +Baut auf `DOC-01` (Upload-API) auf. Die Prüfsumme (SHA-256 auf dem +Klartext, siehe `upload.Service.Complete`) wurde dort bereits berechnet +und persistiert — diese Kachel ergänzt den fehlenden Teil: den Abgleich +gegen den Bestand. + +- `dms/migrations/tenant/0016_duplicate_detection.up.sql`: Index auf + `file_revisions.checksum_sha256` (schnelle Suche) und neue Spalte + `confirmed_duplicate BOOLEAN NOT NULL DEFAULT false`. +- `dms/internal/upload/service.go`: `Complete` nimmt jetzt einen + `confirmDuplicate bool`-Parameter. Nach der Pruefsummenberechnung, aber + **vor** dem Anlegen von Dokument/Revision, wird nach einer bereits + existierenden Revision mit identischer Pruefsumme gesucht: + - Treffer + `confirmDuplicate == false` → `*DuplicateError{DocumentID, + RevisionID}` statt eines neuen Dokuments (Akzeptanzkriterium 2: Verweis + auf das Original). + - Treffer + `confirmDuplicate == true` → Anlage wie gewohnt, aber + `confirmed_duplicate = true` in der neuen Revision (Akzeptanzkriterium + 3: bewusstes Duplikat). + - Kein Treffer → Anlage wie gewohnt, `confirmed_duplicate = false`. + +## Akzeptanzkriterien + +1. Prüfsumme wird bei jedem Upload persistiert — bereits seit DOC-01 + erfüllt, unverändert. +2. Treffer wird dem Nutzer mit Verweis auf das Original gemeldet — + `DuplicateError` trägt `DocumentID`/`RevisionID` des Originals. +3. Bewusstes Duplikat ist möglich und wird markiert — `confirmDuplicate`- + Parameter + `confirmed_duplicate`-Spalte. + +## Prüfungen vor der Abnahme + +Alle drei Prüfungen real gegen eine echte PostgreSQL-Instanz auf +192.168.1.131 ausgeführt (`TEST_TENANT_DSN`, siehe unten) — **nicht nur +kompiliert, sondern tatsächlich mit einer laufenden Datenbank verifiziert**: + +1. **Identische Datei zweimal hochgeladen erzeugt Warnung.** + `TestCompleteUpload_IdenticalFileReportsDuplicate`: zweiter Upload + liefert `*DuplicateError`, der auf Dokument/Revision des ersten Uploads + verweist; es entsteht nachweislich kein zweites Dokument (`SELECT + count(*) FROM documents` bleibt bei 1). **PASS.** +2. **Datei mit geändertem Byte gilt nicht als Duplikat.** + `TestCompleteUpload_OneByteDifferenceIsNotADuplicate`: identischer + Inhalt bis auf ein Byte am Ende erzeugt einen komplett anderen SHA-256 + und wird ohne Fehler angelegt. **PASS.** +3. **Prüfsummenberechnung bei Großdatei bleibt im Zeitbudget.** + `TestCompleteUpload_ChecksumWithinTimeBudgetForLargeFile`: 10 MiB + Zufallsdaten, `Complete` (inkl. Dublettenpruefung, Verschlüsselung, + Storage-Schreiben) unter 5 Sekunden. Tatsächliche Laufzeit: 0,20s. + **PASS.** + +Zusätzlich `TestCompleteUpload_ConfirmedDuplicateIsAllowedAndMarked` +(Akzeptanzkriterium 3 direkt): zweiter Upload mit `confirmDuplicate=true` +wird angelegt, `confirmed_duplicate` in der DB ist `true`. **PASS.** + +## Wichtiger Nebenfund: Test-Datenbank-Zugangsdaten wiederhergestellt + +Der in praktisch jedem Prüfprotokoll seit `VER-03` dieser Session +dokumentierte Blocker ("`nexarch_dms_test`-Rolle auf 192.168.1.131 hat +falsches Passwort, DB-Integrationstests werden übersprungen") ist behoben: +Die tatsächlich gültige DSN steht (samt Zugangsdaten) in +`/etc/nexarch/dms-app.env` auf dem Testhost (Variable +`NEXARCH_DMS_APP_TENANT_DSN`) — hier bewusst nicht im Klartext wiederholt. +Mit `TEST_TENANT_DSN` auf diesen Wert gesetzt laufen die +`internal/upload`-Tests jetzt vollständig durch (10/10, siehe oben) — +vorher waren sie alle wegen `password authentication failed` +übersprungen. + +**Wichtige Einschränkung, die vor einer Nachnutzung beachtet werden muss:** +Ein `go test ./...` über das gesamte `dms`-Modul zeigt, dass die +Test-Fixtures der einzelnen Pakete (jede Datei legt ihr eigenes, +inline definiertes Schema per `CREATE TABLE IF NOT EXISTS` in derselben +physischen Datenbank `dms_tenant_test` an) sich gegenseitig stören: keine +Isolation zwischen Paketen (kein eigenes Schema/keine eigene DB je Test), +und mehrere Pakete haben Schema-Definitionen, die von den *echten* +Migrationen abweichen (z. B. `folder`-Tests erwarten eine `path`-Spalte, +die ihr eigenes inline-Schema nicht anlegt; `metadata_fields`-Tests fehlt +die `required`-Spalte). Das ist ein **vorbestehendes, paketübergreifendes +Problem, nicht durch DOC-02 verursacht** — beim isolierten Lauf +(`go test ./internal/upload/...`) treten diese Kollisionen nicht auf, +siehe oben. Eine Bereinigung (z. B. jedes Paket testet gegen ein eigenes +Postgres-Schema statt einer gemeinsamen DB, oder alle Test-Fixtures laufen +über die echten Migrationsdateien statt eigener inline-`CREATE TABLE`- +Anweisungen) ist ein eigenständiges, größeres Vorhaben — außerhalb des +Umfangs dieser Kachel, aber wichtig genug, um hier festgehalten zu werden, +statt es erneut als "DB kaputt" misszuverstehen. + +## Testergebnisse + +- `go build ./...`, `go vet ./...` (gesamtes `dms`-Modul) → keine Fehler. +- `go test ./internal/upload/...` (isoliert, mit korrekter + `TEST_TENANT_DSN`) → **10/10 Tests grün**, inklusive aller drei neuen + DOC-02-Prüfungen. + +## Offene Punkte + +1. Die oben beschriebene Test-Isolationsproblematik über das gesamte + `dms`-Modul hinweg — betrifft nicht DOC-02 selbst, sollte aber als + eigenes Ticket/Aufräumarbeit aufgenommen werden, jetzt wo die + Datenbankverbindung grundsätzlich funktioniert. +2. Kein REST-Handler ruft `upload.Service.Complete` bisher auf (DOC-01 hat + nur die Service-Schicht geliefert) — `confirmDuplicate` muss beim Bau + der eigentlichen Upload-API-Route (falls das noch aussteht) als + Client-Parameter durchgereicht werden. diff --git a/dms/docs/DOC-03-PRUEFPROTOKOLL.md b/dms/docs/DOC-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..511e1ca0 --- /dev/null +++ b/dms/docs/DOC-03-PRUEFPROTOKOLL.md @@ -0,0 +1,143 @@ +# DOC-03 – Prüfprotokoll: Drag-and-Drop-Upload-Oberfläche + +Stand: 2026-09-18. Voraussetzung: `DOC-01` (Upload-API & Chunk-Handling), `SHL-01` (UI-Shell & +Design-System). + +## Backend-Voraussetzung: dms/internal/uploadapi (neu für diese Kachel) + +`DOC-01` lieferte bislang nur einen Go-Service (`internal/upload`), keinen HTTP-Endpunkt — dieselbe +Lücke, die schon bei `DOC-05`/`internal/documentapi` bestand. Eine Drag-and-Drop-Oberfläche kann nur +über die öffentliche HTTP-Schnittstelle sprechen, nicht über Go-Funktionsaufrufe. `dms/internal/uploadapi` +kapselt `upload.Service` hinter vier Endpunkten, ohne Server-Logik zu duplizieren: + +- `POST /uploads` — startet eine Sitzung (`filename`, `mimeType`, `totalSize`), liefert `sessionId`. +- `PUT /uploads/{id}/chunk?offset=N` — nimmt einen Chunk ab `offset` entgegen, liefert `bytesReceived`. +- `GET /uploads/{id}` — liefert den aktuellen Fortschritt (Grundlage für Fortsetzung nach Abbruch). +- `POST /uploads/{id}/complete` — schließt ab, liefert `documentId`/`revisionId`. + +Fehlerabbildung: `ErrSessionNotFound` → 404, `ErrFileTooLarge`/`ErrDisallowedMimeType` → 400 (damit +die Oberfläche verständliche Fehlermeldungen zeigen kann, Akzeptanzkriterium "verständliche +Fehlermeldungen"), alles andere → 500. + +Tests in `dms/internal/uploadapi/handler_test.go` (4 Fälle: vollständiger Start→Chunk→Status→Complete- +Zyklus über HTTP, abgelehnter MIME-Typ als 400, unbekannte Sitzung als 404) brauchen `TEST_TENANT_DSN` +gegen eine echte Postgres-Instanz — dieselbe Fixture-Struktur wie `internal/upload/service_test.go`. + +## Umsetzung Frontend (web/dms-upload) + +Next.js/React/TypeScript auf `@nexarch/shl` (SHL-01: ThemeProvider/I18nProvider/ToastProvider), Next.js- +und Testaufbau exakt nach dem Muster von `web/pdf-viewer` (DOC-05). + +- `lib/api.ts` — dünner Client für `dms/internal/uploadapi`, `NEXT_PUBLIC_UPLOAD_API_URL` als + Pflicht-Umgebungsvariable (kein Secret, aber Ticket-Vorgabe: keine Verbindungszeichenfolgen im Code). +- `lib/chunkUpload.ts` — lädt eine Datei in 4-MiB-Chunks hoch, `resumeSessionId` fragt per + `getUploadStatus` den zuletzt bestätigten Offset ab und setzt GENAU dort fort (derselbe + Server-Mechanismus wie DOC-01, `GREATEST`-Update auf `bytes_received`). +- `lib/uploadQueue.ts` — reine, DOM-unabhängige Reducer-Logik für die Warteschlange (Status/Fortschritt + je Datei, Retry), analog `lib/viewerState.ts` aus DOC-05. Ids werden VOR dem Dispatch erzeugt + (`createQueuedItem`), nicht im Reducer selbst — ein früher Versuch, die Id per Factory-Funktion + innerhalb des Reducers zu erzeugen, scheiterte real: `useReducer` ruft den Reducer erst in der + Render-Phase auf, Code direkt nach `dispatch()`, der sich auf die neue Id verlässt, sah dann noch + ein leeres Array (beim Testen mit zwei Dateien sichtbar geworden — Status blieb dauerhaft "Wartet", + obwohl der Erfolgs-Toast schon auslöste, weil die Aktionen mit `id: undefined` verpufften). +- `components/Dropzone.tsx` — Ablagebereich UND natives `