Compare commits
18 Commits
11cf9083fa
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f638df387 | ||
|
|
5156fe62da | ||
|
|
2a16dc2200 | ||
|
|
f5ad08cebd | ||
|
|
72005fc326 | ||
|
|
e0218337e7 | ||
|
|
31caa8f66a | ||
|
|
db373e51ce | ||
|
|
917b51299b | ||
|
|
d54636f57a | ||
|
|
15bf277bee | ||
|
|
da91c3e2c1 | ||
|
|
bec34b5988 | ||
|
|
8b08b39725 | ||
|
|
e929e5bf26 | ||
|
|
667343fa81 | ||
|
|
e9e386df85 | ||
|
|
bfe52c73eb |
320
CLAUDE.md
320
CLAUDE.md
@@ -1,16 +1,86 @@
|
|||||||
# Deklarix
|
# Deklarix
|
||||||
|
|
||||||
> Projekt für deklarix.de und deklarix.com
|
> Prüfung und Nachweis für Werbe-Content. Pre-Publish-Kennzeichnungsprüfung
|
||||||
|
> und revisionssichere Archivierung für Creator, Agenturen, Marken und
|
||||||
|
> Kanzleien. `deklarix.de` / `deklarix.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was das Projekt ist
|
||||||
|
|
||||||
|
Drei Funktionen, mehr nicht:
|
||||||
|
|
||||||
|
1. **Pre-Publish-Prüfung** — Caption, Standbild und Vertragslage rein,
|
||||||
|
Risikobewertung mit konkreten Korrekturvorschlägen raus.
|
||||||
|
2. **Beweissicherung** — jeder veröffentlichte Beitrag wird mit Hash und
|
||||||
|
Zeitstempel archiviert: Screenshot, Caption, sichtbare Kennzeichnung,
|
||||||
|
verknüpfter Vertrag, Briefing.
|
||||||
|
3. **Verantwortungsmatrix** — wer hat was vorgegeben, wer hat freigegeben,
|
||||||
|
wer schuldet welche Prüfung (Creator / Agentur / Marke).
|
||||||
|
|
||||||
|
**Wer nutzt es, wer zahlt:** Creator nutzen es täglich, zahlen wenig
|
||||||
|
(Reichweite/Referenz). Agenturen und Marken zahlen richtig — sie haften
|
||||||
|
über § 8 Abs. 2 UWG mit und brauchen Freigabe-Workflow plus
|
||||||
|
Nachweisarchiv. Kanzleien lizenzieren Deklarix als Whitelabel für ihre
|
||||||
|
Mandanten (zahlen für Software, nicht für Mandantenzugang); das
|
||||||
|
Kanzlei-Verzeichnis im Produkt bleibt kostenlos.
|
||||||
|
|
||||||
|
**Rollen von Anfang an.** Eine Submission gehört nicht einem Nutzer,
|
||||||
|
sondern hat Beteiligte mit Rollen (`creator`, `agentur`, `marke`,
|
||||||
|
`kanzlei`). Alle vier existieren im Datenmodell ab dem ersten Commit.
|
||||||
|
|
||||||
|
**Kein Demo-Shortcut.** Es wird direkt produktionsreif entwickelt —
|
||||||
|
keine reduzierte Zwischenstufe mit manuell eingetragenen Daten oder ohne
|
||||||
|
Login als Selbstzweck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kernprinzip: das Modell entscheidet nicht
|
||||||
|
|
||||||
|
**Das LLM extrahiert Fakten. Das Regelwerk in Go fällt das Urteil.**
|
||||||
|
|
||||||
|
- **Stufe 1 — Extraktion (Claude API):** Eingabe Caption plus Bild/Video,
|
||||||
|
Ausgabe striktes JSON (Plattform, Gegenleistung, Kennzeichnung
|
||||||
|
vorhanden/Kanal/Wortlaut/Position, Produktkategorie, Verlinkungen, ...).
|
||||||
|
Ist die Extraktion unsicher, liefert sie `unklar` — nie eine geratene
|
||||||
|
Bewertung.
|
||||||
|
- **Stufe 2 — Bewertung (Go, deterministisch):** Regelwerk als
|
||||||
|
versionierte YAML-Dateien (`rules/`). Jedes Finding referenziert
|
||||||
|
Regel-ID und Version (z. B. `WK-004 v3`). Regeln sind
|
||||||
|
plattformunabhängig — UWG/MStV gelten überall gleich, nur
|
||||||
|
Darstellungsdetails unterscheiden sich; die gehören in
|
||||||
|
Regelbedingungen/Extraktion, nie in separate Regelsätze pro Plattform.
|
||||||
|
|
||||||
|
**Absolute Regel:** Das Modell nennt niemals selbst Normen, Urteile oder
|
||||||
|
Randnummern. Fundstellen stehen ausschließlich in den kuratierten
|
||||||
|
YAML-Regeln. Eine halluzinierte Fundstelle ist in diesem Markt ein
|
||||||
|
Totalschaden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Backend | Frontend (geplant) |
|
Bewusst langweilig, ein Binary, lokal lauffähig:
|
||||||
|---------|---------------------|
|
|
||||||
| Go 1.26 | React 19, TypeScript 5.9 |
|
| Bereich | Wahl |
|
||||||
| net/http (Standard-Library-first) | Vite 8, Tailwind CSS |
|
|---|---|
|
||||||
| SQLite (geplant) | enterprise.css Design-System (enconf-Pattern) |
|
| Sprache | Go 1.26, Standard-Library wo möglich |
|
||||||
|
| Web | `net/http` + `html/template` + htmx, kein Frontend-Build, kein React |
|
||||||
|
| Datenbank | PostgreSQL über `pgx`, Migrationen über `golang-migrate` |
|
||||||
|
| PDF | serverseitig erzeugt |
|
||||||
|
| Zeitstempel | RFC-3161 von einer öffentlichen TSA |
|
||||||
|
| Extraktion | Claude API (nur Fakten-Extraktion, siehe oben) |
|
||||||
|
| Design | `design/enterprise.css` (Enterprise Light Theme, enconf-Pattern, Primärfarbe `#1677ff`) |
|
||||||
|
|
||||||
|
Kein Docker für die App selbst, kein React, keine Queue. Neue
|
||||||
|
Abhängigkeiten nur nach Rückfrage.
|
||||||
|
|
||||||
|
**Warum Postgres statt SQLite:** Das Produkt läuft von Anfang an für
|
||||||
|
hunderte gleichzeitige Nutzer über mehrere Mandanten (Creator, Agentur,
|
||||||
|
Marke, Kanzlei). Ein revisionssicheres Beweisarchiv braucht robuste
|
||||||
|
Concurrent-Writes sowie Point-in-Time-Recovery/Replikation — das ist mit
|
||||||
|
SQLites Single-Writer-Modell nicht sauber zu bauen. Gehostet wird
|
||||||
|
Managed Postgres in der EU (DSGVO).
|
||||||
|
|
||||||
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main`
|
**Pfad:** `/var/www/deklarix` | **Git:** `https://git.netcell-it.de/projekte/deklarix` | **Branch:** `main`
|
||||||
|
|
||||||
@@ -21,21 +91,27 @@
|
|||||||
```
|
```
|
||||||
/var/www/deklarix/
|
/var/www/deklarix/
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ └── server/
|
│ └── deklarix/
|
||||||
│ └── main.go # Entry Point
|
│ └── main.go # Entry Point, HTTP-Server
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # Konfiguration (env-basiert)
|
│ ├── extract/ # Claude-API-Aufruf, JSON-Schema, Validierung
|
||||||
│ ├── handler/ # HTTP Handler
|
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
||||||
│ └── middleware/ # Auth, Logging, CORS
|
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
||||||
|
│ ├── dossier/ # PDF-Erzeugung
|
||||||
|
│ ├── store/ # Postgres, Migrationen
|
||||||
|
│ └── web/ # Handler, Templates
|
||||||
|
├── rules/ # YAML-Regeln, versioniert im Git
|
||||||
|
├── testdata/
|
||||||
|
│ └── golden/ # Golden-Tests: Beispielbeiträge + erwartetes Ergebnis
|
||||||
├── design/
|
├── design/
|
||||||
│ └── enterprise.css # Gemeinsames Design-System (enconf-Basis)
|
│ └── enterprise.css # Design-System (enconf-Basis)
|
||||||
├── packaging/
|
├── packaging/
|
||||||
│ └── DEBIAN/
|
│ └── DEBIAN/
|
||||||
│ └── control.tmpl # .deb Package-Control-Template
|
│ └── control.tmpl # .deb Package-Control-Template
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── build.sh # Cross-Compile amd64 + arm64
|
│ ├── build.sh # Cross-Compile amd64 + arm64
|
||||||
│ ├── test.sh # Tests + vet + build-check
|
│ ├── test.sh # Tests + vet + build-check
|
||||||
│ └── release.sh # Vollständiger Release-Prozess
|
│ └── release.sh # Vollständiger Release-Prozess
|
||||||
├── go.mod
|
├── go.mod
|
||||||
├── go.sum
|
├── go.sum
|
||||||
└── CLAUDE.md
|
└── CLAUDE.md
|
||||||
@@ -43,13 +119,60 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Datenmodell
|
||||||
|
|
||||||
|
Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
|
||||||
|
`session`), mehr braucht der MVP nicht:
|
||||||
|
|
||||||
|
- `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
||||||
|
eigene Organisation); jede Submission gehört genau einem Account
|
||||||
|
- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort-
|
||||||
|
Hash, Rolle)
|
||||||
|
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst
|
||||||
|
eine echte Tabelle statt zustandsloser signierter Tokens, damit
|
||||||
|
Logout eine Sitzung wirklich beendet
|
||||||
|
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
|
||||||
|
- `asset` — Bild oder Datei, Pfad, SHA-256
|
||||||
|
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
|
||||||
|
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
|
||||||
|
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
|
||||||
|
kein Verweis auf die aktuelle Regel-YAML — die kann sich inzwischen
|
||||||
|
geändert haben)
|
||||||
|
- `evidence_package` — Dossier, Hash, Zeitstempel-Token, erzeugt am
|
||||||
|
- `participant` — Beteiligter an einer Submission mit Rolle
|
||||||
|
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
||||||
|
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
|
||||||
|
|
||||||
|
**Append-only.** Kein UPDATE auf `finding`, `extraction` oder
|
||||||
|
`evidence_package`. Korrekturen sind neue Zeilen mit Verweis auf die alte.
|
||||||
|
Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||||
|
|
||||||
|
**Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte
|
||||||
|
JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash.
|
||||||
|
Dossier-PDF enthält Beitrag, Caption, Kennzeichnung, Findings mit
|
||||||
|
Fundstellen, Vertrags- und Briefing-Bezug, Verantwortungsmatrix, alle
|
||||||
|
Hashes und das Zeitstempel-Token. Vollständiger Export muss für den
|
||||||
|
Nutzer jederzeit möglich sein — wer kündigt, bekommt sein Archiv.
|
||||||
|
|
||||||
|
**Auth/Mandantentrennung:** `POST /register` legt einen neuen Account
|
||||||
|
plus den ersten Nutzer darin an, `POST /login` meldet einen bestehenden
|
||||||
|
Nutzer an — beides setzt ein `deklarix_session`-Cookie (HttpOnly,
|
||||||
|
SameSite=Strict, Secure sobald über TLS erreicht). `GET /` verlangt eine
|
||||||
|
gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
|
||||||
|
`POST /veroeffentlichen` und `GET /dossier/{id}` verlangen sie ebenfalls
|
||||||
|
(sonst 401). Ein Beitrag eines fremden Accounts wird wie ein nicht
|
||||||
|
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
|
||||||
|
sonst würde die Antwort selbst verraten, dass die ID existiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Go Commands
|
## Go Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PATH=$PATH:/usr/local/go/bin # Immer setzen!
|
export PATH=$PATH:/usr/local/go/bin # Immer setzen!
|
||||||
|
|
||||||
# Entwickeln
|
# Entwickeln
|
||||||
go run ./cmd/server/
|
go run ./cmd/deklarix/
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
./scripts/test.sh
|
./scripts/test.sh
|
||||||
@@ -78,28 +201,56 @@ go vet ./...
|
|||||||
# 1. Alle Änderungen committen
|
# 1. Alle Änderungen committen
|
||||||
git add -p && git commit -m "feat: ..."
|
git add -p && git commit -m "feat: ..."
|
||||||
|
|
||||||
# 2. Release-Skript (macht Tests → Build → Tag → Push)
|
# 2. Release-Skript (macht Tests → .deb-Build → Gitea-Upload → Tag → Push)
|
||||||
./scripts/release.sh 1.2.0
|
./scripts/release.sh 1.2.0
|
||||||
|
|
||||||
# Danach liegt in dist/:
|
# Danach liegt in dist/:
|
||||||
# deklarix_1.2.0_amd64
|
# deklarix_1.2.0_amd64.deb
|
||||||
# deklarix_1.2.0_arm64
|
# deklarix_1.2.0_arm64.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
### Was das Release-Skript tut
|
### Was das Release-Skript tut
|
||||||
1. Prüft: sauberer Git-Status (keine uncommitted changes)
|
1. Prüft: sauberer Git-Status (keine uncommitted changes)
|
||||||
2. Führt `./scripts/test.sh` aus (vet + race tests + build-check)
|
2. Führt `./scripts/test.sh` aus (vet + race tests + build-check, braucht `DATABASE_URL`)
|
||||||
3. Kompiliert für `linux/amd64` und `linux/arm64`
|
3. Baut `.deb`-Pakete für `linux/amd64` und `linux/arm64` (`scripts/build.sh`)
|
||||||
4. Setzt Git-Tag `v<version>` mit Annotierung
|
4. Lädt die Pakete in Giteas Debian-Paketregistrierung hoch (Kanal `testing`,
|
||||||
5. Pusht `main` + Tag nach `origin`
|
Suites `bookworm` + `trixie`) — siehe „Deployment“ unten
|
||||||
|
5. Setzt Git-Tag `v<version>` mit Annotierung
|
||||||
|
6. Pusht `main` + Tag nach `origin`
|
||||||
|
|
||||||
|
### Deployment (apt, Infrastruktur-Standard wie bei enconf)
|
||||||
|
|
||||||
|
Updates laufen ausschließlich über ein apt-Paket, nie über manuelles
|
||||||
|
Kopieren eines Binaries. `.deb`-Pakete landen in Giteas eingebauter
|
||||||
|
Debian-Paketregistrierung unter der Gitea-Org `projekte` (gleicher
|
||||||
|
Mechanismus wie beim Schwesterprojekt enconf-webpanel). Zielserver
|
||||||
|
richten sich einmalig ein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/apt/keyrings
|
||||||
|
sudo curl -fsSL https://git.netcell-it.de/api/packages/projekte/debian/repository.key \
|
||||||
|
-o /etc/apt/keyrings/deklarix-gitea.asc
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $(lsb_release -cs) testing" \
|
||||||
|
| sudo tee /etc/apt/sources.list.d/deklarix.list
|
||||||
|
sudo apt-get update && sudo apt-get install deklarix
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach: `apt upgrade` für Updates, `systemctl {start,stop,status} deklarix`
|
||||||
|
für den Dienst. Konfiguration liegt in `/etc/deklarix/deklarix.env`
|
||||||
|
(aus `deklarix.env.example` beim Erstinstall kopiert, `DATABASE_URL` ist
|
||||||
|
dort absichtlich auskommentiert — der Dienst startet erst, wenn sie
|
||||||
|
gesetzt ist; `postinst` startet ihn sonst bewusst nicht, um keine
|
||||||
|
Crash-Restart-Schleife gegen einen Platzhalter-Host zu erzeugen).
|
||||||
|
Aktuell gibt es nur den Kanal `testing` (kein `stable` — es gibt noch
|
||||||
|
keine Kunden, für die eine Trennung nötig wäre).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing-Pattern
|
## Testing-Pattern & Qualitätssicherung
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Datei: internal/handler/health_test.go
|
// Datei: internal/web/health_test.go
|
||||||
package handler_test
|
package web_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -117,30 +268,57 @@ func TestHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Tests liegen neben dem Code: `handler/foo_test.go`
|
- Tests liegen neben dem Code: `paket/foo_test.go`
|
||||||
- Package: `package foo_test` (Black-Box-Test) oder `package foo` (White-Box)
|
- Package: `package foo_test` (Black-Box-Test) oder `package foo` (White-Box)
|
||||||
- Race-Detector immer an: `go test -race ./...`
|
- Race-Detector immer an: `go test -race ./...`
|
||||||
- Tabellenbasierte Tests für mehrere Inputs
|
- Tabellenbasierte Tests für mehrere Inputs
|
||||||
|
|
||||||
|
**DATABASE_URL und Testsystem.** `internal/store` hat Integrationstests
|
||||||
|
gegen echtes Postgres (u. a. die Append-only-Garantie). Ohne
|
||||||
|
`DATABASE_URL` überspringt `go test` diese Tests einfach — praktisch für
|
||||||
|
schnelle lokale Iteration ohne lokales Postgres. `./scripts/test.sh`
|
||||||
|
(und damit `release.sh`) ist strenger: es bricht ohne `DATABASE_URL` mit
|
||||||
|
Fehler ab, statt die Append-only-Garantie still zu überspringen. Es
|
||||||
|
zieht dafür aber **kein** eigenes Postgres hoch (kein Docker-Zwang auf
|
||||||
|
dem lokalen Dev-Rechner) — das ist Aufgabe des dedizierten Testsystems,
|
||||||
|
dort muss `DATABASE_URL` vorkonfiguriert sein.
|
||||||
|
|
||||||
|
**Golden-Test-Suite ist das eigentliche Asset des Projekts, nicht die
|
||||||
|
UI.** 30 bis 50 Beispielbeiträge in `testdata/golden/`, abgeleitet aus
|
||||||
|
veröffentlichten Urteilen und Beanstandungen der Medienanstalten, jeweils
|
||||||
|
mit erwarteten Findings. Jede Regeländerung läuft gegen die komplette
|
||||||
|
Suite. Eine Regeländerung, die ein Golden-Ergebnis kippt, wird nicht
|
||||||
|
gemerged, ohne dass der erwartete Wert bewusst und dokumentiert angepasst
|
||||||
|
wird. Extraktion wird separat getestet: fixierte Eingaben, geprüfte
|
||||||
|
JSON-Ausgabe.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Design-System
|
## Leitplanken, die dauerhaft gelten
|
||||||
|
|
||||||
Das Frontend folgt dem **Enterprise Light Theme** aus enconf (`design/enterprise.css`).
|
- **Keine Rechtsdienstleistung.** Deklarix ist ein Werkzeug, keine
|
||||||
|
Rechtsberatung. Keine Formulierung darf eine individuelle rechtliche
|
||||||
- **Primärfarbe:** `#1677ff` (Blau)
|
Bewertung suggerieren. Auf jedem Screen mit einem Ergebnis steht ein
|
||||||
- **Sidebar:** Dunkel (`#0B1426` → `#101D33`) mit weißen Icons
|
entsprechender Hinweis. Keine Erfolgsaussichten-Aussagen.
|
||||||
- **Body:** `#F8FAFC` Hintergrund, `#334155` Text
|
- **Keine Versicherungssprache.** Nie „Abmahnschutz“, „abgesichert“,
|
||||||
- **Font:** Inter (von `/fonts/inter.css` oder Google Fonts)
|
„geschützt“, „Kostenübernahme“ — sonst erlaubnispflichtiges
|
||||||
- **Radius:** 6px / 8px / 10px
|
Versicherungsgeschäft. Immer „Prüfung“, „Hinweis“, „Dokumentation“,
|
||||||
|
„Nachweis“.
|
||||||
Für neue Frontend-Projekte unter `/var/www/deklarix`:
|
- **Keine erfundenen Fundstellen.** Jede Norm und jedes Urteil steht in
|
||||||
```bash
|
einer Regel-YAML und wurde dort von Hand geprüft.
|
||||||
# Frontend-Scaffold (wenn benötigt)
|
- **Nur eigene Inhalte.** Kein Scraping fremder Accounts, keine fremden
|
||||||
npm create vite@latest frontend -- --template react-ts
|
Posts als Demo-Material. Demo-Material ist synthetisch. Nutzerinhalte
|
||||||
cd frontend && npm install
|
kommen über eigenen OAuth-Zugang oder Datenexport.
|
||||||
# enterprise.css aus design/ einbinden
|
- **DSGVO und Hosting in der EU.** Löschkonzept von Anfang an mitdenken,
|
||||||
```
|
aber Beweisarchiv und Löschpflicht sauber gegeneinander abgrenzen
|
||||||
|
(offener Punkt, siehe unten).
|
||||||
|
- **Berufsrecht der Anwälte (§ 49b Abs. 3 S. 1 BRAO).** Kein Routing
|
||||||
|
(kein Button, der einen Fall an eine Kanzlei übergibt — Nutzer wählt
|
||||||
|
selbst aus dem Verzeichnis), kein ergebnisabhängiger Preis, keine
|
||||||
|
Sachvorteile an gelistete Kanzleien, Verzeichnis bleibt kostenlos
|
||||||
|
(BGH IX ZR 89/23: entgeltliche Mandatsvermittlung ist nichtig). Vor dem
|
||||||
|
ersten Kanzlei-Vertrag: formlose Auskunft der zuständigen
|
||||||
|
Rechtsanwaltskammer einholen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -148,8 +326,13 @@ cd frontend && npm install
|
|||||||
|
|
||||||
| Domain | Verwendung |
|
| Domain | Verwendung |
|
||||||
|--------|-----------|
|
|--------|-----------|
|
||||||
| deklarix.de | Primär |
|
| deklarix.de | Primär (Marketing-Site) |
|
||||||
| deklarix.com | Redirect / International |
|
| app.deklarix.de | Die Anwendung selbst (dieser Go-Server) |
|
||||||
|
| deklarix.com | Redirect auf deklarix.de |
|
||||||
|
| get.deklarix.com | Installer-Endpoint (apt-Einrichtung, siehe Deployment) |
|
||||||
|
|
||||||
|
Kein separates `app.deklarix.com` — der Root-Redirect von `.com` auf
|
||||||
|
`.de` deckt Fehlnavigation ab, keine zweite Produktions-Subdomain nötig.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,15 +352,41 @@ git push origin main
|
|||||||
|
|
||||||
### Server-Prozess
|
### Server-Prozess
|
||||||
```bash
|
```bash
|
||||||
# Start (manuell)
|
# Dienst läuft über systemd (installiert per apt, siehe Deployment oben)
|
||||||
PORT=8080 ./dist/deklarix_latest_amd64 &
|
sudo systemctl start deklarix
|
||||||
|
sudo systemctl status deklarix
|
||||||
|
|
||||||
|
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, ANTHROPIC_API_KEY,
|
||||||
|
# RULES_DIR, DOSSIER_DIR, TSA_URL)
|
||||||
|
|
||||||
# Logs prüfen
|
# Logs prüfen
|
||||||
journalctl -u deklarix -f
|
journalctl -u deklarix -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Bekannte Server
|
||||||
|
|
||||||
|
| Rolle | Host | Zugang |
|
||||||
|
|---|---|---|
|
||||||
|
| Testsystem + `app.deklarix.de` (DB-Integrationstests, apt-Verifikation, nginx-Reverse-Proxy + Let's-Encrypt-TLS auf den lokalen Dienst) | 89.163.205.4 | SSH `noroot`, sudo passwortlos |
|
||||||
|
| Installer-Endpoint (`get.deklarix.com`) | 89.163.205.110 | noch nicht eingerichtet |
|
||||||
|
| Marketing-Site (`deklarix.de`/`.com`) | 89.163.205.80 | noch nicht eingerichtet |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Arbeitsweise für Claude Code
|
||||||
|
|
||||||
|
- Kleine Commits, ein Thema pro Commit
|
||||||
|
- Erst Test oder Golden-Fall, dann Implementierung
|
||||||
|
- Keine neue Abhängigkeit ohne Rückfrage
|
||||||
|
- Keine Umbenennung von Regel-IDs; Regeln werden versioniert, nicht
|
||||||
|
ersetzt
|
||||||
|
- Fehler nicht verschlucken, keine stillen Fallbacks bei der Extraktion
|
||||||
|
- Bei Unsicherheit über eine Rechtsfrage: nicht raten, sondern als offene
|
||||||
|
Frage in `rules/OPEN.md` notieren
|
||||||
|
- Der Code-RAG dient der Orientierung im Bestand, nicht als Nachweis von
|
||||||
|
Korrektheit. Verifiziert wird durch Compiler, Tests und die
|
||||||
|
Golden-Suite.
|
||||||
|
|
||||||
## Vor Änderungen
|
## Vor Änderungen
|
||||||
|
|
||||||
1. `go vet ./...` — keine Fehler
|
1. `go vet ./...` — keine Fehler
|
||||||
@@ -190,3 +399,18 @@ journalctl -u deklarix -f
|
|||||||
2. `./scripts/build.sh <version>` → erfolgreich
|
2. `./scripts/build.sh <version>` → erfolgreich
|
||||||
3. Commit mit semantischer Message: `feat:`, `fix:`, `refactor:`, `docs:`
|
3. Commit mit semantischer Message: `feat:`, `fix:`, `refactor:`, `docs:`
|
||||||
4. Bei Release: `./scripts/release.sh <version>`
|
4. Bei Release: `./scripts/release.sh <version>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
|
||||||
|
- Umfang des Regelsatzes für den ersten Pilotkunden festlegen
|
||||||
|
- **TSA für die Zeitstempel:** interimsweise FreeTSA.org (frei, RFC-3161-
|
||||||
|
konform, siehe `internal/evidence.DefaultTSAURL`) — funktioniert
|
||||||
|
technisch, ist aber **nicht eIDAS-qualifiziert**. Vor echtem
|
||||||
|
Kundeneinsatz auf einen eIDAS-qualifizierten Zeitstempeldienst (z. B.
|
||||||
|
D-Trust, Bundesdruckerei) umstellen, der die gesetzliche
|
||||||
|
Vermutungswirkung nach eIDAS Art. 41 hat — dafür ist ein Vertrag/Account
|
||||||
|
nötig, kein anonymer HTTP-Call.
|
||||||
|
- Löschkonzept versus Beweisarchiv sauber ausformulieren
|
||||||
|
- Markenanmeldung Deklarix, sobald erster Kunde zahlt
|
||||||
|
|||||||
73
cmd/deklarix/main.go
Normal file
73
cmd/deklarix/main.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
databaseURL := os.Getenv("DATABASE_URL")
|
||||||
|
if databaseURL == "" {
|
||||||
|
log.Fatal("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Migrate(databaseURL); err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(context.Background(), databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
log.Fatal("ANTHROPIC_API_KEY is required")
|
||||||
|
}
|
||||||
|
extractor, err := extract.NewClient(apiKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("extract client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rulesDir := os.Getenv("RULES_DIR")
|
||||||
|
if rulesDir == "" {
|
||||||
|
rulesDir = "rules"
|
||||||
|
}
|
||||||
|
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load rules from %s: %v", rulesDir, err)
|
||||||
|
}
|
||||||
|
log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir)
|
||||||
|
|
||||||
|
timestamper := evidence.NewHTTPTimestamper(os.Getenv("TSA_URL"))
|
||||||
|
|
||||||
|
dossierDir := os.Getenv("DOSSIER_DIR")
|
||||||
|
if dossierDir == "" {
|
||||||
|
dossierDir = "dossiers"
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("web server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
addr := ":" + port
|
||||||
|
|
||||||
|
log.Printf("Deklarix server starting on %s", addr)
|
||||||
|
if err := http.ListenAndServe(addr, server); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
port := os.Getenv("PORT")
|
|
||||||
if port == "" {
|
|
||||||
port = "8080"
|
|
||||||
}
|
|
||||||
addr := ":" + port
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
fmt.Fprintf(w, `{"ok":true}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
log.Printf("Deklarix server starting on %s", addr)
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
go.mod
21
go.mod
@@ -1,3 +1,24 @@
|
|||||||
module github.com/netcell-it/deklarix
|
module github.com/netcell-it/deklarix
|
||||||
|
|
||||||
go 1.26.6
|
go 1.26.6
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
||||||
|
github.com/go-pdf/fpdf v0.9.0
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
golang.org/x/crypto v0.55.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 // indirect
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
100
go.sum
Normal file
100
go.sum
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
|
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49 h1:h+XMRXf+WLY0h/3itqE8OT3TgjCMHK4nq2FNGi0au2c=
|
||||||
|
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||||
|
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c=
|
||||||
|
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||||
|
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
|
||||||
|
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
56
internal/auth/auth.go
Normal file
56
internal/auth/auth.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Package auth enthält die reine Logik für Anmeldung: Passwort-Hashing,
|
||||||
|
// Session-Token-Erzeugung. Es fasst keine Datenbank an — das macht
|
||||||
|
// internal/store (account, app_user, session), damit Persistenz an
|
||||||
|
// einer Stelle im Projekt gebündelt bleibt.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MinPasswordLength ist die einzige Passwort-Regel, die wir prüfen —
|
||||||
|
// keine erzwungenen Sonderzeichen/Ziffern-Vorgaben, die Nutzer nur zu
|
||||||
|
// vorhersehbaren Mustern verleiten.
|
||||||
|
const MinPasswordLength = 8
|
||||||
|
|
||||||
|
// SessionDuration ist die Gültigkeitsdauer einer neu erstellten Sitzung.
|
||||||
|
const SessionDuration = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
// HashPassword hasht ein Klartext-Passwort für die Speicherung. Lehnt
|
||||||
|
// zu kurze Passwörter ab, statt sie stillschweigend zu hashen.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
if len(password) < MinPasswordLength {
|
||||||
|
return "", fmt.Errorf("auth: passwort muss mindestens %d Zeichen haben", MinPasswordLength)
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("auth: passwort hashen: %w", err)
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword prüft ein Klartext-Passwort gegen einen gespeicherten
|
||||||
|
// Hash. Ein Fehler bedeutet: falsches Passwort oder ungültiger Hash —
|
||||||
|
// beides führt zu "Anmeldung abgelehnt", nie zu einem Unterschied, den
|
||||||
|
// ein Angreifer für User-Enumeration nutzen könnte.
|
||||||
|
func VerifyPassword(hash, password string) error {
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return fmt.Errorf("auth: passwort ungültig: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionToken erzeugt ein kryptographisch zufälliges Session-Token
|
||||||
|
// (32 Byte, hex-kodiert).
|
||||||
|
func NewSessionToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("auth: token generieren: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
50
internal/auth/auth_test.go
Normal file
50
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashPasswordRejectsShortPassword(t *testing.T) {
|
||||||
|
if _, err := auth.HashPassword("kurz"); err == nil {
|
||||||
|
t.Fatal("expected error for a too-short password, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashAndVerifyRoundTrip(t *testing.T) {
|
||||||
|
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
if err := auth.VerifyPassword(hash, "ein-langes-passwort"); err != nil {
|
||||||
|
t.Fatalf("VerifyPassword: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyPasswordRejectsWrongPassword(t *testing.T) {
|
||||||
|
hash, err := auth.HashPassword("ein-langes-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
if err := auth.VerifyPassword(hash, "falsches-passwort"); err == nil {
|
||||||
|
t.Fatal("expected error for a wrong password, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSessionTokenIsRandomAndCorrectLength(t *testing.T) {
|
||||||
|
a, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
b, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("expected two distinct tokens, got the same value twice")
|
||||||
|
}
|
||||||
|
if len(a) != 64 {
|
||||||
|
t.Fatalf("token length = %d, want 64 (hex of 32 random bytes)", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
251
internal/dossier/assets/cp1252.map
Normal file
251
internal/dossier/assets/cp1252.map
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
!00 U+0000 .notdef
|
||||||
|
!01 U+0001 .notdef
|
||||||
|
!02 U+0002 .notdef
|
||||||
|
!03 U+0003 .notdef
|
||||||
|
!04 U+0004 .notdef
|
||||||
|
!05 U+0005 .notdef
|
||||||
|
!06 U+0006 .notdef
|
||||||
|
!07 U+0007 .notdef
|
||||||
|
!08 U+0008 .notdef
|
||||||
|
!09 U+0009 .notdef
|
||||||
|
!0A U+000A .notdef
|
||||||
|
!0B U+000B .notdef
|
||||||
|
!0C U+000C .notdef
|
||||||
|
!0D U+000D .notdef
|
||||||
|
!0E U+000E .notdef
|
||||||
|
!0F U+000F .notdef
|
||||||
|
!10 U+0010 .notdef
|
||||||
|
!11 U+0011 .notdef
|
||||||
|
!12 U+0012 .notdef
|
||||||
|
!13 U+0013 .notdef
|
||||||
|
!14 U+0014 .notdef
|
||||||
|
!15 U+0015 .notdef
|
||||||
|
!16 U+0016 .notdef
|
||||||
|
!17 U+0017 .notdef
|
||||||
|
!18 U+0018 .notdef
|
||||||
|
!19 U+0019 .notdef
|
||||||
|
!1A U+001A .notdef
|
||||||
|
!1B U+001B .notdef
|
||||||
|
!1C U+001C .notdef
|
||||||
|
!1D U+001D .notdef
|
||||||
|
!1E U+001E .notdef
|
||||||
|
!1F U+001F .notdef
|
||||||
|
!20 U+0020 space
|
||||||
|
!21 U+0021 exclam
|
||||||
|
!22 U+0022 quotedbl
|
||||||
|
!23 U+0023 numbersign
|
||||||
|
!24 U+0024 dollar
|
||||||
|
!25 U+0025 percent
|
||||||
|
!26 U+0026 ampersand
|
||||||
|
!27 U+0027 quotesingle
|
||||||
|
!28 U+0028 parenleft
|
||||||
|
!29 U+0029 parenright
|
||||||
|
!2A U+002A asterisk
|
||||||
|
!2B U+002B plus
|
||||||
|
!2C U+002C comma
|
||||||
|
!2D U+002D hyphen
|
||||||
|
!2E U+002E period
|
||||||
|
!2F U+002F slash
|
||||||
|
!30 U+0030 zero
|
||||||
|
!31 U+0031 one
|
||||||
|
!32 U+0032 two
|
||||||
|
!33 U+0033 three
|
||||||
|
!34 U+0034 four
|
||||||
|
!35 U+0035 five
|
||||||
|
!36 U+0036 six
|
||||||
|
!37 U+0037 seven
|
||||||
|
!38 U+0038 eight
|
||||||
|
!39 U+0039 nine
|
||||||
|
!3A U+003A colon
|
||||||
|
!3B U+003B semicolon
|
||||||
|
!3C U+003C less
|
||||||
|
!3D U+003D equal
|
||||||
|
!3E U+003E greater
|
||||||
|
!3F U+003F question
|
||||||
|
!40 U+0040 at
|
||||||
|
!41 U+0041 A
|
||||||
|
!42 U+0042 B
|
||||||
|
!43 U+0043 C
|
||||||
|
!44 U+0044 D
|
||||||
|
!45 U+0045 E
|
||||||
|
!46 U+0046 F
|
||||||
|
!47 U+0047 G
|
||||||
|
!48 U+0048 H
|
||||||
|
!49 U+0049 I
|
||||||
|
!4A U+004A J
|
||||||
|
!4B U+004B K
|
||||||
|
!4C U+004C L
|
||||||
|
!4D U+004D M
|
||||||
|
!4E U+004E N
|
||||||
|
!4F U+004F O
|
||||||
|
!50 U+0050 P
|
||||||
|
!51 U+0051 Q
|
||||||
|
!52 U+0052 R
|
||||||
|
!53 U+0053 S
|
||||||
|
!54 U+0054 T
|
||||||
|
!55 U+0055 U
|
||||||
|
!56 U+0056 V
|
||||||
|
!57 U+0057 W
|
||||||
|
!58 U+0058 X
|
||||||
|
!59 U+0059 Y
|
||||||
|
!5A U+005A Z
|
||||||
|
!5B U+005B bracketleft
|
||||||
|
!5C U+005C backslash
|
||||||
|
!5D U+005D bracketright
|
||||||
|
!5E U+005E asciicircum
|
||||||
|
!5F U+005F underscore
|
||||||
|
!60 U+0060 grave
|
||||||
|
!61 U+0061 a
|
||||||
|
!62 U+0062 b
|
||||||
|
!63 U+0063 c
|
||||||
|
!64 U+0064 d
|
||||||
|
!65 U+0065 e
|
||||||
|
!66 U+0066 f
|
||||||
|
!67 U+0067 g
|
||||||
|
!68 U+0068 h
|
||||||
|
!69 U+0069 i
|
||||||
|
!6A U+006A j
|
||||||
|
!6B U+006B k
|
||||||
|
!6C U+006C l
|
||||||
|
!6D U+006D m
|
||||||
|
!6E U+006E n
|
||||||
|
!6F U+006F o
|
||||||
|
!70 U+0070 p
|
||||||
|
!71 U+0071 q
|
||||||
|
!72 U+0072 r
|
||||||
|
!73 U+0073 s
|
||||||
|
!74 U+0074 t
|
||||||
|
!75 U+0075 u
|
||||||
|
!76 U+0076 v
|
||||||
|
!77 U+0077 w
|
||||||
|
!78 U+0078 x
|
||||||
|
!79 U+0079 y
|
||||||
|
!7A U+007A z
|
||||||
|
!7B U+007B braceleft
|
||||||
|
!7C U+007C bar
|
||||||
|
!7D U+007D braceright
|
||||||
|
!7E U+007E asciitilde
|
||||||
|
!7F U+007F .notdef
|
||||||
|
!80 U+20AC Euro
|
||||||
|
!82 U+201A quotesinglbase
|
||||||
|
!83 U+0192 florin
|
||||||
|
!84 U+201E quotedblbase
|
||||||
|
!85 U+2026 ellipsis
|
||||||
|
!86 U+2020 dagger
|
||||||
|
!87 U+2021 daggerdbl
|
||||||
|
!88 U+02C6 circumflex
|
||||||
|
!89 U+2030 perthousand
|
||||||
|
!8A U+0160 Scaron
|
||||||
|
!8B U+2039 guilsinglleft
|
||||||
|
!8C U+0152 OE
|
||||||
|
!8E U+017D Zcaron
|
||||||
|
!91 U+2018 quoteleft
|
||||||
|
!92 U+2019 quoteright
|
||||||
|
!93 U+201C quotedblleft
|
||||||
|
!94 U+201D quotedblright
|
||||||
|
!95 U+2022 bullet
|
||||||
|
!96 U+2013 endash
|
||||||
|
!97 U+2014 emdash
|
||||||
|
!98 U+02DC tilde
|
||||||
|
!99 U+2122 trademark
|
||||||
|
!9A U+0161 scaron
|
||||||
|
!9B U+203A guilsinglright
|
||||||
|
!9C U+0153 oe
|
||||||
|
!9E U+017E zcaron
|
||||||
|
!9F U+0178 Ydieresis
|
||||||
|
!A0 U+00A0 space
|
||||||
|
!A1 U+00A1 exclamdown
|
||||||
|
!A2 U+00A2 cent
|
||||||
|
!A3 U+00A3 sterling
|
||||||
|
!A4 U+00A4 currency
|
||||||
|
!A5 U+00A5 yen
|
||||||
|
!A6 U+00A6 brokenbar
|
||||||
|
!A7 U+00A7 section
|
||||||
|
!A8 U+00A8 dieresis
|
||||||
|
!A9 U+00A9 copyright
|
||||||
|
!AA U+00AA ordfeminine
|
||||||
|
!AB U+00AB guillemotleft
|
||||||
|
!AC U+00AC logicalnot
|
||||||
|
!AD U+00AD hyphen
|
||||||
|
!AE U+00AE registered
|
||||||
|
!AF U+00AF macron
|
||||||
|
!B0 U+00B0 degree
|
||||||
|
!B1 U+00B1 plusminus
|
||||||
|
!B2 U+00B2 twosuperior
|
||||||
|
!B3 U+00B3 threesuperior
|
||||||
|
!B4 U+00B4 acute
|
||||||
|
!B5 U+00B5 mu
|
||||||
|
!B6 U+00B6 paragraph
|
||||||
|
!B7 U+00B7 periodcentered
|
||||||
|
!B8 U+00B8 cedilla
|
||||||
|
!B9 U+00B9 onesuperior
|
||||||
|
!BA U+00BA ordmasculine
|
||||||
|
!BB U+00BB guillemotright
|
||||||
|
!BC U+00BC onequarter
|
||||||
|
!BD U+00BD onehalf
|
||||||
|
!BE U+00BE threequarters
|
||||||
|
!BF U+00BF questiondown
|
||||||
|
!C0 U+00C0 Agrave
|
||||||
|
!C1 U+00C1 Aacute
|
||||||
|
!C2 U+00C2 Acircumflex
|
||||||
|
!C3 U+00C3 Atilde
|
||||||
|
!C4 U+00C4 Adieresis
|
||||||
|
!C5 U+00C5 Aring
|
||||||
|
!C6 U+00C6 AE
|
||||||
|
!C7 U+00C7 Ccedilla
|
||||||
|
!C8 U+00C8 Egrave
|
||||||
|
!C9 U+00C9 Eacute
|
||||||
|
!CA U+00CA Ecircumflex
|
||||||
|
!CB U+00CB Edieresis
|
||||||
|
!CC U+00CC Igrave
|
||||||
|
!CD U+00CD Iacute
|
||||||
|
!CE U+00CE Icircumflex
|
||||||
|
!CF U+00CF Idieresis
|
||||||
|
!D0 U+00D0 Eth
|
||||||
|
!D1 U+00D1 Ntilde
|
||||||
|
!D2 U+00D2 Ograve
|
||||||
|
!D3 U+00D3 Oacute
|
||||||
|
!D4 U+00D4 Ocircumflex
|
||||||
|
!D5 U+00D5 Otilde
|
||||||
|
!D6 U+00D6 Odieresis
|
||||||
|
!D7 U+00D7 multiply
|
||||||
|
!D8 U+00D8 Oslash
|
||||||
|
!D9 U+00D9 Ugrave
|
||||||
|
!DA U+00DA Uacute
|
||||||
|
!DB U+00DB Ucircumflex
|
||||||
|
!DC U+00DC Udieresis
|
||||||
|
!DD U+00DD Yacute
|
||||||
|
!DE U+00DE Thorn
|
||||||
|
!DF U+00DF germandbls
|
||||||
|
!E0 U+00E0 agrave
|
||||||
|
!E1 U+00E1 aacute
|
||||||
|
!E2 U+00E2 acircumflex
|
||||||
|
!E3 U+00E3 atilde
|
||||||
|
!E4 U+00E4 adieresis
|
||||||
|
!E5 U+00E5 aring
|
||||||
|
!E6 U+00E6 ae
|
||||||
|
!E7 U+00E7 ccedilla
|
||||||
|
!E8 U+00E8 egrave
|
||||||
|
!E9 U+00E9 eacute
|
||||||
|
!EA U+00EA ecircumflex
|
||||||
|
!EB U+00EB edieresis
|
||||||
|
!EC U+00EC igrave
|
||||||
|
!ED U+00ED iacute
|
||||||
|
!EE U+00EE icircumflex
|
||||||
|
!EF U+00EF idieresis
|
||||||
|
!F0 U+00F0 eth
|
||||||
|
!F1 U+00F1 ntilde
|
||||||
|
!F2 U+00F2 ograve
|
||||||
|
!F3 U+00F3 oacute
|
||||||
|
!F4 U+00F4 ocircumflex
|
||||||
|
!F5 U+00F5 otilde
|
||||||
|
!F6 U+00F6 odieresis
|
||||||
|
!F7 U+00F7 divide
|
||||||
|
!F8 U+00F8 oslash
|
||||||
|
!F9 U+00F9 ugrave
|
||||||
|
!FA U+00FA uacute
|
||||||
|
!FB U+00FB ucircumflex
|
||||||
|
!FC U+00FC udieresis
|
||||||
|
!FD U+00FD yacute
|
||||||
|
!FE U+00FE thorn
|
||||||
|
!FF U+00FF ydieresis
|
||||||
157
internal/dossier/content.go
Normal file
157
internal/dossier/content.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// Package dossier erzeugt das Nachweis-Dossier (PDF) aus Submission,
|
||||||
|
// Findings, Verantwortungsmatrix und Beweiskette. Die Aufbereitung des
|
||||||
|
// Inhalts (BuildContent) ist von der eigentlichen PDF-Zeichnung
|
||||||
|
// (Render) getrennt, damit die fachliche Logik — was steht wo im
|
||||||
|
// Dossier, in welcher Form — ohne PDF-Parsing testbar ist.
|
||||||
|
package dossier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Disclaimer steht auf jedem erzeugten Dossier. Siehe CLAUDE.md,
|
||||||
|
// Leitplanken: Deklarix ist ein Werkzeug, keine Rechtsberatung.
|
||||||
|
const Disclaimer = "Dieses Dossier dokumentiert die durchgeführte Prüfung. " +
|
||||||
|
"Es ist keine Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall."
|
||||||
|
|
||||||
|
// Submission sind die Basisdaten des geprüften Beitrags.
|
||||||
|
type Submission struct {
|
||||||
|
Platform string
|
||||||
|
PostType string
|
||||||
|
Caption string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participant ist ein Beteiligter aus der Verantwortungsmatrix.
|
||||||
|
type Participant struct {
|
||||||
|
Role string
|
||||||
|
Name string
|
||||||
|
Vorgegeben bool
|
||||||
|
Freigegeben bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data ist die Eingabe für Generate/BuildContent — alles, was ein
|
||||||
|
// Dossier für einen Beitrag braucht.
|
||||||
|
type Data struct {
|
||||||
|
Submission Submission
|
||||||
|
Facts rules.Facts
|
||||||
|
Findings []rules.Finding
|
||||||
|
Participants []Participant
|
||||||
|
AssetHash []byte
|
||||||
|
MetadataHash []byte
|
||||||
|
TimestampToken []byte
|
||||||
|
GeneratedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindingRow ist die dossier-taugliche Aufbereitung eines rules.Finding.
|
||||||
|
type FindingRow struct {
|
||||||
|
RuleID string
|
||||||
|
Version int
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Fix string
|
||||||
|
Sources []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParticipantRow ist die dossier-taugliche Aufbereitung eines Participant.
|
||||||
|
type ParticipantRow struct {
|
||||||
|
Role string
|
||||||
|
Name string
|
||||||
|
Vorgegeben bool
|
||||||
|
Freigegeben bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content ist die fertig aufbereitete, PDF-unabhängige Darstellung
|
||||||
|
// eines Dossiers.
|
||||||
|
type Content struct {
|
||||||
|
Title string
|
||||||
|
Platform string
|
||||||
|
PostType string
|
||||||
|
Caption string
|
||||||
|
|
||||||
|
SubmittedAt time.Time
|
||||||
|
GeneratedAt time.Time
|
||||||
|
|
||||||
|
DisclosurePresent bool
|
||||||
|
DisclosureWording string
|
||||||
|
DisclosureBeforeCut bool
|
||||||
|
|
||||||
|
Findings []FindingRow
|
||||||
|
Participants []ParticipantRow
|
||||||
|
|
||||||
|
AssetHashHex string
|
||||||
|
MetadataHashHex string
|
||||||
|
TimestampedAt time.Time
|
||||||
|
|
||||||
|
Disclaimer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildContent bereitet Data zu Content auf. Fehlt eine Pflichtangabe
|
||||||
|
// (Plattform, Zeitstempel-Token), wird ein Fehler geliefert statt ein
|
||||||
|
// Dossier mit stillschweigend leeren Beweisfeldern zu erzeugen.
|
||||||
|
func BuildContent(data Data) (Content, error) {
|
||||||
|
if data.Submission.Platform == "" {
|
||||||
|
return Content{}, fmt.Errorf("dossier: submission.platform fehlt")
|
||||||
|
}
|
||||||
|
if len(data.TimestampToken) == 0 {
|
||||||
|
return Content{}, fmt.Errorf("dossier: timestamp token fehlt")
|
||||||
|
}
|
||||||
|
// AssetHash ist optional: nicht jede Prüfung hat ein hochgeladenes
|
||||||
|
// Bild/Video (aktuell reine Caption-Prüfungen). Ein erfundener
|
||||||
|
// Platzhalter-Hash für ein nicht existierendes Asset wäre selbst ein
|
||||||
|
// Integritätsproblem — also zeigt das Dossier stattdessen "kein
|
||||||
|
// Asset hinterlegt" an, siehe Render.
|
||||||
|
if len(data.MetadataHash) == 0 {
|
||||||
|
return Content{}, fmt.Errorf("dossier: metadata hash fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
timestampedAt, err := evidence.TimestampTime(data.TimestampToken)
|
||||||
|
if err != nil {
|
||||||
|
return Content{}, fmt.Errorf("dossier: timestamp token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
findings := make([]FindingRow, len(data.Findings))
|
||||||
|
for i, f := range data.Findings {
|
||||||
|
findings[i] = FindingRow{
|
||||||
|
RuleID: f.RuleID,
|
||||||
|
Version: f.RuleVersion,
|
||||||
|
Severity: string(f.Severity),
|
||||||
|
Title: f.Title,
|
||||||
|
Fix: f.Fix,
|
||||||
|
Sources: f.Sources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
participants := make([]ParticipantRow, len(data.Participants))
|
||||||
|
for i, p := range data.Participants {
|
||||||
|
participants[i] = ParticipantRow{
|
||||||
|
Role: p.Role,
|
||||||
|
Name: p.Name,
|
||||||
|
Vorgegeben: p.Vorgegeben,
|
||||||
|
Freigegeben: p.Freigegeben,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Content{
|
||||||
|
Title: "Nachweis-Dossier",
|
||||||
|
Platform: data.Submission.Platform,
|
||||||
|
PostType: data.Submission.PostType,
|
||||||
|
Caption: data.Submission.Caption,
|
||||||
|
SubmittedAt: data.Submission.CreatedAt,
|
||||||
|
GeneratedAt: data.GeneratedAt,
|
||||||
|
DisclosurePresent: data.Facts.DisclosurePresent,
|
||||||
|
DisclosureWording: data.Facts.DisclosureWording,
|
||||||
|
DisclosureBeforeCut: data.Facts.DisclosureBeforeCut,
|
||||||
|
Findings: findings,
|
||||||
|
Participants: participants,
|
||||||
|
AssetHashHex: hex.EncodeToString(data.AssetHash),
|
||||||
|
MetadataHashHex: hex.EncodeToString(data.MetadataHash),
|
||||||
|
TimestampedAt: timestampedAt,
|
||||||
|
Disclaimer: Disclaimer,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
166
internal/dossier/content_test.go
Normal file
166
internal/dossier/content_test.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package dossier_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/digitorus/timestamp"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/dossier"
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTimestampToken erzeugt einen strukturell gültigen, selbstsignierten
|
||||||
|
// RFC-3161-Token für hash — offline, ohne echte TSA. Damit lassen sich
|
||||||
|
// BuildContent/Render testen, ohne bei jedem Testlauf eine echte
|
||||||
|
// Time-Stamp Authority anzufragen (die Echtheit/Vertrauenswürdigkeit
|
||||||
|
// des Zertifikats spielt für diese Tests keine Rolle, nur dass der Token
|
||||||
|
// strukturell parsbar ist wie ein echter).
|
||||||
|
func fakeTimestampToken(t *testing.T, hash []byte, at time.Time) []byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
|
||||||
|
NotBefore: at.Add(-time.Hour),
|
||||||
|
NotAfter: at.Add(time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create certificate: %v", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(certDER)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse certificate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := timestamp.Timestamp{
|
||||||
|
HashAlgorithm: crypto.SHA256,
|
||||||
|
HashedMessage: hash,
|
||||||
|
Time: at,
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Policy: asn1.ObjectIdentifier{1, 2, 3},
|
||||||
|
}
|
||||||
|
respDER, err := ts.CreateResponse(cert, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create timestamp response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := timestamp.ParseResponse(respDER)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse fake timestamp response: %v", err)
|
||||||
|
}
|
||||||
|
return parsed.RawToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func validData(t *testing.T) dossier.Data {
|
||||||
|
t.Helper()
|
||||||
|
hash := evidence.HashBytes([]byte("caption+bild"))
|
||||||
|
at := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
return dossier.Data{
|
||||||
|
Submission: dossier.Submission{
|
||||||
|
Platform: "instagram",
|
||||||
|
PostType: "reel",
|
||||||
|
Caption: "Werbung für ein Produkt äöüß",
|
||||||
|
CreatedAt: at,
|
||||||
|
},
|
||||||
|
Facts: rules.Facts{
|
||||||
|
DisclosurePresent: true,
|
||||||
|
DisclosureWording: "Werbung",
|
||||||
|
DisclosureBeforeCut: false,
|
||||||
|
},
|
||||||
|
Findings: []rules.Finding{
|
||||||
|
{RuleID: "WK-004", RuleVersion: 1, Severity: rules.SeverityHigh, Title: "t", Fix: "f", Sources: []string{"§ 5a Abs. 4 UWG"}},
|
||||||
|
},
|
||||||
|
Participants: []dossier.Participant{
|
||||||
|
{Role: "creator", Name: "Max Mustermann", Vorgegeben: false, Freigegeben: true},
|
||||||
|
},
|
||||||
|
AssetHash: hash,
|
||||||
|
MetadataHash: evidence.HashBytes([]byte("metadata")),
|
||||||
|
TimestampToken: fakeTimestampToken(t, hash, at),
|
||||||
|
GeneratedAt: at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentRejectsMissingPlatform(t *testing.T) {
|
||||||
|
data := validData(t)
|
||||||
|
data.Submission.Platform = ""
|
||||||
|
if _, err := dossier.BuildContent(data); err == nil {
|
||||||
|
t.Fatal("expected error for missing platform, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentRejectsMissingTimestampToken(t *testing.T) {
|
||||||
|
data := validData(t)
|
||||||
|
data.TimestampToken = nil
|
||||||
|
if _, err := dossier.BuildContent(data); err == nil {
|
||||||
|
t.Fatal("expected error for missing timestamp token, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentRejectsMissingMetadataHash(t *testing.T) {
|
||||||
|
data := validData(t)
|
||||||
|
data.MetadataHash = nil
|
||||||
|
if _, err := dossier.BuildContent(data); err == nil {
|
||||||
|
t.Fatal("expected error for missing metadata hash, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentAllowsMissingAssetHash(t *testing.T) {
|
||||||
|
// Reine Caption-Pruefungen ohne hochgeladenes Bild/Video haben kein
|
||||||
|
// Asset zum Hashen — das ist kein Fehlerfall.
|
||||||
|
data := validData(t)
|
||||||
|
data.AssetHash = nil
|
||||||
|
content, err := dossier.BuildContent(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildContent: %v", err)
|
||||||
|
}
|
||||||
|
if content.AssetHashHex != "" {
|
||||||
|
t.Fatalf("AssetHashHex = %q, want empty when no asset was provided", content.AssetHashHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentSuccess(t *testing.T) {
|
||||||
|
data := validData(t)
|
||||||
|
|
||||||
|
content, err := dossier.BuildContent(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildContent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.Platform != "instagram" {
|
||||||
|
t.Errorf("Platform = %q, want instagram", content.Platform)
|
||||||
|
}
|
||||||
|
if len(content.Findings) != 1 || content.Findings[0].RuleID != "WK-004" {
|
||||||
|
t.Errorf("Findings = %+v, want one WK-004 row", content.Findings)
|
||||||
|
}
|
||||||
|
if len(content.Participants) != 1 || content.Participants[0].Name != "Max Mustermann" {
|
||||||
|
t.Errorf("Participants = %+v, want one Max-Mustermann row", content.Participants)
|
||||||
|
}
|
||||||
|
if len(content.AssetHashHex) != 64 {
|
||||||
|
t.Errorf("AssetHashHex length = %d, want 64 (hex of 32-byte SHA-256)", len(content.AssetHashHex))
|
||||||
|
}
|
||||||
|
if !content.TimestampedAt.Equal(data.Submission.CreatedAt) {
|
||||||
|
t.Errorf("TimestampedAt = %v, want %v", content.TimestampedAt, data.Submission.CreatedAt)
|
||||||
|
}
|
||||||
|
if content.Disclaimer == "" {
|
||||||
|
t.Error("expected a non-empty disclaimer")
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/dossier/render.go
Normal file
129
internal/dossier/render.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package dossier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/cp1252.map
|
||||||
|
var assetsFS embed.FS
|
||||||
|
|
||||||
|
// Generate erzeugt das PDF-Dossier für data und schreibt es nach w.
|
||||||
|
func Generate(w io.Writer, data Data) error {
|
||||||
|
content, err := BuildContent(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return Render(w, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render zeichnet ein bereits aufbereitetes Content als PDF nach w.
|
||||||
|
func Render(w io.Writer, c Content) error {
|
||||||
|
tr, err := unicodeTranslator()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dossier: unicode translator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf := fpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.SetTitle(tr(c.Title), false)
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
heading := func(text string) {
|
||||||
|
pdf.SetFont("Helvetica", "B", 12)
|
||||||
|
pdf.CellFormat(0, 8, tr(text), "", 1, "L", false, 0, "")
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
}
|
||||||
|
line := func(format string, args ...any) {
|
||||||
|
pdf.CellFormat(0, 6, tr(fmt.Sprintf(format, args...)), "", 1, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
paragraph := func(text string) {
|
||||||
|
pdf.MultiCell(0, 5, tr(text), "", "L", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.SetFont("Helvetica", "B", 16)
|
||||||
|
pdf.CellFormat(0, 10, tr(c.Title), "", 1, "L", false, 0, "")
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
line("Erzeugt am %s", c.GeneratedAt.Format("02.01.2006 15:04 MST"))
|
||||||
|
pdf.Ln(4)
|
||||||
|
|
||||||
|
heading("Beitrag")
|
||||||
|
line("Plattform: %s Typ: %s", c.Platform, c.PostType)
|
||||||
|
line("Eingereicht am: %s", c.SubmittedAt.Format("02.01.2006 15:04"))
|
||||||
|
paragraph("Caption: " + c.Caption)
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
heading("Kennzeichnung")
|
||||||
|
line("Vorhanden: %s Wortlaut: %q Vor Kürzung sichtbar: %s",
|
||||||
|
yesNo(c.DisclosurePresent), c.DisclosureWording, yesNo(c.DisclosureBeforeCut))
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
heading("Findings")
|
||||||
|
if len(c.Findings) == 0 {
|
||||||
|
line("Keine Findings.")
|
||||||
|
}
|
||||||
|
for _, f := range c.Findings {
|
||||||
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
|
line("%s v%d — %s (%s)", f.RuleID, f.Version, f.Title, f.Severity)
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
paragraph("Korrektur: " + f.Fix)
|
||||||
|
if len(f.Sources) > 0 {
|
||||||
|
paragraph("Fundstellen: " + strings.Join(f.Sources, "; "))
|
||||||
|
}
|
||||||
|
pdf.Ln(1)
|
||||||
|
}
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
heading("Verantwortungsmatrix")
|
||||||
|
if len(c.Participants) == 0 {
|
||||||
|
line("Keine Beteiligten hinterlegt.")
|
||||||
|
}
|
||||||
|
for _, p := range c.Participants {
|
||||||
|
line("%s: %s (vorgegeben: %s, freigegeben: %s)",
|
||||||
|
p.Role, p.Name, yesNo(p.Vorgegeben), yesNo(p.Freigegeben))
|
||||||
|
}
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
heading("Beweiskette")
|
||||||
|
if c.AssetHashHex != "" {
|
||||||
|
line("Asset-Hash (SHA-256): %s", c.AssetHashHex)
|
||||||
|
} else {
|
||||||
|
line("Asset-Hash: kein Asset hinterlegt (reine Caption-Prüfung)")
|
||||||
|
}
|
||||||
|
line("Metadaten-Hash (SHA-256): %s", c.MetadataHashHex)
|
||||||
|
line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST"))
|
||||||
|
pdf.Ln(4)
|
||||||
|
|
||||||
|
pdf.SetFont("Helvetica", "I", 8)
|
||||||
|
paragraph(c.Disclaimer)
|
||||||
|
|
||||||
|
if err := pdf.Error(); err != nil {
|
||||||
|
return fmt.Errorf("dossier: pdf aufbauen: %w", err)
|
||||||
|
}
|
||||||
|
if err := pdf.Output(w); err != nil {
|
||||||
|
return fmt.Errorf("dossier: pdf schreiben: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func yesNo(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "ja"
|
||||||
|
}
|
||||||
|
return "nein"
|
||||||
|
}
|
||||||
|
|
||||||
|
// unicodeTranslator übersetzt UTF-8 (z. B. Umlaute) in die cp1252-
|
||||||
|
// Kodierung der Helvetica-Kernschriftart. Die Map-Datei ist eingebettet,
|
||||||
|
// damit Deklarix trotz PDF-Erzeugung ein einzelnes Binary bleibt.
|
||||||
|
func unicodeTranslator() (func(string) string, error) {
|
||||||
|
data, err := assetsFS.ReadFile("assets/cp1252.map")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fpdf.UnicodeTranslator(bytes.NewReader(data))
|
||||||
|
}
|
||||||
51
internal/dossier/render_test.go
Normal file
51
internal/dossier/render_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package dossier_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/dossier"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderProducesValidPDF(t *testing.T) {
|
||||||
|
content, err := dossier.BuildContent(validData(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildContent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := dossier.Render(&buf, content); err != nil {
|
||||||
|
t.Fatalf("Render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.Bytes()
|
||||||
|
if !bytes.HasPrefix(out, []byte("%PDF-")) {
|
||||||
|
t.Fatalf("output does not start with %%PDF- header: %q", out[:min(20, len(out))])
|
||||||
|
}
|
||||||
|
if !bytes.Contains(out, []byte("%%EOF")) {
|
||||||
|
t.Fatal("output does not contain the expected PDF EOF trailer")
|
||||||
|
}
|
||||||
|
if len(out) < 500 {
|
||||||
|
t.Fatalf("output suspiciously small (%d bytes) for a multi-section dossier", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateProducesValidPDF(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := dossier.Generate(&buf, validData(t)); err != nil {
|
||||||
|
t.Fatalf("Generate: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(buf.Bytes(), []byte("%PDF-")) {
|
||||||
|
t.Fatal("Generate output does not start with the expected PDF header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePropagatesBuildContentErrors(t *testing.T) {
|
||||||
|
data := validData(t)
|
||||||
|
data.Submission.Platform = ""
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := dossier.Generate(&buf, data); err == nil {
|
||||||
|
t.Fatal("expected Generate to propagate a BuildContent error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/evidence/hash.go
Normal file
32
internal/evidence/hash.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Package evidence bildet die Beweiskette: Hashing von Assets und
|
||||||
|
// Metadaten, RFC-3161-Zeitstempel über diesen Hash. Das Append-only-
|
||||||
|
// Prinzip selbst wird von internal/store (Postgres-Trigger) erzwungen —
|
||||||
|
// dieses Paket liefert nur die Bausteine, die evidence_package braucht.
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashBytes berechnet den SHA-256-Digest über beliebige Bytes, z. B.
|
||||||
|
// ein Asset wie Screenshot oder Video.
|
||||||
|
func HashBytes(data []byte) []byte {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashMetadata berechnet den SHA-256-Digest über die kanonisierte JSON-
|
||||||
|
// Repräsentation von v. "Kanonisiert" heißt hier: encoding/json sortiert
|
||||||
|
// Objektschlüssel bereits deterministisch, und Struct-Felder behalten
|
||||||
|
// ihre Deklarationsreihenfolge — für unsere eigenen, fest definierten
|
||||||
|
// Go-Typen ist das schon eine stabile, reproduzierbare Serialisierung,
|
||||||
|
// ohne dass es eine eigene Kanonisierungs-Bibliothek bräuchte.
|
||||||
|
func HashMetadata(v any) ([]byte, error) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evidence: metadata marshal: %w", err)
|
||||||
|
}
|
||||||
|
return HashBytes(data), nil
|
||||||
|
}
|
||||||
67
internal/evidence/hash_test.go
Normal file
67
internal/evidence/hash_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package evidence_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHashBytesIsDeterministic(t *testing.T) {
|
||||||
|
data := []byte("ein beispiel-asset")
|
||||||
|
a := evidence.HashBytes(data)
|
||||||
|
b := evidence.HashBytes(data)
|
||||||
|
if !bytes.Equal(a, b) {
|
||||||
|
t.Fatalf("HashBytes ist nicht deterministisch: %x != %x", a, b)
|
||||||
|
}
|
||||||
|
if len(a) != 32 {
|
||||||
|
t.Fatalf("expected 32-byte SHA-256 digest, got %d bytes", len(a))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashBytesDiffersForDifferentInput(t *testing.T) {
|
||||||
|
a := evidence.HashBytes([]byte("foo"))
|
||||||
|
b := evidence.HashBytes([]byte("bar"))
|
||||||
|
if bytes.Equal(a, b) {
|
||||||
|
t.Fatal("expected different hashes for different input, got the same")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashMetadataIsStableAcrossMapKeyOrder(t *testing.T) {
|
||||||
|
m1 := map[string]any{"a": 1, "b": 2, "c": 3}
|
||||||
|
m2 := map[string]any{"c": 3, "a": 1, "b": 2}
|
||||||
|
|
||||||
|
h1, err := evidence.HashMetadata(m1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashMetadata(m1): %v", err)
|
||||||
|
}
|
||||||
|
h2, err := evidence.HashMetadata(m2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashMetadata(m2): %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(h1, h2) {
|
||||||
|
t.Fatal("HashMetadata should be stable across map key insertion order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashMetadataDiffersForDifferentContent(t *testing.T) {
|
||||||
|
h1, err := evidence.HashMetadata(map[string]any{"a": 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashMetadata: %v", err)
|
||||||
|
}
|
||||||
|
h2, err := evidence.HashMetadata(map[string]any{"a": 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashMetadata: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(h1, h2) {
|
||||||
|
t.Fatal("expected different hashes for different metadata content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashMetadataRejectsUnmarshalableValue(t *testing.T) {
|
||||||
|
// Kanäle können nicht als JSON serialisiert werden — muss einen
|
||||||
|
// Fehler liefern statt still einen falschen Hash zurückzugeben.
|
||||||
|
if _, err := evidence.HashMetadata(make(chan int)); err == nil {
|
||||||
|
t.Fatal("expected error for unmarshalable value, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/evidence/parse.go
Normal file
18
internal/evidence/parse.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/digitorus/timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimestampTime liefert den im RFC-3161-Token bescheinigten Zeitpunkt,
|
||||||
|
// z. B. für die Anzeige im Nachweis-Dossier.
|
||||||
|
func TimestampTime(token []byte) (time.Time, error) {
|
||||||
|
ts, err := timestamp.Parse(token)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("evidence: timestamp token parse: %w", err)
|
||||||
|
}
|
||||||
|
return ts.Time, nil
|
||||||
|
}
|
||||||
13
internal/evidence/parse_test.go
Normal file
13
internal/evidence/parse_test.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package evidence_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTimestampTimeRejectsGarbage(t *testing.T) {
|
||||||
|
if _, err := evidence.TimestampTime([]byte("not a timestamp token")); err == nil {
|
||||||
|
t.Fatal("expected error for garbage token bytes, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/evidence/timestamp.go
Normal file
18
internal/evidence/timestamp.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Timestamper fragt einen RFC-3161-Zeitstempel für einen Hash (i. d. R.
|
||||||
|
// aus HashBytes/HashMetadata) bei einer Time-Stamp Authority (TSA) an
|
||||||
|
// und liefert den rohen Zeitstempel-Token zurück, wie er unverändert in
|
||||||
|
// evidence_package.timestamp_token gespeichert wird.
|
||||||
|
//
|
||||||
|
// Es gibt hier bewusst noch keine konkrete Implementierung: welche TSA
|
||||||
|
// verwendet wird, ist in CLAUDE.md als offener Punkt vermerkt (freie
|
||||||
|
// TSA vs. eIDAS-qualifizierter Zeitstempeldienst — das betrifft direkt
|
||||||
|
// die Beweiskraft des Archivs und ist keine rein technische
|
||||||
|
// Entscheidung). Sobald das geklärt ist, implementiert ein konkreter
|
||||||
|
// Typ dieses Interface.
|
||||||
|
type Timestamper interface {
|
||||||
|
Timestamp(ctx context.Context, hash []byte) (token []byte, err error)
|
||||||
|
}
|
||||||
113
internal/evidence/tsa.go
Normal file
113
internal/evidence/tsa.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/digitorus/timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultTSAURL ist eine freie, RFC-3161-konforme Time-Stamp Authority.
|
||||||
|
//
|
||||||
|
// NICHT eIDAS-qualifiziert — reicht, um die Beweiskette technisch
|
||||||
|
// funktionsfähig zu haben, aber vor echtem Kundeneinsatz auf einen
|
||||||
|
// eIDAS-qualifizierten Zeitstempeldienst umstellen (z. B. D-Trust,
|
||||||
|
// Bundesdruckerei), der eine gesetzliche Vermutungswirkung nach
|
||||||
|
// eIDAS Art. 41 hat. Siehe CLAUDE.md, Offene Punkte.
|
||||||
|
const DefaultTSAURL = "https://freetsa.org/tsr"
|
||||||
|
|
||||||
|
// HTTPTimestamper implementiert Timestamper gegen eine RFC-3161-TSA
|
||||||
|
// über HTTP (application/timestamp-query, siehe RFC 3161 Abschnitt 3.4).
|
||||||
|
type HTTPTimestamper struct {
|
||||||
|
url string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestamperOption konfiguriert einen HTTPTimestamper.
|
||||||
|
type TimestamperOption func(*HTTPTimestamper)
|
||||||
|
|
||||||
|
// WithTimestamperHTTPClient überschreibt den verwendeten *http.Client
|
||||||
|
// (für Tests).
|
||||||
|
func WithTimestamperHTTPClient(hc *http.Client) TimestamperOption {
|
||||||
|
return func(t *HTTPTimestamper) { t.httpClient = hc }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPTimestamper erstellt einen Timestamper. Ein leerer url-Wert
|
||||||
|
// verwendet DefaultTSAURL.
|
||||||
|
func NewHTTPTimestamper(url string, opts ...TimestamperOption) *HTTPTimestamper {
|
||||||
|
if url == "" {
|
||||||
|
url = DefaultTSAURL
|
||||||
|
}
|
||||||
|
t := &HTTPTimestamper{url: url, httpClient: http.DefaultClient}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(t)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp fragt einen RFC-3161-Zeitstempel für hash (ein SHA-256-
|
||||||
|
// Digest) an und liefert den rohen TimeStampToken zurück. Die Antwort
|
||||||
|
// wird gegen den angefragten Hash und die gesendete Nonce geprüft,
|
||||||
|
// bevor der Token akzeptiert wird — eine TSA-Antwort, die nicht zum
|
||||||
|
// eigenen Request passt, ist kein gültiger Zeitstempel für diesen Hash.
|
||||||
|
func (t *HTTPTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) {
|
||||||
|
if len(hash) != crypto.SHA256.Size() {
|
||||||
|
return nil, fmt.Errorf("evidence: timestamp erwartet einen SHA-256-Digest (%d Bytes), bekam %d", crypto.SHA256.Size(), len(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 64))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evidence: nonce generieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := timestamp.Request{
|
||||||
|
HashAlgorithm: crypto.SHA256,
|
||||||
|
HashedMessage: hash,
|
||||||
|
Nonce: nonce,
|
||||||
|
Certificates: true,
|
||||||
|
}
|
||||||
|
reqBytes, err := req.Marshal()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evidence: timestamp request marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.url, bytes.NewReader(reqBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evidence: request bauen: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/timestamp-query")
|
||||||
|
|
||||||
|
resp, err := t.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evidence: TSA-Request fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evidence: TSA-Response lesen: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("evidence: TSA-Status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := timestamp.ParseResponse(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evidence: TSA-Response parse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ts.HashAlgorithm != crypto.SHA256 || !bytes.Equal(ts.HashedMessage, hash) {
|
||||||
|
return nil, fmt.Errorf("evidence: TSA-Antwort passt nicht zum angefragten Hash")
|
||||||
|
}
|
||||||
|
if ts.Nonce == nil || ts.Nonce.Cmp(nonce) != 0 {
|
||||||
|
return nil, fmt.Errorf("evidence: TSA-Antwort hat falsche oder fehlende Nonce")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ts.RawToken, nil
|
||||||
|
}
|
||||||
142
internal/evidence/tsa_test.go
Normal file
142
internal/evidence/tsa_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package evidence_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/digitorus/timestamp"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTimestampRejectsWrongHashSize(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||||
|
_, err := ts.Timestamp(context.Background(), []byte("zu kurz"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for a non-SHA-256-sized hash, got nil")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("expected no HTTP call for an invalid hash size")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimestampSendsValidRequest prüft serverseitig, dass Timestamp()
|
||||||
|
// einen tatsächlich gültigen, RFC-3161-konformen Request schickt (parsbar,
|
||||||
|
// korrekter Hash, korrekter Algorithmus, Nonce gesetzt) — ohne dafür eine
|
||||||
|
// echte signierte Antwort fälschen zu müssen.
|
||||||
|
func TestTimestampSendsValidRequest(t *testing.T) {
|
||||||
|
hash := evidence.HashBytes([]byte("test-content"))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "application/timestamp-query" {
|
||||||
|
t.Errorf("Content-Type = %q, want application/timestamp-query", ct)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("read request body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := timestamp.ParseRequest(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseRequest: %v", err)
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !bytes.Equal(req.HashedMessage, hash) {
|
||||||
|
t.Errorf("HashedMessage = %x, want %x", req.HashedMessage, hash)
|
||||||
|
}
|
||||||
|
if req.Nonce == nil {
|
||||||
|
t.Error("expected a nonce to be set on the request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine echte TSA hier — nur die Request-Validierung interessiert
|
||||||
|
// dieser Test. Ein absichtlich ungültiger Response-Body lässt
|
||||||
|
// Timestamp() mit einem Parse-Fehler zurückkommen, was für diesen
|
||||||
|
// Test in Ordnung ist.
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("not a valid timestamp response"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||||
|
_, err := ts.Timestamp(context.Background(), hash)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a parse error for the fake response, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimestampRejectsNon200(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("tsa down"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||||
|
hash := evidence.HashBytes([]byte("x"))
|
||||||
|
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
|
||||||
|
t.Fatal("expected error for non-200 TSA status, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimestampRejectsMalformedResponse(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("garbage, not ASN.1 DER"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ts := evidence.NewHTTPTimestamper(server.URL)
|
||||||
|
hash := evidence.HashBytes([]byte("x"))
|
||||||
|
if _, err := ts.Timestamp(context.Background(), hash); err == nil {
|
||||||
|
t.Fatal("expected error for malformed TSA response, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimestampIntegration ruft die echte Standard-TSA (FreeTSA.org) auf.
|
||||||
|
// Läuft nur, wenn DEKLARIX_TSA_INTEGRATION gesetzt ist — kein Netzwerkzugriff
|
||||||
|
// in normalen Testläufen (siehe internal/store für dasselbe Muster mit
|
||||||
|
// DATABASE_URL).
|
||||||
|
func TestTimestampIntegration(t *testing.T) {
|
||||||
|
if os.Getenv("DEKLARIX_TSA_INTEGRATION") == "" {
|
||||||
|
t.Skip("DEKLARIX_TSA_INTEGRATION nicht gesetzt, überspringe echten TSA-Aufruf")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := evidence.HashBytes([]byte(t.Name()))
|
||||||
|
ts := evidence.NewHTTPTimestamper("")
|
||||||
|
|
||||||
|
token, err := ts.Timestamp(context.Background(), hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Timestamp: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := timestamp.Parse(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(token): %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(parsed.HashedMessage, hash) {
|
||||||
|
t.Fatalf("token hash = %x, want %x", parsed.HashedMessage, hash)
|
||||||
|
}
|
||||||
|
if parsed.Time.IsZero() {
|
||||||
|
t.Fatal("expected a non-zero timestamp time")
|
||||||
|
}
|
||||||
|
|
||||||
|
tm, err := evidence.TimestampTime(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TimestampTime: %v", err)
|
||||||
|
}
|
||||||
|
if !tm.Equal(parsed.Time) {
|
||||||
|
t.Fatalf("TimestampTime() = %v, want %v", tm, parsed.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/extract/api.go
Normal file
111
internal/extract/api.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Diese Typen bilden nur den Ausschnitt der Anthropic-Messages-API ab,
|
||||||
|
// den die Extraktion tatsächlich braucht (Tool-Use für strukturierte
|
||||||
|
// Ausgabe) — kein vollständiger API-Client.
|
||||||
|
|
||||||
|
type messageRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Messages []message `json:"messages"`
|
||||||
|
Tools []tool `json:"tools"`
|
||||||
|
ToolChoice toolChoice `json:"tool_choice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []contentBlock `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type contentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Source *imageSource `json:"source,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageSource struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
InputSchema toolInputSchema `json:"input_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolInputSchema struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Properties map[string]any `json:"properties"`
|
||||||
|
Required []string `json:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolChoice struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageResponse struct {
|
||||||
|
Content []responseBlock `json:"content"`
|
||||||
|
Error *apiError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractionArgs spiegelt extractionTool.InputSchema — das JSON, das
|
||||||
|
// die Extraktion vom Modell zurückbekommt.
|
||||||
|
type extractionArgs struct {
|
||||||
|
Consideration string `json:"gegenleistung"`
|
||||||
|
DisclosurePresent bool `json:"kennzeichnung_vorhanden"`
|
||||||
|
DisclosureWording string `json:"kennzeichnung_wortlaut"`
|
||||||
|
DisclosureBeforeCut bool `json:"kennzeichnung_vor_kuerzung"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var extractionTool = tool{
|
||||||
|
Name: "extrahiere_fakten",
|
||||||
|
Description: "Extrahiere ausschließlich beobachtbare Fakten aus Caption und Bild eines " +
|
||||||
|
"Social-Media-Beitrags für eine Kennzeichnungsprüfung. Triff KEINE rechtliche " +
|
||||||
|
"Bewertung. Ist ein Feld nicht sicher zu bestimmen, wähle den vorgesehenen " +
|
||||||
|
"Unsicherheitswert statt zu raten.",
|
||||||
|
InputSchema: toolInputSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: map[string]any{
|
||||||
|
"gegenleistung": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"bezahlt", "sachbezug", "keine", "unklar"},
|
||||||
|
"description": "Erhält der/die Postende eine Gegenleistung (Geld, Produkt, Einladung)? 'unklar' wenn nicht sicher bestimmbar.",
|
||||||
|
},
|
||||||
|
"kennzeichnung_vorhanden": map[string]any{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Enthält die Caption einen Kennzeichnungshinweis wie 'Werbung' oder 'Anzeige'?",
|
||||||
|
},
|
||||||
|
"kennzeichnung_wortlaut": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Der exakte Wortlaut der Kennzeichnung, falls vorhanden, sonst leerer String.",
|
||||||
|
},
|
||||||
|
"kennzeichnung_vor_kuerzung": map[string]any{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Ist die Kennzeichnung sichtbar, BEVOR die Plattform die Caption hinter 'mehr anzeigen' kürzt? Ohne Kürzung: true.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{
|
||||||
|
"gegenleistung",
|
||||||
|
"kennzeichnung_vorhanden",
|
||||||
|
"kennzeichnung_wortlaut",
|
||||||
|
"kennzeichnung_vor_kuerzung",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
233
internal/extract/client.go
Normal file
233
internal/extract/client.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// Package extract ist Stufe 1 aus dem Kernprinzip: es extrahiert
|
||||||
|
// ausschließlich beobachtbare Fakten aus Caption und Bild eines
|
||||||
|
// Beitrags über die Claude API. Es bewertet nichts — das Urteil fällt
|
||||||
|
// ausschließlich das Regelwerk in internal/rules.
|
||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBaseURL = "https://api.anthropic.com"
|
||||||
|
defaultModel = "claude-sonnet-5"
|
||||||
|
anthropicVersion = "2023-06-01"
|
||||||
|
|
||||||
|
// PromptVersion wird zusammen mit jeder Extraktion gespeichert
|
||||||
|
// (siehe Datenmodell: extraction.prompt_version), damit sich ein
|
||||||
|
// späteres Finding auf den exakten Prompt-Stand zurückführen lässt,
|
||||||
|
// der es erzeugt hat. Hochzählen bei jeder inhaltlichen Änderung an
|
||||||
|
// systemPrompt oder extractionTool.
|
||||||
|
PromptVersion = "v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemPrompt = `Du extrahierst ausschließlich beobachtbare Fakten aus einem Social-Media-Beitrag (Caption und Bild) für eine Kennzeichnungsprüfung nach deutschem Recht.
|
||||||
|
|
||||||
|
Du triffst KEINE rechtliche Bewertung und nennst KEINE Gesetze, Paragraphen oder Urteile — das ist nicht deine Aufgabe.
|
||||||
|
|
||||||
|
Ist ein Fakt nicht sicher aus Caption oder Bild zu bestimmen, wähle den dafür vorgesehenen Unsicherheitswert (z. B. "unklar"). Rate niemals.`
|
||||||
|
|
||||||
|
// Input ist, was Stufe 1 zur Extraktion braucht. Platform und
|
||||||
|
// Jurisdiction kommen vom Aufrufer (der Nutzer wählt Plattform und
|
||||||
|
// Rechtsordnung beim Einreichen) statt vom Modell erraten zu werden —
|
||||||
|
// aus Caption/Bild lässt sich keine Rechtsordnung ablesen.
|
||||||
|
type Input struct {
|
||||||
|
Platform string
|
||||||
|
Jurisdiction string
|
||||||
|
Caption string
|
||||||
|
ImageMediaType string // z. B. "image/jpeg", "image/png"
|
||||||
|
ImageData []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result ist die Ausgabe von Extract: die für das Regelwerk
|
||||||
|
// aufbereiteten Facts, plus RawJSON — das exakte, unveränderte JSON, das
|
||||||
|
// das Modell zurückgegeben hat. RawJSON gehört unverändert in
|
||||||
|
// extraction.payload (siehe Datenmodell); Facts ist eine abgeleitete
|
||||||
|
// Sicht darauf und nicht der Beweis-Eintrag selbst.
|
||||||
|
type Result struct {
|
||||||
|
Facts rules.Facts
|
||||||
|
RawJSON []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client ruft die Claude API zur Fakten-Extraktion auf.
|
||||||
|
type Client struct {
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option konfiguriert einen Client.
|
||||||
|
type Option func(*Client)
|
||||||
|
|
||||||
|
// WithModel überschreibt das Standardmodell.
|
||||||
|
func WithModel(model string) Option {
|
||||||
|
return func(c *Client) { c.model = model }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBaseURL überschreibt die API-Basis-URL (für Tests).
|
||||||
|
func WithBaseURL(url string) Option {
|
||||||
|
return func(c *Client) { c.baseURL = url }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHTTPClient überschreibt den verwendeten *http.Client (für Tests).
|
||||||
|
func WithHTTPClient(hc *http.Client) Option {
|
||||||
|
return func(c *Client) { c.httpClient = hc }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient erstellt einen Extraktions-Client. apiKey darf nicht leer
|
||||||
|
// sein — es gibt keinen stillen Fallback auf einen ungültigen Zustand.
|
||||||
|
func NewClient(apiKey string, opts ...Option) (*Client, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return nil, fmt.Errorf("extract: apiKey darf nicht leer sein")
|
||||||
|
}
|
||||||
|
c := &Client{
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: defaultModel,
|
||||||
|
baseURL: defaultBaseURL,
|
||||||
|
httpClient: http.DefaultClient,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(c)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelVersion ist der Modell-Identifier, der zusammen mit jeder
|
||||||
|
// Extraktion gespeichert werden sollte (siehe Datenmodell:
|
||||||
|
// extraction.model_version).
|
||||||
|
func (c *Client) ModelVersion() string { return c.model }
|
||||||
|
|
||||||
|
// Extract ruft die Claude API auf und liefert die extrahierten Fakten.
|
||||||
|
// Bei jedem Fehler (Netzwerk, API-Fehler, unerwartete Antwortform,
|
||||||
|
// ungültiger Enum-Wert) wird ein Fehler zurückgegeben statt stumm ein
|
||||||
|
// Zero-Value-Facts zu liefern — ein falsches "keine Gegenleistung" wäre
|
||||||
|
// hier schlimmer als ein sichtbarer Fehler.
|
||||||
|
func (c *Client) Extract(ctx context.Context, in Input) (Result, error) {
|
||||||
|
if in.Caption == "" && len(in.ImageData) == 0 {
|
||||||
|
return Result{}, fmt.Errorf("extract: caption und bild sind beide leer")
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []contentBlock
|
||||||
|
if len(in.ImageData) > 0 {
|
||||||
|
if in.ImageMediaType == "" {
|
||||||
|
return Result{}, fmt.Errorf("extract: ImageMediaType fehlt für vorhandenes Bild")
|
||||||
|
}
|
||||||
|
content = append(content, contentBlock{
|
||||||
|
Type: "image",
|
||||||
|
Source: &imageSource{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: in.ImageMediaType,
|
||||||
|
Data: base64.StdEncoding.EncodeToString(in.ImageData),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
content = append(content, contentBlock{Type: "text", Text: "Caption:\n" + in.Caption})
|
||||||
|
|
||||||
|
reqBody := messageRequest{
|
||||||
|
Model: c.model,
|
||||||
|
MaxTokens: 1024,
|
||||||
|
System: systemPrompt,
|
||||||
|
Messages: []message{{Role: "user", Content: content}},
|
||||||
|
Tools: []tool{extractionTool},
|
||||||
|
ToolChoice: toolChoice{Type: "tool", Name: extractionTool.Name},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("extract: request marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("extract: request bauen: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
req.Header.Set("x-api-key", c.apiKey)
|
||||||
|
req.Header.Set("anthropic-version", anthropicVersion)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("extract: request fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var msg messageResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
||||||
|
return Result{}, fmt.Errorf("extract: response decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg.Error != nil {
|
||||||
|
return Result{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message)
|
||||||
|
}
|
||||||
|
return Result{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolUse *responseBlock
|
||||||
|
for i := range msg.Content {
|
||||||
|
if msg.Content[i].Type == "tool_use" && msg.Content[i].Name == extractionTool.Name {
|
||||||
|
toolUse = &msg.Content[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolUse == nil {
|
||||||
|
return Result{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
facts, err := parseArgsToFacts(toolUse.Input, in.Platform, in.Jurisdiction)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, fmt.Errorf("extract: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{Facts: facts, RawJSON: toolUse.Input}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePayload rekonstruiert Facts aus einem zuvor gespeicherten
|
||||||
|
// RawJSON-Payload (z. B. aus extraction.payload). platform/jurisdiction
|
||||||
|
// müssen erneut mitgegeben werden — sie sind, wie bei Extract, nie Teil
|
||||||
|
// des Modell-Payloads.
|
||||||
|
func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) {
|
||||||
|
facts, err := parseArgsToFacts(payload, platform, jurisdiction)
|
||||||
|
if err != nil {
|
||||||
|
return rules.Facts{}, fmt.Errorf("extract: %w", err)
|
||||||
|
}
|
||||||
|
return facts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArgsToFacts(raw []byte, platform, jurisdiction string) (rules.Facts, error) {
|
||||||
|
var args extractionArgs
|
||||||
|
if err := json.Unmarshal(raw, &args); err != nil {
|
||||||
|
return rules.Facts{}, fmt.Errorf("tool-input parse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
consideration, err := parseConsideration(args.Consideration)
|
||||||
|
if err != nil {
|
||||||
|
return rules.Facts{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules.Facts{
|
||||||
|
Platform: platform,
|
||||||
|
Jurisdiction: jurisdiction,
|
||||||
|
Consideration: consideration,
|
||||||
|
DisclosurePresent: args.DisclosurePresent,
|
||||||
|
DisclosureWording: args.DisclosureWording,
|
||||||
|
DisclosureBeforeCut: args.DisclosureBeforeCut,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConsideration(s string) (rules.Consideration, error) {
|
||||||
|
switch rules.Consideration(s) {
|
||||||
|
case rules.ConsiderationPaid, rules.ConsiderationInKind, rules.ConsiderationNone, rules.ConsiderationUnclear:
|
||||||
|
return rules.Consideration(s), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unerwarteter gegenleistung-Wert %q vom Modell", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
225
internal/extract/client_test.go
Normal file
225
internal/extract/client_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package extract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolUseResponse baut eine minimale Anthropic-Messages-API-Antwort mit
|
||||||
|
// genau einem tool_use-Block, wie sie extract.Client erwartet.
|
||||||
|
func toolUseResponse(t *testing.T, toolName string, args any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
input, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal args: %v", err)
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "tool_use", "name": toolName, "input": json.RawMessage(input)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal response: %v", err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestClient(t *testing.T, handler http.HandlerFunc) *extract.Client {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
c, err := extract.NewClient("test-key", extract.WithBaseURL(server.URL))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClientRejectsEmptyAPIKey(t *testing.T) {
|
||||||
|
if _, err := extract.NewClient(""); err == nil {
|
||||||
|
t.Fatal("expected error for empty apiKey, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsEmptyInput(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
})
|
||||||
|
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty caption+image, got nil")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("expected no HTTP call for invalid input, but the server was called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractSuccess(t *testing.T) {
|
||||||
|
var gotBody map[string]any
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("x-api-key"); got != "test-key" {
|
||||||
|
t.Errorf("x-api-key = %q, want test-key", got)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||||
|
t.Fatalf("decode request body: %v", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
||||||
|
"gegenleistung": "bezahlt",
|
||||||
|
"kennzeichnung_vorhanden": true,
|
||||||
|
"kennzeichnung_wortlaut": "Werbung",
|
||||||
|
"kennzeichnung_vor_kuerzung": false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := c.Extract(context.Background(), extract.Input{
|
||||||
|
Platform: "instagram",
|
||||||
|
Jurisdiction: "DE",
|
||||||
|
Caption: "Schaut euch dieses Produkt an! Werbung wegen ...",
|
||||||
|
ImageMediaType: "image/jpeg",
|
||||||
|
ImageData: []byte("fake-jpeg-bytes"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := rules.Facts{
|
||||||
|
Platform: "instagram",
|
||||||
|
Jurisdiction: "DE",
|
||||||
|
Consideration: rules.ConsiderationPaid,
|
||||||
|
DisclosurePresent: true,
|
||||||
|
DisclosureWording: "Werbung",
|
||||||
|
DisclosureBeforeCut: false,
|
||||||
|
}
|
||||||
|
if got.Facts != want {
|
||||||
|
t.Fatalf("Extract().Facts = %+v, want %+v", got.Facts, want)
|
||||||
|
}
|
||||||
|
var rawArgs map[string]any
|
||||||
|
if err := json.Unmarshal(got.RawJSON, &rawArgs); err != nil {
|
||||||
|
t.Fatalf("RawJSON does not parse as JSON: %v", err)
|
||||||
|
}
|
||||||
|
if rawArgs["gegenleistung"] != "bezahlt" {
|
||||||
|
t.Fatalf("RawJSON = %s, expected it to contain the model's raw field names", got.RawJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolChoice, _ := gotBody["tool_choice"].(map[string]any)
|
||||||
|
if toolChoice["name"] != "extrahiere_fakten" {
|
||||||
|
t.Errorf("tool_choice.name = %v, want extrahiere_fakten", toolChoice["name"])
|
||||||
|
}
|
||||||
|
if gotBody["system"] == nil || gotBody["system"] == "" {
|
||||||
|
t.Error("expected a non-empty system prompt in the request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPassesThroughUnclear(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
||||||
|
"gegenleistung": "unklar",
|
||||||
|
"kennzeichnung_vorhanden": false,
|
||||||
|
"kennzeichnung_wortlaut": "",
|
||||||
|
"kennzeichnung_vor_kuerzung": false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := c.Extract(context.Background(), extract.Input{Platform: "tiktok", Caption: "..."})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract: %v", err)
|
||||||
|
}
|
||||||
|
if got.Facts.Consideration != rules.ConsiderationUnclear {
|
||||||
|
t.Fatalf("Consideration = %q, want unklar", got.Facts.Consideration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsInvalidConsiderationValue(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(toolUseResponse(t, "extrahiere_fakten", map[string]any{
|
||||||
|
"gegenleistung": "vielleicht", // nicht im Enum
|
||||||
|
"kennzeichnung_vorhanden": false,
|
||||||
|
"kennzeichnung_wortlaut": "",
|
||||||
|
"kennzeichnung_vor_kuerzung": false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
||||||
|
t.Fatal("expected error for out-of-enum gegenleistung value, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsMissingToolUseBlock(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"content":[{"type":"text","text":"kein tool_use hier"}]}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
||||||
|
t.Fatal("expected error when response has no tool_use block, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsMalformedToolInput(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"content":[{"type":"tool_use","name":"extrahiere_fakten","input":"not-an-object"}]}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"}); err == nil {
|
||||||
|
t.Fatal("expected error for malformed tool input, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePayloadRoundTrip(t *testing.T) {
|
||||||
|
payload := []byte(`{"gegenleistung":"sachbezug","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Anzeige","kennzeichnung_vor_kuerzung":true}`)
|
||||||
|
|
||||||
|
facts, err := extract.ParsePayload(payload, "tiktok", "DE")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParsePayload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := rules.Facts{
|
||||||
|
Platform: "tiktok",
|
||||||
|
Jurisdiction: "DE",
|
||||||
|
Consideration: rules.ConsiderationInKind,
|
||||||
|
DisclosurePresent: true,
|
||||||
|
DisclosureWording: "Anzeige",
|
||||||
|
DisclosureBeforeCut: true,
|
||||||
|
}
|
||||||
|
if facts != want {
|
||||||
|
t.Fatalf("ParsePayload() = %+v, want %+v", facts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePayloadRejectsInvalidConsideration(t *testing.T) {
|
||||||
|
payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)
|
||||||
|
if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil {
|
||||||
|
t.Fatal("expected error for out-of-enum stored payload, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPropagatesAPIError(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte(`{"error":{"type":"rate_limit_error","message":"too many requests"}}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := c.Extract(context.Background(), extract.Input{Platform: "instagram", Caption: "x"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-200 status, got nil")
|
||||||
|
}
|
||||||
|
if got := err.Error(); !strings.Contains(got, "too many requests") {
|
||||||
|
t.Fatalf("error %q does not mention API message", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/rules/evaluate.go
Normal file
42
internal/rules/evaluate.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
// Finding ist das Ergebnis einer einzelnen zutreffenden Regel.
|
||||||
|
type Finding struct {
|
||||||
|
RuleID string
|
||||||
|
RuleVersion int
|
||||||
|
Severity Severity
|
||||||
|
Title string
|
||||||
|
Fix string
|
||||||
|
Sources []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate prüft alle Regeln gegen f und liefert ein Finding für jede
|
||||||
|
// zutreffende Regel. Reihenfolge folgt der Reihenfolge von rules.
|
||||||
|
//
|
||||||
|
// Ist f.Consideration "unklar", wird KEINE Bewertung abgegeben —
|
||||||
|
// needsClarification ist dann true und findings ist immer leer. Das ist
|
||||||
|
// Absicht (Kernprinzip): eine unsichere Extraktion erzeugt eine
|
||||||
|
// Rückfrage an den Nutzer, niemals eine stille "keine Findings"-
|
||||||
|
// Bewertung, die wie "alles in Ordnung" aussähe.
|
||||||
|
func Evaluate(rules []Rule, f Facts) (findings []Finding, needsClarification bool) {
|
||||||
|
if f.Consideration == ConsiderationUnclear {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Jurisdiction != f.Jurisdiction {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Condition.Matches(f) {
|
||||||
|
findings = append(findings, Finding{
|
||||||
|
RuleID: r.ID,
|
||||||
|
RuleVersion: r.Version,
|
||||||
|
Severity: r.Severity,
|
||||||
|
Title: r.Title,
|
||||||
|
Fix: r.Fix,
|
||||||
|
Sources: r.Sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findings, false
|
||||||
|
}
|
||||||
29
internal/rules/facts.go
Normal file
29
internal/rules/facts.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
// Consideration ist die Gegenleistung für einen Beitrag, wie sie die
|
||||||
|
// Extraktion (Stufe 1) liefert.
|
||||||
|
type Consideration string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConsiderationPaid Consideration = "bezahlt"
|
||||||
|
ConsiderationInKind Consideration = "sachbezug"
|
||||||
|
ConsiderationNone Consideration = "keine"
|
||||||
|
ConsiderationUnclear Consideration = "unklar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Facts sind die Fakten aus der Extraktion (Stufe 1), auf denen die
|
||||||
|
// Regelauswertung (Stufe 2) urteilt. Die Extraktion liefert diese Werte,
|
||||||
|
// sie bewertet sie nicht — das Urteil fällt ausschließlich das Regelwerk.
|
||||||
|
//
|
||||||
|
// Jurisdiction ist keine vom Modell extrahierte Tatsache (aus Caption/
|
||||||
|
// Bild lässt sich keine Rechtsordnung ablesen), sondern kommt vom
|
||||||
|
// Aufrufer — analog zu Platform. Aktuell ist "DE" der einzig unterstützte
|
||||||
|
// Wert; siehe Rule.Jurisdiction.
|
||||||
|
type Facts struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Jurisdiction string `json:"jurisdiction"`
|
||||||
|
Consideration Consideration `json:"consideration"`
|
||||||
|
DisclosurePresent bool `json:"disclosure_present"`
|
||||||
|
DisclosureWording string `json:"disclosure_wording"`
|
||||||
|
DisclosureBeforeCut bool `json:"disclosure_before_cut"`
|
||||||
|
}
|
||||||
106
internal/rules/golden_test.go
Normal file
106
internal/rules/golden_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goldenCase spiegelt eine Datei aus testdata/golden/: die extrahierten
|
||||||
|
// Fakten eines Beispielbeitrags plus die Findings, die das Regelwerk
|
||||||
|
// dafür liefern muss. Das ist das eigentliche Asset des Projekts, nicht
|
||||||
|
// die UI — siehe CLAUDE.md.
|
||||||
|
type goldenCase struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Facts rules.Facts `json:"facts"`
|
||||||
|
ExpectedFindings []goldenFinding `json:"expected_findings"`
|
||||||
|
ExpectNeedsClarification bool `json:"expect_needs_clarification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type goldenFinding struct {
|
||||||
|
RuleID string `json:"rule_id"`
|
||||||
|
RuleVersion int `json:"rule_version"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
rulesDir = "../../rules"
|
||||||
|
goldenDir = "../../testdata/golden"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGolden(t *testing.T) {
|
||||||
|
ruleSet, err := rules.Load(os.DirFS(rulesDir))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load rules: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(goldenDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read golden dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found++
|
||||||
|
entry := entry
|
||||||
|
t.Run(entry.Name(), func(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(goldenDir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", entry.Name(), err)
|
||||||
|
}
|
||||||
|
var gc goldenCase
|
||||||
|
if err := json.Unmarshal(data, &gc); err != nil {
|
||||||
|
t.Fatalf("parse %s: %v", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, needsClarification := rules.Evaluate(ruleSet, gc.Facts)
|
||||||
|
|
||||||
|
if needsClarification != gc.ExpectNeedsClarification {
|
||||||
|
t.Fatalf("%s: needsClarification = %v, want %v", gc.Name, needsClarification, gc.ExpectNeedsClarification)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotKeys := make([]string, 0, len(got))
|
||||||
|
for _, f := range got {
|
||||||
|
gotKeys = append(gotKeys, findingKey(f.RuleID, f.RuleVersion, string(f.Severity)))
|
||||||
|
}
|
||||||
|
wantKeys := make([]string, 0, len(gc.ExpectedFindings))
|
||||||
|
for _, ef := range gc.ExpectedFindings {
|
||||||
|
wantKeys = append(wantKeys, findingKey(ef.RuleID, ef.RuleVersion, ef.Severity))
|
||||||
|
}
|
||||||
|
sort.Strings(gotKeys)
|
||||||
|
sort.Strings(wantKeys)
|
||||||
|
|
||||||
|
if !equalStrings(gotKeys, wantKeys) {
|
||||||
|
t.Fatalf("%s: got findings %v, want %v", gc.Name, gotKeys, wantKeys)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if found == 0 {
|
||||||
|
t.Fatal("no golden cases found in " + goldenDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findingKey(ruleID string, version int, severity string) string {
|
||||||
|
return ruleID + "/" + strconv.Itoa(version) + "/" + severity
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
52
internal/rules/loader.go
Normal file
52
internal/rules/loader.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load liest alle *.yaml-Dateien aus fsys (nicht rekursiv) und parst sie
|
||||||
|
// als Regeln. Ein Fehler in einer Datei (Parse-Fehler, fehlende ID/
|
||||||
|
// Version) bricht das Laden komplett ab, statt die Datei stillschweigend
|
||||||
|
// zu überspringen — ein halb geladenes Regelwerk ist gefährlicher als
|
||||||
|
// ein Start, der mit einem klaren Fehler abbricht.
|
||||||
|
func Load(fsys fs.FS) ([]Rule, error) {
|
||||||
|
entries, err := fs.ReadDir(fsys, ".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rules: read dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
var result []Rule
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(fsys, entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("rules: read %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
var r Rule
|
||||||
|
if err := yaml.Unmarshal(data, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("rules: parse %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
if r.ID == "" {
|
||||||
|
return nil, fmt.Errorf("rules: %s: missing id", entry.Name())
|
||||||
|
}
|
||||||
|
if r.Version == 0 {
|
||||||
|
return nil, fmt.Errorf("rules: %s: missing version", entry.Name())
|
||||||
|
}
|
||||||
|
if r.Jurisdiction == "" {
|
||||||
|
return nil, fmt.Errorf("rules: %s: missing land (jurisdiction)", entry.Name())
|
||||||
|
}
|
||||||
|
if seen[r.ID] {
|
||||||
|
return nil, fmt.Errorf("rules: %s: duplicate rule id %s", entry.Name(), r.ID)
|
||||||
|
}
|
||||||
|
seen[r.ID] = true
|
||||||
|
result = append(result, r)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
62
internal/rules/rule.go
Normal file
62
internal/rules/rule.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
// Severity ist die Schwere eines Findings.
|
||||||
|
type Severity string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SeverityLow Severity = "niedrig"
|
||||||
|
SeverityMedium Severity = "mittel"
|
||||||
|
SeverityHigh Severity = "hoch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule ist eine versionierte Regel aus einer YAML-Datei in rules/.
|
||||||
|
// Regel-IDs werden nie umbenannt oder wiederverwendet — Änderungen an
|
||||||
|
// einer Regel erhöhen die Version.
|
||||||
|
//
|
||||||
|
// Jurisdiction ordnet eine Regel genau einer Rechtsordnung zu (aktuell
|
||||||
|
// nur "DE" — deutsche Rechtslage zuerst). Damit lassen sich künftige
|
||||||
|
// AT/CH-Regeln als zusätzliche Dateien ergänzen, ohne bestehende Regeln
|
||||||
|
// anzufassen: eine Regel gilt nie für mehrere Rechtsordnungen gleichzeitig,
|
||||||
|
// auch wenn sich Gesetzestexte ähneln — jede Rechtsordnung bekommt ihre
|
||||||
|
// eigene, einzeln geprüfte Fundstelle.
|
||||||
|
type Rule struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Jurisdiction string `yaml:"land"`
|
||||||
|
Title string `yaml:"titel"`
|
||||||
|
Condition Condition `yaml:"bedingung"`
|
||||||
|
Severity Severity `yaml:"schwere"`
|
||||||
|
Sources []string `yaml:"fundstelle"`
|
||||||
|
Fix string `yaml:"korrektur"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition ist eine flache UND-Bedingung über Facts. Ein nil/leeres
|
||||||
|
// Feld bedeutet "keine Einschränkung durch dieses Feld".
|
||||||
|
type Condition struct {
|
||||||
|
Consideration []Consideration `yaml:"gegenleistung,omitempty"`
|
||||||
|
DisclosurePresent *bool `yaml:"kennzeichnung_vorhanden,omitempty"`
|
||||||
|
DisclosureBeforeCut *bool `yaml:"kennzeichnung_vor_kuerzung,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches prüft, ob f alle gesetzten Bedingungsfelder erfüllt.
|
||||||
|
func (c Condition) Matches(f Facts) bool {
|
||||||
|
if len(c.Consideration) > 0 {
|
||||||
|
found := false
|
||||||
|
for _, allowed := range c.Consideration {
|
||||||
|
if allowed == f.Consideration {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.DisclosurePresent != nil && *c.DisclosurePresent != f.DisclosurePresent {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.DisclosureBeforeCut != nil && *c.DisclosureBeforeCut != f.DisclosureBeforeCut {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
133
internal/rules/rule_test.go
Normal file
133
internal/rules/rule_test.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package rules_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadRejectsMissingID(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"bad.yaml": &fstest.MapFile{Data: []byte("version: 1\ntitel: x\nschwere: hoch\n")},
|
||||||
|
}
|
||||||
|
if _, err := rules.Load(fsys); err == nil {
|
||||||
|
t.Fatal("expected error for rule without id, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMissingVersion(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\ntitel: x\nschwere: hoch\n")},
|
||||||
|
}
|
||||||
|
if _, err := rules.Load(fsys); err == nil {
|
||||||
|
t.Fatal("expected error for rule without version, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsMissingJurisdiction(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"bad.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\ntitel: x\nschwere: hoch\n")},
|
||||||
|
}
|
||||||
|
if _, err := rules.Load(fsys); err == nil {
|
||||||
|
t.Fatal("expected error for rule without land (jurisdiction), got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsDuplicateID(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
|
||||||
|
"b.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 2\nland: DE\ntitel: y\nschwere: hoch\n")},
|
||||||
|
}
|
||||||
|
if _, err := rules.Load(fsys); err == nil {
|
||||||
|
t.Fatal("expected error for duplicate rule id, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadIgnoresNonYAMLFiles(t *testing.T) {
|
||||||
|
fsys := fstest.MapFS{
|
||||||
|
"a.yaml": &fstest.MapFile{Data: []byte("id: WK-999\nversion: 1\nland: DE\ntitel: x\nschwere: hoch\n")},
|
||||||
|
"README.md": &fstest.MapFile{Data: []byte("not a rule")},
|
||||||
|
}
|
||||||
|
got, err := rules.Load(fsys)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConditionMatches(t *testing.T) {
|
||||||
|
yes := true
|
||||||
|
no := false
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cond rules.Condition
|
||||||
|
fact rules.Facts
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "consideration list matches",
|
||||||
|
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||||
|
fact: rules.Facts{Consideration: rules.ConsiderationPaid},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "consideration list does not match",
|
||||||
|
cond: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||||
|
fact: rules.Facts{Consideration: rules.ConsiderationNone},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disclosure present must match",
|
||||||
|
cond: rules.Condition{DisclosurePresent: &no},
|
||||||
|
fact: rules.Facts{DisclosurePresent: true},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unset fields impose no constraint",
|
||||||
|
cond: rules.Condition{},
|
||||||
|
fact: rules.Facts{Consideration: rules.ConsiderationUnclear},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all constraints must hold (AND)",
|
||||||
|
cond: rules.Condition{
|
||||||
|
Consideration: []rules.Consideration{rules.ConsiderationPaid},
|
||||||
|
DisclosurePresent: &yes,
|
||||||
|
DisclosureBeforeCut: &no,
|
||||||
|
},
|
||||||
|
fact: rules.Facts{Consideration: rules.ConsiderationPaid, DisclosurePresent: true, DisclosureBeforeCut: false},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := tc.cond.Matches(tc.fact); got != tc.want {
|
||||||
|
t.Fatalf("Matches() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateFiltersByJurisdiction(t *testing.T) {
|
||||||
|
rule := rules.Rule{
|
||||||
|
ID: "WK-TEST",
|
||||||
|
Version: 1,
|
||||||
|
Jurisdiction: "AT",
|
||||||
|
Condition: rules.Condition{Consideration: []rules.Consideration{rules.ConsiderationPaid}},
|
||||||
|
Severity: rules.SeverityHigh,
|
||||||
|
}
|
||||||
|
fact := rules.Facts{Jurisdiction: "DE", Consideration: rules.ConsiderationPaid}
|
||||||
|
|
||||||
|
findings, needsClarification := rules.Evaluate([]rules.Rule{rule}, fact)
|
||||||
|
if needsClarification {
|
||||||
|
t.Fatal("needsClarification = true, want false")
|
||||||
|
}
|
||||||
|
if len(findings) != 0 {
|
||||||
|
t.Fatalf("expected no findings for a rule from a different jurisdiction, got %v", findings)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
internal/store/account.go
Normal file
40
internal/store/account.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
||||||
|
// eigene Organisation). Jeder Beitrag gehört genau einem Account.
|
||||||
|
type Account struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAccount legt einen neuen Mandanten an.
|
||||||
|
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||||
|
var a Account
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO account (name) VALUES ($1)
|
||||||
|
RETURNING id, name, created_at
|
||||||
|
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccount liest einen Mandanten anhand seiner ID.
|
||||||
|
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||||
|
var a Account
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, name, created_at FROM account WHERE id = $1
|
||||||
|
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, fmt.Errorf("store: get account: %w", err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
116
internal/store/auth_test.go
Normal file
116
internal/store/auth_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccountCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetAccount(ctx, acc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.Name != "Beispiel Agentur GmbH" {
|
||||||
|
t.Fatalf("Name = %q, want Beispiel Agentur GmbH", got.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
user, err := s.CreateUser(ctx, accID, "team@example.com", "bcrypt-hash", "agentur")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byEmail, err := s.GetUserByEmail(ctx, "team@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserByEmail: %v", err)
|
||||||
|
}
|
||||||
|
if byEmail.ID != user.ID {
|
||||||
|
t.Fatalf("GetUserByEmail returned a different user than CreateUser")
|
||||||
|
}
|
||||||
|
|
||||||
|
byID, err := s.GetUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser: %v", err)
|
||||||
|
}
|
||||||
|
if byID.Email != "team@example.com" || byID.AccountID != accID {
|
||||||
|
t.Fatalf("GetUser = %+v, unerwartete Werte", byID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUserByEmailNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.GetUserByEmail(context.Background(), "nichtvorhanden@example.com")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserEmailIsUnique(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash1", "creator"); err != nil {
|
||||||
|
t.Fatalf("CreateUser (1): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateUser(ctx, accID, "doppelt@example.com", "hash2", "creator"); err == nil {
|
||||||
|
t.Fatal("expected error for a duplicate email, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
user, err := s.CreateUser(ctx, accID, "session@example.com", "hash", "marke")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(time.Hour).Truncate(time.Millisecond)
|
||||||
|
sess, err := s.CreateSession(ctx, "test-token-123", user.ID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSession(ctx, sess.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
if got.UserID != user.ID {
|
||||||
|
t.Fatalf("UserID = %q, want %q", got.UserID, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteSession(ctx, sess.Token); err != nil {
|
||||||
|
t.Fatalf("DeleteSession: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetSession(ctx, sess.Token); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSessionNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
_, err := s.GetSession(context.Background(), "unbekanntes-token")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
211
internal/store/crud_test.go
Normal file
211
internal/store/crud_test.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
url := testDatabaseURL(t)
|
||||||
|
if err := store.Migrate(url); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
s, err := store.Open(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// testAccountID legt einen Mandanten an und liefert dessen ID — jede
|
||||||
|
// Submission braucht seit der Auth-Migration einen Account.
|
||||||
|
func testAccountID(t *testing.T, s *store.Store) string {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := s.CreateAccount(context.Background(), "Test-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
return acc.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmissionCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
created, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "Werbung fuer ein Produkt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
if created.Status != "draft" {
|
||||||
|
t.Fatalf("Status = %q, want draft", created.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetSubmission(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubmission: %v", err)
|
||||||
|
}
|
||||||
|
if got.Platform != "instagram" || got.Caption != "Werbung fuer ein Produkt" {
|
||||||
|
t.Fatalf("GetSubmission = %+v, unerwartete Werte", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SetSubmissionStatus(ctx, created.ID, "checked"); err != nil {
|
||||||
|
t.Fatalf("SetSubmissionStatus: %v", err)
|
||||||
|
}
|
||||||
|
got, err = s.GetSubmission(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubmission nach Statuswechsel: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != "checked" {
|
||||||
|
t.Fatalf("Status nach SetSubmissionStatus = %q, want checked", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSubmissionNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
if _, err := s.GetSubmission(context.Background(), "00000000-0000-0000-0000-000000000000"); err == nil {
|
||||||
|
t.Fatal("expected error for a nonexistent submission, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSubmissionStatusNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
err := s.SetSubmissionStatus(context.Background(), "00000000-0000-0000-0000-000000000000", "checked")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when updating a nonexistent submission, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractionCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
sub, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "...")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := []byte(`{"gegenleistung":"bezahlt"}`)
|
||||||
|
ext, err := s.CreateExtraction(ctx, sub.ID, payload, "claude-sonnet-5", "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateExtraction: %v", err)
|
||||||
|
}
|
||||||
|
// JSONB normalisiert die Textform (z. B. Leerzeichen nach ':'), der Inhalt
|
||||||
|
// muss aber semantisch identisch bleiben — kein Byte-Vergleich.
|
||||||
|
var gotPayload, wantPayload map[string]any
|
||||||
|
if err := json.Unmarshal(ext.Payload, &gotPayload); err != nil {
|
||||||
|
t.Fatalf("unmarshal stored payload: %v", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payload, &wantPayload); err != nil {
|
||||||
|
t.Fatalf("unmarshal input payload: %v", err)
|
||||||
|
}
|
||||||
|
if gotPayload["gegenleistung"] != wantPayload["gegenleistung"] {
|
||||||
|
t.Fatalf("stored payload = %v, want %v", gotPayload, wantPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestExtraction(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestExtraction: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != ext.ID {
|
||||||
|
t.Fatalf("GetLatestExtraction returned a different row than CreateExtraction")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine zweite Extraktion (erneute Pruefung) muss die "latest" sein.
|
||||||
|
payload2 := []byte(`{"gegenleistung":"keine"}`)
|
||||||
|
ext2, err := s.CreateExtraction(ctx, sub.ID, payload2, "claude-sonnet-5", "v1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateExtraction (2): %v", err)
|
||||||
|
}
|
||||||
|
got, err = s.GetLatestExtraction(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestExtraction (2): %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != ext2.ID {
|
||||||
|
t.Fatalf("GetLatestExtraction did not return the most recent extraction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingCRUDAndSupersedes(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
old, err := s.CreateFinding(ctx, sub.ID, nil, "WK-004", 1, "hoch", "alte Fassung", "alte Korrektur", []string{"§ 5a Abs. 4 UWG"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFinding (old): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.ListCurrentFindings(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCurrentFindings: %v", err)
|
||||||
|
}
|
||||||
|
if len(current) != 1 || current[0].ID != old.ID {
|
||||||
|
t.Fatalf("expected exactly the old finding before any correction, got %+v", current)
|
||||||
|
}
|
||||||
|
if len(current[0].Sources) != 1 || current[0].Sources[0] != "§ 5a Abs. 4 UWG" {
|
||||||
|
t.Fatalf("Sources = %v, want [§ 5a Abs. 4 UWG]", current[0].Sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Korrektur: neue Zeile, die die alte per supersedes ersetzt.
|
||||||
|
_, err = s.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources, supersedes)
|
||||||
|
VALUES ($1, 'WK-004', 2, 'hoch', 'korrigierte Fassung', 'neue Korrektur', '{}', $2)
|
||||||
|
`, sub.ID, old.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert superseding finding: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err = s.ListCurrentFindings(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCurrentFindings nach Korrektur: %v", err)
|
||||||
|
}
|
||||||
|
if len(current) != 1 {
|
||||||
|
t.Fatalf("expected exactly one current finding after a correction, got %d: %+v", len(current), current)
|
||||||
|
}
|
||||||
|
if current[0].Title != "korrigierte Fassung" {
|
||||||
|
t.Fatalf("expected the corrected finding to be current, got %+v", current[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvidencePackageCRUD(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
sub, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "...")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := []byte("fake-rfc3161-token-bytes")
|
||||||
|
pkg, err := s.CreateEvidencePackage(ctx, sub.ID, "/var/lib/deklarix/dossiers/x.pdf", "deadbeef", token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateEvidencePackage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestEvidencePackage(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestEvidencePackage: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != pkg.ID || string(got.TimestampToken) != string(token) {
|
||||||
|
t.Fatalf("GetLatestEvidencePackage = %+v, unerwartete Werte", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append-only: ein UPDATE muss vom Trigger abgelehnt werden.
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE evidence_package SET sha256 = 'geaendert' WHERE id = $1`, pkg.ID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on evidence_package to be rejected, but it succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/store/evidence.go
Normal file
54
internal/store/evidence.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvidencePackage ist das Ergebnis der Archivierung eines Beitrags:
|
||||||
|
// Dossier-Pfad, Hash der kanonisierten Metadaten und RFC-3161-Token.
|
||||||
|
// Append-only: siehe Migration.
|
||||||
|
type EvidencePackage struct {
|
||||||
|
ID string
|
||||||
|
SubmissionID string
|
||||||
|
DossierPath string
|
||||||
|
SHA256 string
|
||||||
|
TimestampToken []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEvidencePackage speichert ein EvidencePackage.
|
||||||
|
func (s *Store) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (EvidencePackage, error) {
|
||||||
|
var e EvidencePackage
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO evidence_package (submission_id, dossier_path, sha256, timestamp_token)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, submission_id, dossier_path, sha256, timestamp_token, created_at
|
||||||
|
`, submissionID, dossierPath, sha256Hex, timestampToken).Scan(
|
||||||
|
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return EvidencePackage{}, fmt.Errorf("store: create evidence package: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestEvidencePackage liefert das zuletzt erzeugte EvidencePackage
|
||||||
|
// für einen Beitrag.
|
||||||
|
func (s *Store) GetLatestEvidencePackage(ctx context.Context, submissionID string) (EvidencePackage, error) {
|
||||||
|
var e EvidencePackage
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, submission_id, dossier_path, sha256, timestamp_token, created_at
|
||||||
|
FROM evidence_package
|
||||||
|
WHERE submission_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, submissionID).Scan(
|
||||||
|
&e.ID, &e.SubmissionID, &e.DossierPath, &e.SHA256, &e.TimestampToken, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return EvidencePackage{}, fmt.Errorf("store: get latest evidence package: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
56
internal/store/extraction.go
Normal file
56
internal/store/extraction.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extraction ist das Ergebnis der Stufe-1-Extraktion für einen Beitrag.
|
||||||
|
// Payload ist das rohe, vom Modell gelieferte JSON — nicht eine
|
||||||
|
// abgeleitete Repräsentation. Append-only: siehe Migration.
|
||||||
|
type Extraction struct {
|
||||||
|
ID string
|
||||||
|
SubmissionID string
|
||||||
|
Payload []byte
|
||||||
|
ModelVersion string
|
||||||
|
PromptVersion string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateExtraction speichert eine Extraktion. payload ist das rohe
|
||||||
|
// JSON, wie es das Modell zurückgegeben hat (siehe extract.Result.RawJSON).
|
||||||
|
func (s *Store) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (Extraction, error) {
|
||||||
|
var e Extraction
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO extraction (submission_id, payload, model_version, prompt_version)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, submission_id, payload, model_version, prompt_version, created_at
|
||||||
|
`, submissionID, payload, modelVersion, promptVersion).Scan(
|
||||||
|
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Extraction{}, fmt.Errorf("store: create extraction: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestExtraction liest die zuletzt erzeugte Extraktion für einen
|
||||||
|
// Beitrag (append-only: es kann mehrere geben, z. B. bei einer erneuten
|
||||||
|
// Prüfung — die aktuellste zählt).
|
||||||
|
func (s *Store) GetLatestExtraction(ctx context.Context, submissionID string) (Extraction, error) {
|
||||||
|
var e Extraction
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, submission_id, payload, model_version, prompt_version, created_at
|
||||||
|
FROM extraction
|
||||||
|
WHERE submission_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, submissionID).Scan(
|
||||||
|
&e.ID, &e.SubmissionID, &e.Payload, &e.ModelVersion, &e.PromptVersion, &e.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Extraction{}, fmt.Errorf("store: get latest extraction: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
74
internal/store/finding.go
Normal file
74
internal/store/finding.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Finding ist das Ergebnis einer Regel für einen Beitrag. Append-only:
|
||||||
|
// siehe Migration. ExtractionID ist optional (nil wenn ein Finding nicht
|
||||||
|
// direkt aus einer Extraktion, sondern z. B. manuell erzeugt wurde).
|
||||||
|
// Title/Fix/Sources sind zum Zeitpunkt der Regelauswertung fixiert
|
||||||
|
// gespeichert (nicht nur rule_id/rule_version referenziert), weil ein
|
||||||
|
// späteres Update der Regel-YAML eine ältere Version sonst nicht mehr
|
||||||
|
// nachträglich auflösen könnte — der Wortlaut zum Zeitpunkt des
|
||||||
|
// Findings ist der Beweis, kein Verweis darauf.
|
||||||
|
type Finding struct {
|
||||||
|
ID string
|
||||||
|
SubmissionID string
|
||||||
|
ExtractionID *string
|
||||||
|
RuleID string
|
||||||
|
RuleVersion int
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Fix string
|
||||||
|
Sources []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFinding speichert ein Finding.
|
||||||
|
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
|
||||||
|
var f Finding
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources, created_at
|
||||||
|
`, submissionID, extractionID, ruleID, ruleVersion, severity, title, fix, sources).Scan(
|
||||||
|
&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Finding{}, fmt.Errorf("store: create finding: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCurrentFindings liefert die aktuell gültigen Findings eines
|
||||||
|
// Beitrags — Zeilen, die von keiner anderen Zeile per supersedes
|
||||||
|
// ersetzt wurden (siehe Migrationskommentar zu finding.supersedes).
|
||||||
|
func (s *Store) ListCurrentFindings(ctx context.Context, submissionID string) ([]Finding, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT f.id, f.submission_id, f.extraction_id, f.rule_id, f.rule_version, f.severity, f.title, f.fix, f.sources, f.created_at
|
||||||
|
FROM finding f
|
||||||
|
WHERE f.submission_id = $1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
|
||||||
|
ORDER BY f.created_at
|
||||||
|
`, submissionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list current findings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var findings []Finding
|
||||||
|
for rows.Next() {
|
||||||
|
var f Finding
|
||||||
|
if err := rows.Scan(&f.ID, &f.SubmissionID, &f.ExtractionID, &f.RuleID, &f.RuleVersion, &f.Severity, &f.Title, &f.Fix, &f.Sources, &f.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan finding: %w", err)
|
||||||
|
}
|
||||||
|
findings = append(findings, f)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list current findings: %w", err)
|
||||||
|
}
|
||||||
|
return findings, nil
|
||||||
|
}
|
||||||
48
internal/store/migrate.go
Normal file
48
internal/store/migrate.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang-migrate/migrate/v4"
|
||||||
|
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||||
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// Migrate wendet alle ausstehenden Migrationen aus migrations/ an.
|
||||||
|
// Die Migrationen sind im Binary eingebettet (go:embed), damit Deklarix
|
||||||
|
// weiterhin als einzelnes Binary lauffähig bleibt.
|
||||||
|
func Migrate(databaseURL string) error {
|
||||||
|
db, err := sql.Open("pgx", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: open db for migration: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: migration driver: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
source, err := iofs.New(migrationsFS, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: migration source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := migrate.NewWithInstance("iofs", source, "pgx5", driver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: migrate init: %w", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
|
||||||
|
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("store: migrate up: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
11
internal/store/migrations/0001_init.down.sql
Normal file
11
internal/store/migrations/0001_init.down.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package;
|
||||||
|
DROP TRIGGER IF EXISTS finding_append_only ON finding;
|
||||||
|
DROP TRIGGER IF EXISTS extraction_append_only ON extraction;
|
||||||
|
DROP FUNCTION IF EXISTS forbid_update_delete();
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS participant;
|
||||||
|
DROP TABLE IF EXISTS evidence_package;
|
||||||
|
DROP TABLE IF EXISTS finding;
|
||||||
|
DROP TABLE IF EXISTS extraction;
|
||||||
|
DROP TABLE IF EXISTS asset;
|
||||||
|
DROP TABLE IF EXISTS submission;
|
||||||
89
internal/store/migrations/0001_init.up.sql
Normal file
89
internal/store/migrations/0001_init.up.sql
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE submission (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||||
|
post_type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||||
|
caption TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE asset (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten.
|
||||||
|
CREATE TABLE extraction (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
model_version TEXT NOT NULL,
|
||||||
|
prompt_version TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile.
|
||||||
|
-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt
|
||||||
|
-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das
|
||||||
|
-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt,
|
||||||
|
-- siehe Index unten für den Anti-Join.
|
||||||
|
CREATE TABLE finding (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
extraction_id UUID REFERENCES extraction (id),
|
||||||
|
rule_id TEXT NOT NULL,
|
||||||
|
rule_version INTEGER NOT NULL,
|
||||||
|
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
supersedes UUID REFERENCES finding (id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||||
|
|
||||||
|
-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only.
|
||||||
|
CREATE TABLE evidence_package (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
dossier_path TEXT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
timestamp_token BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE participant (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||||
|
CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER extraction_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON extraction
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TRIGGER finding_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON finding
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
|
|
||||||
|
CREATE TRIGGER evidence_package_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON evidence_package
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE finding DROP COLUMN title;
|
||||||
|
ALTER TABLE finding DROP COLUMN fix;
|
||||||
|
ALTER TABLE finding DROP COLUMN sources;
|
||||||
|
ALTER TABLE finding ADD COLUMN message TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE finding ALTER COLUMN message DROP DEFAULT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- finding.message war ein einzelnes Freitextfeld. Fürs Dossier brauchen
|
||||||
|
-- wir Titel, Korrekturvorschlag und Fundstellen aber strukturiert und
|
||||||
|
-- getrennt (siehe internal/dossier), sonst müsste die Anzeige Text
|
||||||
|
-- wieder auseinanderparsen, den wir selbst zusammengefügt haben.
|
||||||
|
ALTER TABLE finding DROP COLUMN message;
|
||||||
|
ALTER TABLE finding ADD COLUMN title TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE finding ADD COLUMN fix TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE finding ADD COLUMN sources TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE finding ALTER COLUMN title DROP DEFAULT;
|
||||||
|
ALTER TABLE finding ALTER COLUMN fix DROP DEFAULT;
|
||||||
|
ALTER TABLE finding ALTER COLUMN sources DROP DEFAULT;
|
||||||
4
internal/store/migrations/0003_auth.down.sql
Normal file
4
internal/store/migrations/0003_auth.down.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE submission DROP COLUMN account_id;
|
||||||
|
DROP TABLE session;
|
||||||
|
DROP TABLE app_user;
|
||||||
|
DROP TABLE account;
|
||||||
33
internal/store/migrations/0003_auth.up.sql
Normal file
33
internal/store/migrations/0003_auth.up.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Mandantentrennung: ein account ist der Mandant (Creator, Agentur,
|
||||||
|
-- Marke oder Kanzlei als eigene Organisation), app_user ist ein Login
|
||||||
|
-- innerhalb eines accounts. session ist bewusst eine eigene Tabelle
|
||||||
|
-- statt signierter, zustandsloser Tokens — ein Logout muss eine
|
||||||
|
-- Sitzung wirklich beenden können, nicht nur clientseitig "vergessen"
|
||||||
|
-- werden.
|
||||||
|
CREATE TABLE account (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- "app_user" statt "user", da user ein reserviertes Wort in SQL ist.
|
||||||
|
CREATE TABLE app_user (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE session (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES app_user (id),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Jeder Beitrag gehört ab jetzt zu genau einem Mandanten. NOT NULL ohne
|
||||||
|
-- Default ist hier sicher, weil noch keine Submission-Zeilen aus der
|
||||||
|
-- Zeit vor Auth existieren (kein Kunde live).
|
||||||
|
ALTER TABLE submission ADD COLUMN account_id UUID NOT NULL REFERENCES account (id);
|
||||||
62
internal/store/session.go
Normal file
62
internal/store/session.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session ist eine angemeldete Sitzung. token ist der Primärschlüssel
|
||||||
|
// (das Cookie-Geheimnis selbst) — es gibt bewusst keine separate ID,
|
||||||
|
// eine Session wird immer über ihren Token nachgeschlagen.
|
||||||
|
type Session struct {
|
||||||
|
Token string
|
||||||
|
UserID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession speichert eine neue Sitzung. token muss bereits ein
|
||||||
|
// kryptographisch zufälliges Geheimnis sein (siehe internal/auth).
|
||||||
|
func (s *Store) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (Session, error) {
|
||||||
|
var sess Session
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO session (token, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING token, user_id, expires_at, created_at
|
||||||
|
`, token, userID, expiresAt).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: create session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession liest eine Sitzung anhand ihres Tokens. Liefert
|
||||||
|
// ErrNotFound, wenn der Token unbekannt ist — abgelaufene Sitzungen
|
||||||
|
// werden NICHT automatisch als "nicht gefunden" behandelt, das prüft
|
||||||
|
// der Aufrufer über ExpiresAt (siehe internal/auth), damit die
|
||||||
|
// Unterscheidung "gab es nie" vs. "ist abgelaufen" nicht verloren geht.
|
||||||
|
func (s *Store) GetSession(ctx context.Context, token string) (Session, error) {
|
||||||
|
var sess Session
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT token, user_id, expires_at, created_at FROM session WHERE token = $1
|
||||||
|
`, token).Scan(&sess.Token, &sess.UserID, &sess.ExpiresAt, &sess.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Session{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, fmt.Errorf("store: get session: %w", err)
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession beendet eine Sitzung (Logout).
|
||||||
|
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
if _, err := s.Pool.Exec(ctx, `DELETE FROM session WHERE token = $1`, token); err != nil {
|
||||||
|
return fmt.Errorf("store: delete session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
31
internal/store/store.go
Normal file
31
internal/store/store.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store hält den Verbindungspool zur Postgres-Datenbank.
|
||||||
|
type Store struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open baut den Verbindungspool auf und prüft ihn mit einem Ping.
|
||||||
|
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open pool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Ping(ctx); err != nil {
|
||||||
|
pool.Close()
|
||||||
|
return nil, fmt.Errorf("store: ping: %w", err)
|
||||||
|
}
|
||||||
|
return &Store{Pool: pool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close gibt den Verbindungspool frei.
|
||||||
|
func (s *Store) Close() {
|
||||||
|
s.Pool.Close()
|
||||||
|
}
|
||||||
91
internal/store/store_test.go
Normal file
91
internal/store/store_test.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Diese Tests brauchen eine laufende Postgres-Instanz und werden ohne
|
||||||
|
// DATABASE_URL übersprungen, statt eine Verbindung vorzutäuschen.
|
||||||
|
func testDatabaseURL(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("DATABASE_URL nicht gesetzt, überspringe Store-Integrationstest")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateAndOpen(t *testing.T) {
|
||||||
|
url := testDatabaseURL(t)
|
||||||
|
|
||||||
|
if err := store.Migrate(url); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := store.Open(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
var tableCount int
|
||||||
|
err = s.Pool.QueryRow(context.Background(), `
|
||||||
|
SELECT count(*) FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = ANY($1)
|
||||||
|
`, []string{"submission", "asset", "extraction", "finding", "evidence_package", "participant"}).Scan(&tableCount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query tables: %v", err)
|
||||||
|
}
|
||||||
|
if tableCount != 6 {
|
||||||
|
t.Fatalf("expected 6 tables, got %d", tableCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindingIsAppendOnly(t *testing.T) {
|
||||||
|
url := testDatabaseURL(t)
|
||||||
|
|
||||||
|
if err := store.Migrate(url); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := store.Open(context.Background(), url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
acc, err := s.CreateAccount(ctx, "Test-Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var submissionID string
|
||||||
|
err = s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO submission (account_id, platform, post_type) VALUES ($1, 'instagram', 'reel')
|
||||||
|
RETURNING id
|
||||||
|
`, acc.ID).Scan(&submissionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert submission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var findingID string
|
||||||
|
err = s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO finding (submission_id, rule_id, rule_version, severity, title, fix, sources)
|
||||||
|
VALUES ($1, 'WK-004', 3, 'hoch', 'Testfeststellung', 'Testkorrektur', '{}')
|
||||||
|
RETURNING id
|
||||||
|
`, submissionID).Scan(&findingID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert finding: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.Pool.Exec(ctx, `UPDATE finding SET title = 'geändert' WHERE id = $1`, findingID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected UPDATE on finding to be rejected, but it succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
79
internal/store/submission.go
Normal file
79
internal/store/submission.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Submission ist ein eingereichter Beitrag. AccountID ist der Mandant,
|
||||||
|
// dem der Beitrag gehört (Mandantentrennung) — jede Abfrage, die einen
|
||||||
|
// Beitrag ausliefert, muss AccountID gegen den angemeldeten Account
|
||||||
|
// prüfen (siehe internal/web-Middleware), store selbst erzwingt das
|
||||||
|
// nicht auf Zeilenebene.
|
||||||
|
type Submission struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Platform string
|
||||||
|
PostType string
|
||||||
|
Caption string
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubmission legt einen neuen Beitrag für einen Mandanten an
|
||||||
|
// (Status "draft").
|
||||||
|
func (s *Store) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (Submission, error) {
|
||||||
|
var sub Submission
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO submission (account_id, platform, post_type, caption)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, account_id, platform, post_type, caption, status, created_at, updated_at
|
||||||
|
`, accountID, platform, postType, caption).Scan(
|
||||||
|
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return Submission{}, fmt.Errorf("store: create submission: %w", err)
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubmission liest einen Beitrag anhand seiner ID — ohne
|
||||||
|
// Mandanten-Prüfung, das ist Sache des Aufrufers (siehe Submission.AccountID).
|
||||||
|
func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error) {
|
||||||
|
var sub Submission
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, platform, post_type, caption, status, created_at, updated_at
|
||||||
|
FROM submission WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Submission{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Submission{}, fmt.Errorf("store: get submission: %w", err)
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSubmissionStatus setzt den Status eines Beitrags (submission ist,
|
||||||
|
// anders als extraction/finding/evidence_package, NICHT append-only —
|
||||||
|
// der Lebenszyklus draft → checked → published → archived ist eine
|
||||||
|
// normale Zustandsänderung, kein Beweis-Eintrag).
|
||||||
|
func (s *Store) SetSubmissionStatus(ctx context.Context, id, status string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `
|
||||||
|
UPDATE submission SET status = $2, updated_at = now() WHERE id = $1
|
||||||
|
`, id, status)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: set submission status: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("store: set submission status: submission %s nicht gefunden", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
77
internal/store/user.go
Normal file
77
internal/store/user.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound signalisiert, dass eine Abfrage keine Zeile ergeben hat —
|
||||||
|
// abgrenzbar von echten Fehlern (z. B. Verbindungsabbruch), damit
|
||||||
|
// Aufrufer "nicht gefunden" gezielt anders behandeln können (z. B. bei
|
||||||
|
// Login: falsche E-Mail vs. Datenbankfehler).
|
||||||
|
var ErrNotFound = errors.New("store: nicht gefunden")
|
||||||
|
|
||||||
|
// User ist ein Login innerhalb eines Account (Mandanten).
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
Role string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser legt einen neuen Nutzer innerhalb eines Accounts an.
|
||||||
|
// passwordHash muss bereits gehasht sein (siehe internal/auth) — store
|
||||||
|
// speichert nur, es hasht nicht selbst.
|
||||||
|
func (s *Store) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (User, error) {
|
||||||
|
var u User
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, account_id, email, password_hash, role, created_at
|
||||||
|
`, accountID, email, passwordHash, role).Scan(
|
||||||
|
&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("store: create user: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail liest einen Nutzer anhand seiner E-Mail-Adresse.
|
||||||
|
// Liefert ErrNotFound, wenn keine E-Mail passt (kein Datenbankfehler).
|
||||||
|
func (s *Store) GetUserByEmail(ctx context.Context, email string) (User, error) {
|
||||||
|
var u User
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, email, password_hash, role, created_at
|
||||||
|
FROM app_user WHERE email = $1
|
||||||
|
`, email).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("store: get user by email: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser liest einen Nutzer anhand seiner ID.
|
||||||
|
func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||||
|
var u User
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, email, password_hash, role, created_at
|
||||||
|
FROM app_user WHERE id = $1
|
||||||
|
`, id).Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return User{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, fmt.Errorf("store: get user: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
136
internal/web/auth_handlers.go
Normal file
136
internal/web/auth_handlers.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authPageData struct {
|
||||||
|
Title string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) renderAuthPage(w http.ResponseWriter, name string, data authPageData) {
|
||||||
|
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegister legt einen neuen Mandanten (Account) und den ersten
|
||||||
|
// Nutzer darin an. Es gibt bewusst keinen separaten "Account beitreten"-
|
||||||
|
// Flow — das wäre Schritt 4 (Agentur-/Markensicht mit mehreren Nutzern
|
||||||
|
// pro Account), hier reicht ein Nutzer pro neuem Account.
|
||||||
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountName := r.FormValue("account_name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
role := r.FormValue("role")
|
||||||
|
if accountName == "" || email == "" || password == "" || role == "" {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
acc, err := s.store.CreateAccount(ctx, accountName)
|
||||||
|
if err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, role)
|
||||||
|
if err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
|
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Sitzung konnte nicht gestartet werden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "ungültiges Formular"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
user, err := s.store.GetUserByEmail(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
// Absichtlich dieselbe Meldung wie bei falschem Passwort — sonst
|
||||||
|
// verrät die Fehlermeldung, ob eine E-Mail-Adresse existiert.
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "E-Mail oder Passwort falsch"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.startSession(w, r, user.ID); err != nil {
|
||||||
|
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
_ = s.store.DeleteSession(r.Context(), cookie.Value)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
})
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession erzeugt eine neue Sitzung und setzt das Session-Cookie.
|
||||||
|
// Secure ist an genau dann, wenn die Anfrage selbst über TLS kam — ein
|
||||||
|
// fest verdrahtetes Secure=true würde lokale Entwicklung ohne TLS
|
||||||
|
// (http://localhost) brechen, da Browser ein Secure-Cookie über Klartext-
|
||||||
|
// HTTP schlicht nicht speichern.
|
||||||
|
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(auth.SessionDuration)
|
||||||
|
if _, err := s.store.CreateSession(r.Context(), token, userID, expiresAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Expires: expiresAt,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
272
internal/web/handlers.go
Normal file
272
internal/web/handlers.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/dossier"
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"ok":true}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type indexData struct {
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "index", indexData{Title: "Pre-Publish-Prüfung"}); err != nil {
|
||||||
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type findingView struct {
|
||||||
|
RuleID string
|
||||||
|
Version int
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Fix string
|
||||||
|
Sources []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type resultData struct {
|
||||||
|
SubmissionID string
|
||||||
|
NeedsClarification bool
|
||||||
|
CanArchive bool
|
||||||
|
Findings []findingView
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1),
|
||||||
|
// Regelauswertung (Stufe 2), und speichert Submission + Extraction +
|
||||||
|
// Findings. Die eigentliche Archivierung (Hash, Zeitstempel, Dossier)
|
||||||
|
// passiert erst in handleArchive, wenn der Beitrag tatsächlich
|
||||||
|
// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im
|
||||||
|
// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2).
|
||||||
|
func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
platform := r.FormValue("platform")
|
||||||
|
postType := r.FormValue("post_type")
|
||||||
|
caption := r.FormValue("caption")
|
||||||
|
if platform == "" || postType == "" || caption == "" {
|
||||||
|
http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
accountID := currentUser(r).AccountID
|
||||||
|
|
||||||
|
result, err := s.extractor.Extract(ctx, extract.Input{
|
||||||
|
Platform: platform,
|
||||||
|
Jurisdiction: "DE",
|
||||||
|
Caption: caption,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts)
|
||||||
|
|
||||||
|
data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification}
|
||||||
|
if !needsClarification {
|
||||||
|
extractionID := ext.ID
|
||||||
|
for _, f := range findings {
|
||||||
|
if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil {
|
||||||
|
http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.Findings = append(data.Findings, findingView{
|
||||||
|
RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity),
|
||||||
|
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil {
|
||||||
|
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.CanArchive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "result", data); err != nil {
|
||||||
|
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type archivedData struct {
|
||||||
|
SubmissionID string
|
||||||
|
DossierURL string
|
||||||
|
TimestampedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleArchive markiert einen geprüften Beitrag als veröffentlicht und
|
||||||
|
// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier.
|
||||||
|
// Setzt eine vorherige Prüfung voraus (Submission + Extraction +
|
||||||
|
// Findings müssen bereits existieren).
|
||||||
|
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submissionID := r.FormValue("submission_id")
|
||||||
|
if submissionID == "" {
|
||||||
|
http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
sub, err := s.store.GetSubmission(ctx, submissionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein
|
||||||
|
// nicht existierender behandelt, nicht mit einer 403 bestätigt —
|
||||||
|
// eine 403 würde einem anderen Mandanten verraten, dass die ID
|
||||||
|
// existiert.
|
||||||
|
if sub.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext, err := s.store.GetLatestExtraction(ctx, submissionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dossierFindings := make([]rules.Finding, len(storeFindings))
|
||||||
|
for i, f := range storeFindings {
|
||||||
|
dossierFindings[i] = rules.Finding{
|
||||||
|
RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity),
|
||||||
|
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataHash, err := evidence.HashMetadata(struct {
|
||||||
|
SubmissionID string
|
||||||
|
Platform string
|
||||||
|
PostType string
|
||||||
|
Caption string
|
||||||
|
Facts rules.Facts
|
||||||
|
Findings []rules.Finding
|
||||||
|
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(s.dossierDir, 0o750); err != nil {
|
||||||
|
http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf")
|
||||||
|
dossierFile, err := os.Create(dossierPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dossierFile.Close()
|
||||||
|
|
||||||
|
genErr := dossier.Generate(dossierFile, dossier.Data{
|
||||||
|
Submission: dossier.Submission{
|
||||||
|
Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt,
|
||||||
|
},
|
||||||
|
Facts: facts,
|
||||||
|
Findings: dossierFindings,
|
||||||
|
MetadataHash: metadataHash,
|
||||||
|
TimestampToken: timestampToken,
|
||||||
|
GeneratedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if genErr != nil {
|
||||||
|
http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil {
|
||||||
|
http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil {
|
||||||
|
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
timestampedAt, err := evidence.TimestampTime(timestampToken)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := archivedData{
|
||||||
|
SubmissionID: submissionID,
|
||||||
|
DossierURL: "/dossier/" + submissionID,
|
||||||
|
TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"),
|
||||||
|
}
|
||||||
|
if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil {
|
||||||
|
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDossierDownload liefert das erzeugte PDF-Dossier eines
|
||||||
|
// archivierten Beitrags aus.
|
||||||
|
func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
sub, err := s.store.GetSubmission(ctx, id)
|
||||||
|
if err != nil || sub.AccountID != currentUser(r).AccountID {
|
||||||
|
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.store.GetLatestEvidencePackage(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
http.ServeFile(w, r, pkg.DossierPath)
|
||||||
|
}
|
||||||
81
internal/web/middleware.go
Normal file
81
internal/web/middleware.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionCookieName = "deklarix_session"
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const userContextKey contextKey = iota
|
||||||
|
|
||||||
|
// authenticate liest das Session-Cookie, prüft die Sitzung (existiert,
|
||||||
|
// nicht abgelaufen) und lädt den zugehörigen Nutzer. Liefert (User{}, false),
|
||||||
|
// wenn irgendein Schritt fehlschlägt — die Gründe (kein Cookie, unbekanntes
|
||||||
|
// Token, abgelaufen, Nutzer weg) werden bewusst nicht unterschieden, damit
|
||||||
|
// requirePage/requireAPI immer denselben, einzigen Fehlerpfad haben.
|
||||||
|
func (s *Server) authenticate(r *http.Request) (store.User, bool) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return store.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := s.store.GetSession(r.Context(), cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
return store.User{}, false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
|
return store.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.store.GetUser(r.Context(), sess.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return store.User{}, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirePage schützt volle Seitenaufrufe — ohne gültige Sitzung geht
|
||||||
|
// es zurück zu /login (eine echte Navigation, kein htmx-Fragment).
|
||||||
|
func (s *Server) requirePage(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAPI schützt htmx-Endpunkte (Formular-Posts, Downloads) — diese
|
||||||
|
// werden nur aus einer bereits authentifizierten Seite heraus
|
||||||
|
// aufgerufen, ein Fehlschlag hier ist der Ausnahmefall (z. B. Sitzung
|
||||||
|
// mitten in der Nutzung abgelaufen), daher schlicht 401 statt Redirect.
|
||||||
|
func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := s.authenticate(r)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentUser liest den Nutzer, den requirePage/requireAPI in den
|
||||||
|
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
||||||
|
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
||||||
|
// kein Laufzeitfall, den man abfangen sollte.
|
||||||
|
func currentUser(r *http.Request) store.User {
|
||||||
|
user, ok := r.Context().Value(userContextKey).(store.User)
|
||||||
|
if !ok {
|
||||||
|
panic("web: currentUser aufgerufen ohne requirePage/requireAPI")
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
109
internal/web/server.go
Normal file
109
internal/web/server.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
||||||
|
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
||||||
|
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
|
||||||
|
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
||||||
|
// Account — siehe middleware.go.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var templatesFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
|
||||||
|
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
|
||||||
|
// Claude-API-Aufrufe zu machen.
|
||||||
|
type Extractor interface {
|
||||||
|
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
|
||||||
|
ModelVersion() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
||||||
|
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
||||||
|
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
||||||
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
||||||
|
type Store interface {
|
||||||
|
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
|
||||||
|
GetSubmission(ctx context.Context, id string) (store.Submission, error)
|
||||||
|
SetSubmissionStatus(ctx context.Context, id, status string) error
|
||||||
|
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
|
||||||
|
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
|
||||||
|
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
|
||||||
|
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
||||||
|
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
||||||
|
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
|
||||||
|
|
||||||
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
||||||
|
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
||||||
|
GetUser(ctx context.Context, id string) (store.User, error)
|
||||||
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
||||||
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
||||||
|
DeleteSession(ctx context.Context, token string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||||
|
type Server struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
extractor Extractor
|
||||||
|
ruleSet []rules.Rule
|
||||||
|
store Store
|
||||||
|
timestamper evidence.Timestamper
|
||||||
|
dossierDir string
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
||||||
|
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
||||||
|
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden.
|
||||||
|
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
|
||||||
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
extractor: extractor,
|
||||||
|
ruleSet: ruleSet,
|
||||||
|
store: st,
|
||||||
|
timestamper: timestamper,
|
||||||
|
dossierDir: dossierDir,
|
||||||
|
templates: tmpl,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
|
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
||||||
|
mux.HandleFunc("POST /register", s.handleRegister)
|
||||||
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||||
|
mux.HandleFunc("POST /login", s.handleLogin)
|
||||||
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
||||||
|
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
|
||||||
|
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
||||||
|
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
|
||||||
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
|
s.mux = mux
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP macht Server zu einem http.Handler.
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
780
internal/web/server_test.go
Normal file
780
internal/web/server_test.go
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/digitorus/timestamp"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/auth"
|
||||||
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSessionCookie = "deklarix_session"
|
||||||
|
|
||||||
|
// ─── Fakes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type fakeExtractor struct {
|
||||||
|
facts rules.Facts
|
||||||
|
raw []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (extract.Result, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return extract.Result{}, f.err
|
||||||
|
}
|
||||||
|
raw := f.raw
|
||||||
|
if raw == nil {
|
||||||
|
raw = []byte(`{}`)
|
||||||
|
}
|
||||||
|
return extract.Result{Facts: f.facts, RawJSON: raw}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeExtractor) ModelVersion() string { return "fake-model-v0" }
|
||||||
|
|
||||||
|
// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die
|
||||||
|
// Handler-Tests keine echte Postgres-Instanz brauchen.
|
||||||
|
type fakeStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
nextID int
|
||||||
|
accounts map[string]store.Account
|
||||||
|
users map[string]store.User
|
||||||
|
usersByEmail map[string]string // email -> user id
|
||||||
|
sessions map[string]store.Session
|
||||||
|
submissions map[string]store.Submission
|
||||||
|
extractions map[string]store.Extraction
|
||||||
|
findings map[string][]store.Finding
|
||||||
|
evidencePkgs map[string]store.EvidencePackage
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeStore() *fakeStore {
|
||||||
|
return &fakeStore{
|
||||||
|
accounts: map[string]store.Account{},
|
||||||
|
users: map[string]store.User{},
|
||||||
|
usersByEmail: map[string]string{},
|
||||||
|
sessions: map[string]store.Session{},
|
||||||
|
submissions: map[string]store.Submission{},
|
||||||
|
extractions: map[string]store.Extraction{},
|
||||||
|
findings: map[string][]store.Finding{},
|
||||||
|
evidencePkgs: map[string]store.EvidencePackage{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) newID() string {
|
||||||
|
f.nextID++
|
||||||
|
return fmt.Sprintf("id-%d", f.nextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
acc := store.Account{ID: f.newID(), Name: name, CreatedAt: time.Now()}
|
||||||
|
f.accounts[acc.ID] = acc
|
||||||
|
return acc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if _, exists := f.usersByEmail[email]; exists {
|
||||||
|
return store.User{}, fmt.Errorf("fakeStore: email %s bereits vergeben", email)
|
||||||
|
}
|
||||||
|
u := store.User{
|
||||||
|
ID: f.newID(), AccountID: accountID, Email: email, PasswordHash: passwordHash,
|
||||||
|
Role: role, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.users[u.ID] = u
|
||||||
|
f.usersByEmail[email] = u.ID
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetUserByEmail(ctx context.Context, email string) (store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
id, ok := f.usersByEmail[email]
|
||||||
|
if !ok {
|
||||||
|
return store.User{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return f.users[id], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetUser(ctx context.Context, id string) (store.User, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
u, ok := f.users[id]
|
||||||
|
if !ok {
|
||||||
|
return store.User{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess := store.Session{Token: token, UserID: userID, ExpiresAt: expiresAt, CreatedAt: time.Now()}
|
||||||
|
f.sessions[token] = sess
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetSession(ctx context.Context, token string) (store.Session, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sess, ok := f.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return store.Session{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return sess, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) DeleteSession(ctx context.Context, token string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.sessions, token)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sub := store.Submission{
|
||||||
|
ID: f.newID(), AccountID: accountID, Platform: platform, PostType: postType, Caption: caption,
|
||||||
|
Status: "draft", CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.submissions[sub.ID] = sub
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetSubmission(ctx context.Context, id string) (store.Submission, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sub, ok := f.submissions[id]
|
||||||
|
if !ok {
|
||||||
|
return store.Submission{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetSubmissionStatus(ctx context.Context, id, status string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
sub, ok := f.submissions[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("fakeStore: submission %s nicht gefunden", id)
|
||||||
|
}
|
||||||
|
sub.Status = status
|
||||||
|
f.submissions[id] = sub
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
ext := store.Extraction{
|
||||||
|
ID: f.newID(), SubmissionID: submissionID, Payload: payload,
|
||||||
|
ModelVersion: modelVersion, PromptVersion: promptVersion, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.extractions[submissionID] = ext
|
||||||
|
return ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
ext, ok := f.extractions[submissionID]
|
||||||
|
if !ok {
|
||||||
|
return store.Extraction{}, fmt.Errorf("fakeStore: keine Extraktion fuer %s", submissionID)
|
||||||
|
}
|
||||||
|
return ext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
finding := store.Finding{
|
||||||
|
ID: f.newID(), SubmissionID: submissionID, ExtractionID: extractionID,
|
||||||
|
RuleID: ruleID, RuleVersion: ruleVersion, Severity: severity,
|
||||||
|
Title: title, Fix: fix, Sources: sources, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.findings[submissionID] = append(f.findings[submissionID], finding)
|
||||||
|
return finding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.findings[submissionID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
pkg := store.EvidencePackage{
|
||||||
|
ID: f.newID(), SubmissionID: submissionID, DossierPath: dossierPath,
|
||||||
|
SHA256: sha256Hex, TimestampToken: timestampToken, CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
f.evidencePkgs[submissionID] = pkg
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
pkg, ok := f.evidencePkgs[submissionID]
|
||||||
|
if !ok {
|
||||||
|
return store.EvidencePackage{}, fmt.Errorf("fakeStore: kein evidence package fuer %s", submissionID)
|
||||||
|
}
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeTimestamper liefert einen offline erzeugten, strukturell gültigen
|
||||||
|
// (selbstsignierten) RFC-3161-Token — genug, damit evidence.TimestampTime
|
||||||
|
// ihn parsen kann, ohne eine echte TSA zu brauchen.
|
||||||
|
type fakeTimestamper struct{}
|
||||||
|
|
||||||
|
func (fakeTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
certTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "deklarix-test-tsa"},
|
||||||
|
NotBefore: now.Add(-time.Hour),
|
||||||
|
NotAfter: now.Add(time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping},
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(certDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ts := timestamp.Timestamp{
|
||||||
|
HashAlgorithm: crypto.SHA256,
|
||||||
|
HashedMessage: hash,
|
||||||
|
Time: now,
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Policy: asn1.ObjectIdentifier{1, 2, 3},
|
||||||
|
}
|
||||||
|
respDER, err := ts.CreateResponse(cert, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parsed, err := timestamp.ParseResponse(respDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parsed.RawToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test-Setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func loadRealRules(t *testing.T) []rules.Rule {
|
||||||
|
t.Helper()
|
||||||
|
rs, err := rules.Load(os.DirFS("../../rules"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rules.Load: %v", err)
|
||||||
|
}
|
||||||
|
return rs
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
|
||||||
|
t.Helper()
|
||||||
|
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAccount legt direkt im fakeStore (ohne HTTP) einen Account, einen
|
||||||
|
// Nutzer und eine gültige Sitzung an und liefert das Session-Cookie.
|
||||||
|
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
acc, err := fs.CreateAccount(ctx, accountName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword("test-passwort-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
user, err := fs.CreateUser(ctx, acc.ID, email, hash, "creator")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
token, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionToken: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateSession(ctx, token, user.ID, time.Now().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
return &http.Cookie{Name: testSessionCookie, Value: token}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAuthedTestServer ist der Standardfall für Tests, die sich nicht für
|
||||||
|
// Auth selbst interessieren: ein Server plus ein fertig angemeldeter Mandant.
|
||||||
|
func newAuthedTestServer(t *testing.T, ex web.Extractor) (*web.Server, *fakeStore, *http.Cookie) {
|
||||||
|
t.Helper()
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, ex, fs)
|
||||||
|
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
|
||||||
|
return s, fs, cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWithCookie(t *testing.T, s *web.Server, cookie *http.Cookie, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
if cookie != nil {
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForm(extra ...string) url.Values {
|
||||||
|
v := url.Values{"platform": {"instagram"}, "post_type": {"reel"}, "caption": {"..."}}
|
||||||
|
for i := 0; i+1 < len(extra); i += 2 {
|
||||||
|
v.Set(extra[i], extra[i+1])
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests: Health, statische Assets (kein Auth nötig) ────────────────
|
||||||
|
|
||||||
|
func TestHandleHealth(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.String() != `{"ok":true}` {
|
||||||
|
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleStaticServesHTMX(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if w.Body.Len() < 1000 {
|
||||||
|
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests: Auth (Registrierung, Login, Logout, Zugriffsschutz) ───────
|
||||||
|
|
||||||
|
func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fakeExtractor{}, fs)
|
||||||
|
|
||||||
|
regResp := postForm(t, s, nil, "/register", url.Values{
|
||||||
|
"account_name": {"Meine Agentur"}, "email": {"neu@example.com"},
|
||||||
|
"password": {"ein-sicheres-passwort"}, "role": {"agentur"},
|
||||||
|
})
|
||||||
|
if regResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String())
|
||||||
|
}
|
||||||
|
cookies := regResp.Result().Cookies()
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
t.Fatal("expected a session cookie to be set after registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit dem gesetzten Cookie muss die geschützte Startseite erreichbar sein.
|
||||||
|
indexResp := getWithCookie(t, s, cookies[0], "/")
|
||||||
|
if indexResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("index status with fresh session = %d, want 200", indexResp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fakeExtractor{}, fs)
|
||||||
|
form := url.Values{
|
||||||
|
"account_name": {"A"}, "email": {"doppelt@example.com"},
|
||||||
|
"password": {"ein-sicheres-passwort"}, "role": {"creator"},
|
||||||
|
}
|
||||||
|
if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("first register status = %d, want 303", resp.Code)
|
||||||
|
}
|
||||||
|
resp := postForm(t, s, nil, "/register", form)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second register status = %d, want 200 (re-rendered form with error)", resp.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(resp.Body.String(), "schon vergeben") {
|
||||||
|
t.Errorf("expected a duplicate-email error, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginWithCorrectPassword(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fakeExtractor{}, fs)
|
||||||
|
hash, err := auth.HashPassword("richtiges-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAccount: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "bestehend@example.com", hash, "marke"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"bestehend@example.com"}, "password": {"richtiges-passwort"}})
|
||||||
|
if resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
if len(resp.Result().Cookies()) == 0 {
|
||||||
|
t.Fatal("expected a session cookie after successful login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fakeExtractor{}, fs)
|
||||||
|
hash, err := auth.HashPassword("richtiges-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword: %v", err)
|
||||||
|
}
|
||||||
|
acc, _ := fs.CreateAccount(context.Background(), "X")
|
||||||
|
if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "marke"); err != nil {
|
||||||
|
t.Fatalf("CreateUser: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"x@example.com"}, "password": {"falsch"}})
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (re-rendered login form)", resp.Code)
|
||||||
|
}
|
||||||
|
if len(resp.Result().Cookies()) != 0 {
|
||||||
|
t.Fatal("expected no session cookie for a failed login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRejectsUnknownEmailWithSameMessageAsWrongPassword(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fakeExtractor{}, fs)
|
||||||
|
|
||||||
|
resp := postForm(t, s, nil, "/login", url.Values{"email": {"gibtsnicht@example.com"}, "password": {"irgendwas"}})
|
||||||
|
if !strings.Contains(resp.Body.String(), "E-Mail oder Passwort falsch") {
|
||||||
|
t.Errorf("expected the generic invalid-credentials message, got: %s", resp.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexRedirectsToLoginWithoutSession(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
|
||||||
|
}
|
||||||
|
if loc := w.Header().Get("Location"); loc != "/login" {
|
||||||
|
t.Fatalf("Location = %q, want /login", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRejectsRequestsWithoutSession(t *testing.T) {
|
||||||
|
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
w := postForm(t, s, nil, "/pruefen", checkForm())
|
||||||
|
if w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401 without a session", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutClearsSession(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("index before logout = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
logoutResp := postForm(t, s, cookie, "/logout", url.Values{})
|
||||||
|
if logoutResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("logout status = %d, want 303", logoutResp.Code)
|
||||||
|
}
|
||||||
|
if _, err := fs.GetSession(context.Background(), cookie.Value); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatal("expected the session to be deleted from the store after logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp := getWithCookie(t, s, cookie, "/"); resp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("index after logout = %d, want 303 redirect (session no longer valid)", resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests: Pre-Publish-Prüfung (angemeldet) ──────────────────────────
|
||||||
|
|
||||||
|
func TestHandleIndexRendersForm(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
resp := getWithCookie(t, s, cookie, "/")
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.Code)
|
||||||
|
}
|
||||||
|
body := resp.Body.String()
|
||||||
|
for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("index body missing %q\nbody: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCheckRejectsMissingFields(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
w := postForm(t, s, cookie, "/pruefen", url.Values{"platform": {"instagram"}})
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 for missing fields", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCheckPropagatesExtractionError(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")})
|
||||||
|
|
||||||
|
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
||||||
|
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
||||||
|
}, raw: []byte(`{"gegenleistung":"bezahlt"}`)})
|
||||||
|
|
||||||
|
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "WK-004") {
|
||||||
|
t.Fatalf("expected WK-004 in result, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fs.submissions) != 1 {
|
||||||
|
t.Fatalf("expected 1 persisted submission, got %d", len(fs.submissions))
|
||||||
|
}
|
||||||
|
var subID string
|
||||||
|
for id, sub := range fs.submissions {
|
||||||
|
subID = id
|
||||||
|
if sub.Status != "checked" {
|
||||||
|
t.Errorf("submission status = %q, want checked", sub.Status)
|
||||||
|
}
|
||||||
|
if sub.AccountID == "" {
|
||||||
|
t.Error("expected the submission to carry the current account's ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := fs.extractions[subID]; !ok {
|
||||||
|
t.Error("expected an extraction to be persisted")
|
||||||
|
}
|
||||||
|
if len(fs.findings[subID]) != 1 || fs.findings[subID][0].RuleID != "WK-004" {
|
||||||
|
t.Errorf("expected exactly one persisted WK-004 finding, got %+v", fs.findings[subID])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear,
|
||||||
|
}})
|
||||||
|
|
||||||
|
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
||||||
|
body := w.Body.String()
|
||||||
|
if strings.Contains(body, "WK-") {
|
||||||
|
t.Errorf("expected no rule findings for an unclear extraction, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "nicht sicher bestimmt") {
|
||||||
|
t.Errorf("expected a clarification message, got: %s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "/veroeffentlichen") {
|
||||||
|
t.Errorf("expected no archive option when clarification is needed, got: %s", body)
|
||||||
|
}
|
||||||
|
for _, sub := range fs.submissions {
|
||||||
|
if sub.Status == "checked" {
|
||||||
|
t.Error("submission should not be marked checked when clarification is needed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||||
|
}})
|
||||||
|
|
||||||
|
w := postForm(t, s, cookie, "/pruefen", checkForm())
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if strings.Contains(body, "WK-") {
|
||||||
|
t.Errorf("expected no findings for an organic post, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
|
||||||
|
t.Errorf("expected the no-findings message, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "/veroeffentlichen") {
|
||||||
|
t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests: Archivierung + Mandantentrennung ──────────────────────────
|
||||||
|
|
||||||
|
func TestFullCheckThenArchiveFlow(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
||||||
|
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
||||||
|
}, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)})
|
||||||
|
|
||||||
|
checkResp := postForm(t, s, cookie, "/pruefen", checkForm())
|
||||||
|
if checkResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check status = %d, body: %s", checkResp.Code, checkResp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(checkResp.Body.String(), `name="submission_id"`) {
|
||||||
|
t.Fatalf("expected a hidden submission_id field in the result, got: %s", checkResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var subID string
|
||||||
|
for id := range fs.submissions {
|
||||||
|
subID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
||||||
|
if archiveResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(archiveResp.Body.String(), "/dossier/"+subID) {
|
||||||
|
t.Fatalf("expected a dossier download link, got: %s", archiveResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, ok := fs.evidencePkgs[subID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an evidence package to be persisted")
|
||||||
|
}
|
||||||
|
if fs.submissions[subID].Status != "published" {
|
||||||
|
t.Errorf("submission status = %q, want published", fs.submissions[subID].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
|
||||||
|
downloadReq.AddCookie(cookie)
|
||||||
|
downloadReq.SetPathValue("id", subID)
|
||||||
|
downloadW := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(downloadW, downloadReq)
|
||||||
|
if downloadW.Code != http.StatusOK {
|
||||||
|
t.Fatalf("download status = %d", downloadW.Code)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(downloadW.Body.String(), "%PDF-") {
|
||||||
|
t.Fatal("downloaded dossier does not start with the PDF header")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(pkg.DossierPath); err != nil {
|
||||||
|
t.Fatalf("dossier file does not exist on disk: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleArchiveRejectsUnknownSubmission(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
|
w := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}})
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404 for an unknown submission", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantIsolationArchiveAndDownload(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||||
|
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)}, fs)
|
||||||
|
|
||||||
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
checkResp := postForm(t, s, cookieA, "/pruefen", checkForm())
|
||||||
|
if checkResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check status = %d", checkResp.Code)
|
||||||
|
}
|
||||||
|
var subID string
|
||||||
|
for id, sub := range fs.submissions {
|
||||||
|
if sub.AccountID != "" {
|
||||||
|
subID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subID == "" {
|
||||||
|
t.Fatal("expected a submission to have been created for Mandant A")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mandant B darf As Beitrag weder archivieren noch dessen Dossier
|
||||||
|
// abrufen — beides muss wie "nicht gefunden" aussehen, nicht wie ein
|
||||||
|
// expliziter Zugriffsfehler.
|
||||||
|
archiveResp := postForm(t, s, cookieB, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
||||||
|
if archiveResp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant archive status = %d, want 404", archiveResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Damit A tatsächlich etwas zum Herunterladen hat: A archiviert selbst.
|
||||||
|
ownArchiveResp := postForm(t, s, cookieA, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
||||||
|
if ownArchiveResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("own archive status = %d, body: %s", ownArchiveResp.Code, ownArchiveResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil)
|
||||||
|
downloadReq.AddCookie(cookieB)
|
||||||
|
downloadReq.SetPathValue("id", subID)
|
||||||
|
downloadW := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(downloadW, downloadReq)
|
||||||
|
if downloadW.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant download status = %d, want 404", downloadW.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/web/static/htmx.min.js
vendored
Normal file
1
internal/web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
internal/web/templates/archived.html
Normal file
13
internal/web/templates/archived.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{{define "archived"}}
|
||||||
|
<p class="archiviert">
|
||||||
|
Beitrag archiviert und mit RFC-3161-Zeitstempel versehen
|
||||||
|
({{.TimestampedAt}}).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a>
|
||||||
|
</p>
|
||||||
|
<p class="disclaimer">
|
||||||
|
Dieses Dossier dokumentiert die durchgeführte Prüfung. Es ist keine
|
||||||
|
Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
33
internal/web/templates/index.html
Normal file
33
internal/web/templates/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "index"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
<h1>Pre-Publish-Prüfung</h1>
|
||||||
|
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
|
||||||
|
|
||||||
|
<form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML">
|
||||||
|
<label for="platform">Plattform</label>
|
||||||
|
<select id="platform" name="platform" required>
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="tiktok">TikTok</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="post_type">Beitragstyp</label>
|
||||||
|
<select id="post_type" name="post_type" required>
|
||||||
|
<option value="feed">Feed</option>
|
||||||
|
<option value="reel">Reel</option>
|
||||||
|
<option value="story">Story</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="caption">Caption</label>
|
||||||
|
<textarea id="caption" name="caption" rows="6" required></textarea>
|
||||||
|
|
||||||
|
<button type="submit">Prüfen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="ergebnis"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
14
internal/web/templates/layout.html
Normal file
14
internal/web/templates/layout.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{define "head"}}
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} — Deklarix</title>
|
||||||
|
<script src="/static/htmx.min.js"></script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "nav"}}
|
||||||
|
<nav>
|
||||||
|
<form method="post" action="/logout" style="display:inline">
|
||||||
|
<button type="submit">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
19
internal/web/templates/login.html
Normal file
19
internal/web/templates/login.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{{define "login"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
<h1>Anmelden</h1>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
<p><a href="/register">Noch kein Konto? Registrieren</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
30
internal/web/templates/register.html
Normal file
30
internal/web/templates/register.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{{define "register"}}<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>{{template "head" .}}</head>
|
||||||
|
<body>
|
||||||
|
<h1>Registrieren</h1>
|
||||||
|
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
|
||||||
|
<form method="post" action="/register">
|
||||||
|
<label for="account_name">Name (Creator, Agentur, Marke oder Kanzlei)</label>
|
||||||
|
<input type="text" id="account_name" name="account_name" required>
|
||||||
|
|
||||||
|
<label for="role">Rolle</label>
|
||||||
|
<select id="role" name="role" required>
|
||||||
|
<option value="creator">Creator</option>
|
||||||
|
<option value="agentur">Agentur</option>
|
||||||
|
<option value="marke">Marke</option>
|
||||||
|
<option value="kanzlei">Kanzlei</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" required>
|
||||||
|
|
||||||
|
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||||
|
<input type="password" id="password" name="password" minlength="8" required>
|
||||||
|
|
||||||
|
<button type="submit">Konto anlegen</button>
|
||||||
|
</form>
|
||||||
|
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
35
internal/web/templates/result.html
Normal file
35
internal/web/templates/result.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{{define "result"}}
|
||||||
|
{{if .NeedsClarification}}
|
||||||
|
<p class="rueckfrage">
|
||||||
|
Die Gegenleistung konnte nicht sicher bestimmt werden. Bitte Angaben
|
||||||
|
präzisieren (z. B. ob ein Produkt oder eine Zahlung im Zusammenhang mit
|
||||||
|
dem Beitrag stand) — es wurde bewusst keine Bewertung abgegeben.
|
||||||
|
</p>
|
||||||
|
{{else if .Findings}}
|
||||||
|
<ul class="findings">
|
||||||
|
{{range .Findings}}
|
||||||
|
<li class="finding finding-{{.Severity}}">
|
||||||
|
<strong>{{.RuleID}} v{{.Version}}</strong> ({{.Severity}}) — {{.Title}}
|
||||||
|
<p>Korrektur: {{.Fix}}</p>
|
||||||
|
{{if .Sources}}
|
||||||
|
<p>Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}</p>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CanArchive}}
|
||||||
|
<form hx-post="/veroeffentlichen" hx-target="#ergebnis" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
|
||||||
|
<button type="submit">Als veröffentlicht markieren & archivieren</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p class="disclaimer">
|
||||||
|
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
||||||
|
Prüfung im Einzelfall.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
1
packaging/DEBIAN/conffiles
Normal file
1
packaging/DEBIAN/conffiles
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/etc/deklarix/deklarix.env.example
|
||||||
@@ -2,6 +2,10 @@ Package: deklarix
|
|||||||
Version: VERSION
|
Version: VERSION
|
||||||
Architecture: ARCH
|
Architecture: ARCH
|
||||||
Maintainer: NetCell IT <team@netcell-it.de>
|
Maintainer: NetCell IT <team@netcell-it.de>
|
||||||
Description: Deklarix — deklarix.de / deklarix.com
|
Homepage: https://deklarix.de
|
||||||
Deklarix Software Package.
|
Section: web
|
||||||
Depends:
|
Priority: optional
|
||||||
|
Depends: ca-certificates
|
||||||
|
Description: Deklarix — Prüfung und Nachweis für Werbe-Content
|
||||||
|
Pre-Publish-Kennzeichnungsprüfung und revisionssichere Archivierung
|
||||||
|
veröffentlichter Beiträge für Creator, Agenturen, Marken und Kanzleien.
|
||||||
|
|||||||
67
packaging/DEBIAN/postinst
Executable file
67
packaging/DEBIAN/postinst
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deklarix — postinst
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SERVICE_USER="deklarix"
|
||||||
|
CONFIG_DIR="/etc/deklarix"
|
||||||
|
DATA_DIR="/var/lib/deklarix"
|
||||||
|
LOG_DIR="/var/log/deklarix"
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
# ─── apt-Quelle einrichten/aktualisieren ────────────────────
|
||||||
|
# Gleiches Muster wie enconf: Gitea-Debian-Registry, Suite =
|
||||||
|
# OS-Codename, Komponente = Kanal (aktuell nur "testing"). Ein
|
||||||
|
# bereits gewählter Kanal bleibt über Upgrades hinweg erhalten.
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \
|
||||||
|
-o /etc/apt/keyrings/deklarix-gitea.asc 2>/dev/null || true
|
||||||
|
if [ -f /etc/apt/keyrings/deklarix-gitea.asc ]; then
|
||||||
|
DX_CODENAME="$(. /etc/os-release 2>/dev/null; echo "${VERSION_CODENAME:-}")"
|
||||||
|
case "$DX_CODENAME" in bookworm|trixie) ;; *) DX_CODENAME="trixie" ;; esac
|
||||||
|
DX_CHANNEL="$(grep -oE 'projekte/debian[[:space:]]+[a-z]+[[:space:]]+(stable|testing)' \
|
||||||
|
/etc/apt/sources.list.d/deklarix.list 2>/dev/null | awk '{print $NF}' | head -1)"
|
||||||
|
case "$DX_CHANNEL" in stable|testing) ;; *) DX_CHANNEL="testing" ;; esac
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/deklarix-gitea.asc] https://git.netcell-it.de/api/packages/projekte/debian $DX_CODENAME $DX_CHANNEL" \
|
||||||
|
> /etc/apt/sources.list.d/deklarix.list
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─── System-User + Verzeichnisse ────────────────────────────
|
||||||
|
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||||
|
useradd -r -s /usr/sbin/nologin -d "$DATA_DIR" "$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
mkdir -p "$DATA_DIR" "$LOG_DIR" "$CONFIG_DIR"
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
|
||||||
|
|
||||||
|
# ─── Konfiguration ───────────────────────────────────────────
|
||||||
|
# Erstinstall: Vorlage kopieren, nicht scharf schalten (Secrets
|
||||||
|
# fehlen noch). Upgrade: bestehende deklarix.env bleibt unberührt.
|
||||||
|
if [ ! -f "$CONFIG_DIR/deklarix.env" ]; then
|
||||||
|
cp "$CONFIG_DIR/deklarix.env.example" "$CONFIG_DIR/deklarix.env"
|
||||||
|
chmod 640 "$CONFIG_DIR/deklarix.env"
|
||||||
|
chown "root:$SERVICE_USER" "$CONFIG_DIR/deklarix.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
systemctl enable deklarix.service >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Nicht blind starten — ohne gesetzte DATABASE_URL/ANTHROPIC_API_KEY
|
||||||
|
# würde der Dienst nur in eine Restart-Schleife laufen (main.go
|
||||||
|
# bricht sonst bewusst mit log.Fatal ab, siehe CLAUDE.md "keine
|
||||||
|
# stillen Fallbacks"). Die Vorlage liefert beide auskommentiert aus —
|
||||||
|
# ein Treffer hier bedeutet also wirklich "vom Admin gesetzt", nicht
|
||||||
|
# den Platzhalter.
|
||||||
|
if grep -qE '^DATABASE_URL=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null \
|
||||||
|
&& grep -qE '^ANTHROPIC_API_KEY=.+' "$CONFIG_DIR/deklarix.env" 2>/dev/null; then
|
||||||
|
systemctl restart deklarix.service
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo " → Deklarix installiert, aber noch nicht gestartet."
|
||||||
|
echo " DATABASE_URL und ANTHROPIC_API_KEY in $CONFIG_DIR/deklarix.env setzen, dann:"
|
||||||
|
echo " systemctl start deklarix"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
11
packaging/DEBIAN/prerm
Executable file
11
packaging/DEBIAN/prerm
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deklarix — prerm
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|deconfigure)
|
||||||
|
systemctl stop deklarix.service 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
27
packaging/etc/deklarix/deklarix.env.example
Normal file
27
packaging/etc/deklarix/deklarix.env.example
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Deklarix — Konfiguration
|
||||||
|
# Wird bei der Erstinstallation nach /etc/deklarix/deklarix.env kopiert.
|
||||||
|
# Diese Datei selbst wird von apt-Updates verwaltet — Anpassungen gehören
|
||||||
|
# in deklarix.env, nicht hierhin.
|
||||||
|
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Wo internal/rules die YAML-Regeln findet — vom .deb-Paket nach
|
||||||
|
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
|
||||||
|
RULES_DIR=/usr/share/deklarix/rules
|
||||||
|
|
||||||
|
# Wo erzeugte Nachweis-Dossiers (PDF) abgelegt werden. Wird beim
|
||||||
|
# Archivieren eines Beitrags automatisch angelegt.
|
||||||
|
DOSSIER_DIR=/var/lib/deklarix/dossiers
|
||||||
|
|
||||||
|
# RFC-3161-Zeitstempeldienst. Leer = FreeTSA.org (frei, aber NICHT
|
||||||
|
# eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem
|
||||||
|
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
|
||||||
|
#TSA_URL=https://freetsa.org/tsr
|
||||||
|
|
||||||
|
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL und
|
||||||
|
# ANTHROPIC_API_KEY. Beide auskommentiert lassen, bis echte Werte
|
||||||
|
# eingetragen sind: postinst prüft genau diese beiden Zeilen, um den
|
||||||
|
# Dienst nicht blind in eine Restart-Schleife gegen Platzhalter laufen
|
||||||
|
# zu lassen.
|
||||||
|
#DATABASE_URL=postgres://user:password@host:5432/deklarix?sslmode=require
|
||||||
|
#ANTHROPIC_API_KEY=
|
||||||
23
packaging/etc/systemd/system/deklarix.service
Normal file
23
packaging/etc/systemd/system/deklarix.service
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Deklarix
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=deklarix
|
||||||
|
Group=deklarix
|
||||||
|
EnvironmentFile=/etc/deklarix/deklarix.env
|
||||||
|
ExecStart=/usr/bin/deklarix
|
||||||
|
WorkingDirectory=/var/lib/deklarix
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/deklarix /var/log/deklarix
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
62
rules/OPEN.md
Normal file
62
rules/OPEN.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Offene Rechtsfragen
|
||||||
|
|
||||||
|
Fragen, bei denen sich eine Regel nicht auf eine handgeprüfte,
|
||||||
|
eindeutig passende Fundstelle stützen lässt, ohne zu raten. Werden hier
|
||||||
|
notiert statt in einer Regel geraten beantwortet zu werden (siehe
|
||||||
|
CLAUDE.md, Arbeitsweise für Claude Code).
|
||||||
|
|
||||||
|
## WK-004 — Fundstelle für "Kennzeichnung muss vor Kürzung sichtbar sein"
|
||||||
|
|
||||||
|
**Stand 2026-08-27, zweite Recherche:** Es gibt jetzt eine einschlägige
|
||||||
|
Entscheidung: **LG Köln, Urteil v. 12.05.2026** — Werbekennzeichnung muss
|
||||||
|
bereits im Vorschaubild/Grid erkennbar sein, wenn der kommerzielle Zweck
|
||||||
|
dort nicht auf den ersten Blick ersichtlich ist; eine erst in der
|
||||||
|
Caption oder erst im geöffneten Beitrag sichtbare Kennzeichnung kommt zu
|
||||||
|
spät. Das deckt sich direkt mit WK-004 und ist jetzt als Fundstelle in
|
||||||
|
der Regel hinterlegt. Zusätzlich bestätigt: OLG Celle hat ein bloßes
|
||||||
|
„#ad" als unzureichend zurückgewiesen, das Kammergericht Berlin ebenso
|
||||||
|
„#sponsoredby" — beides stützt die in WK-001/WK-004 empfohlene
|
||||||
|
Kennzeichnung mit „Werbung"/„Anzeige".
|
||||||
|
|
||||||
|
**Weiterhin offen:** Das LG-Köln-Urteil ist laut Quellenlage **noch
|
||||||
|
nicht rechtskräftig** (Stand der Recherche). Vor Kundeneinsatz prüfen,
|
||||||
|
ob es zwischenzeitlich in Berufung bestätigt oder aufgehoben wurde, und
|
||||||
|
ob es Aussagen zur reinen Caption-Kürzung (statt Vorschaubild) trifft —
|
||||||
|
das Urteil betont primär das Vorschaubild/Grid, WK-004 bewertet aktuell
|
||||||
|
die Caption-Kürzung. Beides hängt zusammen (wenn schon das Vorschaubild
|
||||||
|
fehlt, ist die Caption-Kürzung ohnehin nachrangig), sollte aber sauber
|
||||||
|
auseinandergehalten werden — evtl. braucht es eine eigene Regel für
|
||||||
|
"keine Kennzeichnung im Vorschaubild/ersten Frame", statt beides in
|
||||||
|
WK-004 zu vermischen.
|
||||||
|
|
||||||
|
## Ausnahme "kommerzieller Zweck ohnehin erkennbar" (BGH I ZR 90/20 u. a.)
|
||||||
|
|
||||||
|
**Stand 2026-08-27, zweite Recherche — deutlich präzisiert:** Der BGH
|
||||||
|
unterscheidet zwei Fälle:
|
||||||
|
|
||||||
|
1. **Eigenwerbung** (Influencer bewirbt das eigene Unternehmen/die eigene
|
||||||
|
Marke): Bei kommerziell genutzten Accounts wissen Follower laut BGH
|
||||||
|
"aus den Umständen", dass es sich um Eigenwerbung handelt — hier kann
|
||||||
|
die Kennzeichnungspflicht entfallen. Als Kriterium diente u. a. die
|
||||||
|
Followerzahl (im konkreten Fall bei 600.000 bzw. 1,7 Mio. Followern
|
||||||
|
bejaht), aber der BGH hat **keine klare Grenze** gezogen. Das ist zu
|
||||||
|
vage für eine objektive, extrahierbare Bedingung — eine Regel, die
|
||||||
|
auf "Followerzahl > X" abstellt, würde eine Grenze erfinden, die es
|
||||||
|
so nicht gibt. Bleibt vorerst nicht automatisiert.
|
||||||
|
|
||||||
|
2. **Fremdwerbung** (Influencer bewirbt Produkte/Marken Dritter): Hier
|
||||||
|
ist die neue, klar extrahierbare Erkenntnis: **Ein Link auf die
|
||||||
|
Website des Herstellers/der Marke begründet laut BGH regelmäßig einen
|
||||||
|
"werblichen Überschuss"** — die Kennzeichnungspflicht lebt dann
|
||||||
|
unabhängig davon auf, ob der Post sonst wie Eigenwerbung wirkt. Das
|
||||||
|
ist ein objektives Extraktionsfeld (Link vorhanden ja/nein, Linkziel
|
||||||
|
Hersteller/Marke ja/nein), kein Werturteil — passt zum Kernprinzip
|
||||||
|
und ist ein guter Kandidat für eine kommende Regel WK-002
|
||||||
|
("Verlinkung auf Fremdprodukt ohne Kennzeichnung"), sobald das
|
||||||
|
Extraktionsschema `verlinkungen`/`affiliate_link` echte Daten liefert.
|
||||||
|
|
||||||
|
**Ergebnis:** Die "ohnehin erkennbar"-Ausnahme bleibt bewusst
|
||||||
|
unautomatisiert (Kriterium zu vage, siehe oben) — WK-001/WK-004 lösen in
|
||||||
|
diesem seltenen Fall weiterhin ein potenzielles False Positive aus. Das
|
||||||
|
ist der sicherere Fehler in diese Richtung (lieber eine unnötige
|
||||||
|
Rückfrage an den Nutzer als eine übersehene Kennzeichnungspflicht).
|
||||||
16
rules/WK-001.yaml
Normal file
16
rules/WK-001.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
id: WK-001
|
||||||
|
version: 1
|
||||||
|
land: DE
|
||||||
|
titel: Keine Kennzeichnung trotz Gegenleistung
|
||||||
|
bedingung:
|
||||||
|
gegenleistung: [bezahlt, sachbezug]
|
||||||
|
kennzeichnung_vorhanden: false
|
||||||
|
schwere: hoch
|
||||||
|
fundstelle:
|
||||||
|
- "§ 5a Abs. 4 UWG"
|
||||||
|
- "§ 22 Abs. 1 MStV"
|
||||||
|
korrektur: >
|
||||||
|
Beitrag mit "Werbung" oder "Anzeige" kennzeichnen. Die bloße
|
||||||
|
plattform-eigene Kennzeichnung (z. B. Instagrams "Bezahlte
|
||||||
|
Partnerschaft") reicht laut Leitfaden der Medienanstalten nicht als
|
||||||
|
alleinige Kennzeichnung aus.
|
||||||
17
rules/WK-004.yaml
Normal file
17
rules/WK-004.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
id: WK-004
|
||||||
|
version: 1
|
||||||
|
land: DE
|
||||||
|
titel: Kennzeichnung nicht auf den ersten Blick erkennbar (Kürzung durch "mehr anzeigen")
|
||||||
|
bedingung:
|
||||||
|
gegenleistung: [bezahlt, sachbezug]
|
||||||
|
kennzeichnung_vorhanden: true
|
||||||
|
kennzeichnung_vor_kuerzung: false
|
||||||
|
schwere: hoch
|
||||||
|
fundstelle:
|
||||||
|
- "§ 5a Abs. 4 UWG"
|
||||||
|
- "Leitfaden der Medienanstalten: Werbekennzeichnung bei Online-Medien"
|
||||||
|
- "LG Köln, Urteil v. 12.05.2026 (noch nicht rechtskräftig) — Werbekennzeichnung muss bereits im Vorschaubild/Grid erkennbar sein, eine erst in der Caption versteckte Kennzeichnung kommt zu spät"
|
||||||
|
korrektur: >
|
||||||
|
Kennzeichnung ("Werbung" oder "Anzeige") an den Anfang der Caption
|
||||||
|
setzen, sodass sie sichtbar ist, bevor Instagram/TikTok den Text
|
||||||
|
hinter "mehr anzeigen" kürzt.
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deklarix — Build-Skript (amd64 + arm64)
|
# Deklarix — .deb-Pakete bauen (amd64 + arm64)
|
||||||
#
|
#
|
||||||
# Verwendung:
|
# Verwendung:
|
||||||
# ./scripts/build.sh [version] # Beide Architekturen
|
# ./scripts/build.sh [version] # Beide Architekturen
|
||||||
# ./scripts/build.sh [version] amd64 # Nur amd64
|
# ./scripts/build.sh [version] amd64 # Nur amd64
|
||||||
# ./scripts/build.sh [version] arm64 # Nur arm64
|
# ./scripts/build.sh [version] arm64 # Nur arm64
|
||||||
#
|
#
|
||||||
# Ausgabe: dist/deklarix_<version>_<arch>
|
# Ausgabe: dist/deklarix_<version>_<arch>.deb
|
||||||
#
|
|
||||||
# Release-Prozess:
|
|
||||||
# 1. VERSION erhöhen (Semantic Versioning: MAJOR.MINOR.PATCH)
|
|
||||||
# 2. ./scripts/build.sh <version>
|
|
||||||
# 3. ./scripts/test.sh
|
|
||||||
# 4. git tag v<version> && git push origin v<version>
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
PACKAGING_DIR="$REPO_DIR/packaging"
|
||||||
|
BUILD_DIR="$REPO_DIR/dist/build"
|
||||||
VERSION="${1:-0.0.1}"
|
VERSION="${1:-0.0.1}"
|
||||||
ARCH_FILTER="${2:-both}"
|
ARCH_FILTER="${2:-both}"
|
||||||
|
|
||||||
@@ -26,9 +22,8 @@ log() { echo -e "${GRN}[build]${NC} $*"; }
|
|||||||
|
|
||||||
export PATH=$PATH:/usr/local/go/bin
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
|
||||||
command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; }
|
command -v go >/dev/null || { echo "Go nicht gefunden — PATH: $PATH"; exit 1; }
|
||||||
|
command -v dpkg-deb >/dev/null || { echo "dpkg-deb nicht gefunden"; exit 1; }
|
||||||
mkdir -p "$REPO_DIR/dist"
|
|
||||||
|
|
||||||
log "Version: $VERSION | Go: $(go version)"
|
log "Version: $VERSION | Go: $(go version)"
|
||||||
|
|
||||||
@@ -39,14 +34,42 @@ case "$ARCH_FILTER" in
|
|||||||
*) ARCHS=("amd64" "arm64") ;;
|
*) ARCHS=("amd64" "arm64") ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
mkdir -p "$REPO_DIR/dist"
|
||||||
|
|
||||||
for ARCH in "${ARCHS[@]}"; do
|
for ARCH in "${ARCHS[@]}"; do
|
||||||
OUT="$REPO_DIR/dist/deklarix_${VERSION}_${ARCH}"
|
DEB_NAME="deklarix_${VERSION}_${ARCH}"
|
||||||
log "Baue $ARCH → $OUT"
|
DEB_DIR="$BUILD_DIR/$DEB_NAME"
|
||||||
GOARCH=$ARCH GOOS=linux go build \
|
|
||||||
-ldflags="-X main.Version=${VERSION} -s -w" \
|
log "Baue Binary ($ARCH)..."
|
||||||
-o "$OUT" \
|
mkdir -p "$DEB_DIR/DEBIAN" \
|
||||||
./cmd/server/
|
"$DEB_DIR/usr/bin" \
|
||||||
log "$ARCH fertig: $(du -sh "$OUT" | cut -f1)"
|
"$DEB_DIR/usr/share/deklarix/rules" \
|
||||||
|
"$DEB_DIR/etc/systemd/system" \
|
||||||
|
"$DEB_DIR/etc/deklarix"
|
||||||
|
CGO_ENABLED=0 GOARCH=$ARCH GOOS=linux go build \
|
||||||
|
-trimpath -ldflags="-X main.Version=${VERSION} -s -w" \
|
||||||
|
-o "$DEB_DIR/usr/bin/deklarix" \
|
||||||
|
./cmd/deklarix/
|
||||||
|
chmod 755 "$DEB_DIR/usr/bin/deklarix"
|
||||||
|
|
||||||
|
log "Stelle Paket zusammen ($ARCH)..."
|
||||||
|
sed "s/^Version: VERSION/Version: $VERSION/; s/^Architecture: ARCH/Architecture: $ARCH/" \
|
||||||
|
"$PACKAGING_DIR/DEBIAN/control.tmpl" > "$DEB_DIR/DEBIAN/control"
|
||||||
|
cp "$PACKAGING_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/postinst"
|
||||||
|
cp "$PACKAGING_DIR/DEBIAN/prerm" "$DEB_DIR/DEBIAN/prerm"
|
||||||
|
cp "$PACKAGING_DIR/DEBIAN/conffiles" "$DEB_DIR/DEBIAN/conffiles"
|
||||||
|
chmod 755 "$DEB_DIR/DEBIAN/postinst" "$DEB_DIR/DEBIAN/prerm"
|
||||||
|
|
||||||
|
cp "$PACKAGING_DIR/etc/systemd/system/deklarix.service" "$DEB_DIR/etc/systemd/system/"
|
||||||
|
cp "$PACKAGING_DIR/etc/deklarix/deklarix.env.example" "$DEB_DIR/etc/deklarix/"
|
||||||
|
cp "$REPO_DIR"/rules/*.yaml "$DEB_DIR/usr/share/deklarix/rules/"
|
||||||
|
|
||||||
|
dpkg-deb --build --root-owner-group "$DEB_DIR" "$REPO_DIR/dist/$DEB_NAME.deb"
|
||||||
|
|
||||||
|
log "$ARCH fertig: dist/$DEB_NAME.deb ($(du -sh "$REPO_DIR/dist/$DEB_NAME.deb" | cut -f1))"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
|
||||||
log "Build abgeschlossen ✓"
|
log "Build abgeschlossen ✓"
|
||||||
|
|||||||
@@ -6,13 +6,14 @@
|
|||||||
#
|
#
|
||||||
# Was passiert:
|
# Was passiert:
|
||||||
# 1. Tests laufen
|
# 1. Tests laufen
|
||||||
# 2. Build für amd64 + arm64
|
# 2. .deb-Pakete bauen (amd64 + arm64)
|
||||||
# 3. git tag v<version> wird gesetzt
|
# 3. Pakete in Giteas Debian-Paketregistrierung hochladen (Kanal "testing")
|
||||||
# 4. git push origin main + git push origin v<version>
|
# 4. git tag v<version> wird gesetzt
|
||||||
|
# 5. git push origin main + git push origin v<version>
|
||||||
#
|
#
|
||||||
# Voraussetzungen:
|
# Voraussetzungen:
|
||||||
# - Sauberer Git-Status (keine uncommitted changes)
|
# - Sauberer Git-Status (keine uncommitted changes)
|
||||||
# - Alle Tests grün
|
# - Alle Tests grün (inkl. DATABASE_URL für die Store-Integrationstests)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -24,8 +25,9 @@ if [[ -z "$VERSION" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
GRN='\033[0;32m'; BLD='\033[1m'; NC='\033[0m'
|
GRN='\033[0;32m'; YLW='\033[0;33m'; BLD='\033[1m'; NC='\033[0m'
|
||||||
log() { echo -e "${GRN}[release]${NC} $*"; }
|
log() { echo -e "${GRN}[release]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YLW}[release]${NC} $*"; }
|
||||||
|
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
@@ -43,9 +45,46 @@ log "Tests laufen..."
|
|||||||
bash "$REPO_DIR/scripts/test.sh"
|
bash "$REPO_DIR/scripts/test.sh"
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
log "Build für alle Architekturen..."
|
log "Pakete bauen..."
|
||||||
bash "$REPO_DIR/scripts/build.sh" "$VERSION"
|
bash "$REPO_DIR/scripts/build.sh" "$VERSION"
|
||||||
|
|
||||||
|
# ─── apt-Kanal-Modell ────────────────────────────────────────────────────────
|
||||||
|
# Suite = OS-Codename, Komponente = Kanal. Deklarix hat aktuell nur einen
|
||||||
|
# Kanal (testing) — es gibt noch keine Kunden, für die "stable" vom
|
||||||
|
# aktuellen Entwicklungsstand getrennt werden müsste. Sobald das relevant
|
||||||
|
# wird: gleiches Promote-Modell wie enconf-webpanel (release.sh stable).
|
||||||
|
GITEA_URL="https://git.netcell-it.de"
|
||||||
|
GITEA_ORG="projekte"
|
||||||
|
GITEA_CHANNEL="testing"
|
||||||
|
APT_SUITES=(bookworm trixie)
|
||||||
|
|
||||||
|
GITEA_TOKEN="$(git -C "$REPO_DIR" remote get-url origin | grep -oP '://[^:]+:\K[^@]+' 2>/dev/null || echo '')"
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
echo "FEHLER: Gitea-Token nicht im Git-Remote gefunden."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
upload_deb() {
|
||||||
|
curl -sk -w "%{http_code}" -o /dev/null -X PUT \
|
||||||
|
"$GITEA_URL/api/packages/$GITEA_ORG/debian/pool/$2/$3/upload" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" --upload-file "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
log "Pakete → Gitea-Debian-Registry (${APT_SUITES[*]} / $GITEA_CHANNEL)..."
|
||||||
|
for deb in "$REPO_DIR"/dist/deklarix_"${VERSION}"_*.deb; do
|
||||||
|
[ -f "$deb" ] || { echo "FEHLER: kein .deb für Version $VERSION in dist/ gefunden"; exit 1; }
|
||||||
|
b="$(basename "$deb")"
|
||||||
|
for suite in "${APT_SUITES[@]}"; do
|
||||||
|
code=$(upload_deb "$deb" "$suite" "$GITEA_CHANNEL")
|
||||||
|
if [ "$code" = "201" ] || [ "$code" = "409" ]; then
|
||||||
|
log " ✅ $b → $suite/$GITEA_CHANNEL"
|
||||||
|
else
|
||||||
|
echo "FEHLER: Upload $b → $suite/$GITEA_CHANNEL fehlgeschlagen (HTTP $code)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
# Git Tag
|
# Git Tag
|
||||||
log "Tag v${VERSION} setzen..."
|
log "Tag v${VERSION} setzen..."
|
||||||
git tag -a "v${VERSION}" -m "Release v${VERSION}"
|
git tag -a "v${VERSION}" -m "Release v${VERSION}"
|
||||||
@@ -57,4 +96,5 @@ git push origin "v${VERSION}"
|
|||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "Release v${VERSION} fertig ✓"
|
log "Release v${VERSION} fertig ✓"
|
||||||
log "Binaries: dist/deklarix_${VERSION}_amd64 + _arm64"
|
log "Pakete: dist/deklarix_${VERSION}_amd64.deb + _arm64.deb"
|
||||||
|
log "Installation/Update auf Zielservern: apt install/upgrade deklarix"
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; }
|
|||||||
|
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
# internal/store hat Integrationstests gegen echtes Postgres (u. a. die
|
||||||
|
# Append-only-Garantie auf finding/extraction/evidence_package). Ohne
|
||||||
|
# DATABASE_URL überspringt Go diese Tests still — das darf im
|
||||||
|
# Release-Pfad (release.sh ruft dieses Skript auf) nicht passieren.
|
||||||
|
# Postgres wird hier bewusst NICHT selbst hochgezogen — das ist Aufgabe
|
||||||
|
# des Testsystems, nicht des lokalen Dev-Rechners. DATABASE_URL muss dort
|
||||||
|
# vorkonfiguriert sein.
|
||||||
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
|
fail "DATABASE_URL ist nicht gesetzt — Store-Integrationstests (Append-only-Garantie) würden sonst still übersprungen. Auf dem Testsystem konfigurieren; für schnelle lokale Iteration ohne Postgres stattdessen gezielt 'go test ./internal/...' o. Ä. nutzen."
|
||||||
|
fi
|
||||||
|
|
||||||
log "go vet ..."
|
log "go vet ..."
|
||||||
go vet ./... || fail "go vet fehlgeschlagen"
|
go vet ./... || fail "go vet fehlgeschlagen"
|
||||||
|
|
||||||
@@ -22,6 +33,6 @@ log "go test ./... "
|
|||||||
go test -race -count=1 ./... || fail "Tests fehlgeschlagen"
|
go test -race -count=1 ./... || fail "Tests fehlgeschlagen"
|
||||||
|
|
||||||
log "Build-Check (amd64) ..."
|
log "Build-Check (amd64) ..."
|
||||||
GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/server/ || fail "Build fehlgeschlagen"
|
GOARCH=amd64 GOOS=linux go build -o /dev/null ./cmd/deklarix/ || fail "Build fehlgeschlagen"
|
||||||
|
|
||||||
log "Alle Tests bestanden ✓"
|
log "Alle Tests bestanden ✓"
|
||||||
|
|||||||
12
testdata/golden/organic-no-consideration.json
vendored
Normal file
12
testdata/golden/organic-no-consideration.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Organischer Post ohne Gegenleistung, keine Kennzeichnung noetig",
|
||||||
|
"facts": {
|
||||||
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
|
"consideration": "keine",
|
||||||
|
"disclosure_present": false,
|
||||||
|
"disclosure_wording": "",
|
||||||
|
"disclosure_before_cut": false
|
||||||
|
},
|
||||||
|
"expected_findings": []
|
||||||
|
}
|
||||||
14
testdata/golden/paid-disclosure-hidden-by-cut.json
vendored
Normal file
14
testdata/golden/paid-disclosure-hidden-by-cut.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Bezahlter Post, Kennzeichnung vorhanden aber hinter 'mehr anzeigen' versteckt",
|
||||||
|
"facts": {
|
||||||
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
|
"consideration": "bezahlt",
|
||||||
|
"disclosure_present": true,
|
||||||
|
"disclosure_wording": "Werbung",
|
||||||
|
"disclosure_before_cut": false
|
||||||
|
},
|
||||||
|
"expected_findings": [
|
||||||
|
{ "rule_id": "WK-004", "rule_version": 1, "severity": "hoch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
testdata/golden/paid-disclosure-visible.json
vendored
Normal file
12
testdata/golden/paid-disclosure-visible.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "Bezahlter Post, Kennzeichnung korrekt vor der Kürzung sichtbar",
|
||||||
|
"facts": {
|
||||||
|
"platform": "tiktok",
|
||||||
|
"jurisdiction": "DE",
|
||||||
|
"consideration": "sachbezug",
|
||||||
|
"disclosure_present": true,
|
||||||
|
"disclosure_wording": "Werbung",
|
||||||
|
"disclosure_before_cut": true
|
||||||
|
},
|
||||||
|
"expected_findings": []
|
||||||
|
}
|
||||||
14
testdata/golden/paid-no-disclosure.json
vendored
Normal file
14
testdata/golden/paid-no-disclosure.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "Bezahlter Post ohne jede Kennzeichnung",
|
||||||
|
"facts": {
|
||||||
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
|
"consideration": "bezahlt",
|
||||||
|
"disclosure_present": false,
|
||||||
|
"disclosure_wording": "",
|
||||||
|
"disclosure_before_cut": false
|
||||||
|
},
|
||||||
|
"expected_findings": [
|
||||||
|
{ "rule_id": "WK-001", "rule_version": 1, "severity": "hoch" }
|
||||||
|
]
|
||||||
|
}
|
||||||
13
testdata/golden/unclear-consideration-needs-clarification.json
vendored
Normal file
13
testdata/golden/unclear-consideration-needs-clarification.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "Gegenleistung unklar - Rueckfrage statt Bewertung",
|
||||||
|
"facts": {
|
||||||
|
"platform": "instagram",
|
||||||
|
"jurisdiction": "DE",
|
||||||
|
"consideration": "unklar",
|
||||||
|
"disclosure_present": false,
|
||||||
|
"disclosure_wording": "",
|
||||||
|
"disclosure_before_cut": false
|
||||||
|
},
|
||||||
|
"expected_findings": [],
|
||||||
|
"expect_needs_clarification": true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user