Compare commits

...

8 Commits

Author SHA1 Message Date
noroot
6fd7831784 feat: Erinnerung zum Sichern von Story-Insights vor Ablauf
Ausgangspunkt: Instagram hält Story-Insights nach eigener Aussage nur
24 Stunden vor, auch der offizielle Datenexport enthält sie nicht mehr
danach. Der Standbild-Screenshot beim Prüfen entsteht direkt beim
Veröffentlichen, bevor nennenswerte Kennzahlen existieren — er kann
das strukturell nicht auffangen. Eine OAuth-Anbindung allein löst das
auch nicht: selbst mit API-Zugriff bräuchte es einen Abruf innerhalb
desselben 24h-Fensters.

- Migration 0007: asset.purpose ('initial' | 'insights', Default
  'initial' erhält die Bedeutung aller Bestandszeilen). Ein Beitrag
  kann jetzt mehrere Insights-Nachweise über die Zeit bekommen.
  GetLatestAssetForSubmission berücksichtigt weiterhin nur 'initial',
  damit ein späterer Insights-Upload nie den beim Archivieren
  referenzierten Original-Screenshot verdrängt.
- internal/web/insights_reminder.go: computeInsightsReminder — reine,
  ungetestete gegen echte Instagram-Daten, aber isoliert testbare
  Logik fürs Erinnerungs-Timing (Produktentscheidung, keine Rechtsnorm,
  daher nicht in rules/*.yaml).
- GET /beitraege/{id} zeigt die Erinnerung bei veröffentlichten
  "story"-Beiträgen ohne existierendes insights-Asset; POST
  /beitraege/{id}/insights speichert einen weiteren Screenshot (gleiche
  Validierung wie das initiale Standbild, wiederverwendet über
  readUploadedAsset/storeAsset mit purpose-Parameter).
- Bewusst nur In-App-Banner in dieser Ausbaustufe, kein Mail-/Push-
  Versand — dafür fehlt aktuell ein SMTP-Relay/Versanddienst, siehe
  CLAUDE.md-Hinweis dazu.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Story archivieren → Erinnerung
sichtbar → Insights-Upload → Erinnerung verschwindet, Nachweis
gelistet, Mandantentrennung beim Upload durchgesetzt).
2026-08-28 10:10:44 +02:00
noroot
5813e6209c feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.

- internal/socialconnect: Connector-Interface + InstagramConnector/
  TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
  Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
  TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
  gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
  API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
  Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
  finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
  Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
  nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
  (State-Cookie gegen CSRF, Redirect zum Consent-Screen),
  GET /oauth/{platform}/callback (State prüfen, Code tauschen,
  Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
  Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
  "nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.

Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
2026-08-28 09:32:43 +02:00
noroot
790ab20651 feat: Standbild-Upload bei der Pre-Publish-Prüfung
CLAUDE.md beschreibt die Prüfung seit dem ersten Commit als "Caption,
Standbild und Vertragslage rein" — bisher wurde nur die Caption
verarbeitet, das asset-Schema aus Migration 0001 blieb ungenutzt.

- internal/store/asset.go: CreateAsset/GetLatestAssetForSubmission.
  Migration 0005 macht asset append-only (Trigger fehlte seit 0001,
  weil bis jetzt nichts hineinschrieb) — ein hochgeladenes Beweisstück
  wird nicht nachträglich ausgetauscht, aus demselben Grund wie bei
  extraction/finding/evidence_package.
- handleCheck liest ein optionales "standbild"-Formularfeld (Bild-
  Upload, max. 8 MiB, Content-Type muss image/* sein), validiert es
  VOR dem Anlegen der Submission (ein ungültiger Upload hinterlässt so
  keine leere Beitrags-Zeile), speichert es danach unter ASSET_DIR und
  legt die Asset-Zeile an.
- handleArchive bindet den Asset-Hash (falls vorhanden) in den
  Metadaten-Hash und ins PDF-Dossier ein (dossier.Data.AssetHash war
  bereits vorbereitet, wurde aber nie befüllt).
- index.html: Formular auf multipart/form-data umgestellt
  (hx-encoding + enctype), neues optionales Dateifeld. handleCheck
  bleibt abwärtskompatibel zu urlencoded-Requests (ParseMultipartForm
  liefert ErrNotMultipart, das wird wie "kein Bild hochgeladen"
  behandelt, nicht wie ein Fehler).
- ASSET_DIR neue Konfigurationsvariable (Default "assets", wie
  DOSSIER_DIR relativ zu WorkingDirectory=/var/lib/deklarix — kein
  postinst-Healing nötig, anders als bei RULES_DIR, dessen Default
  nicht zum installierten Pfad passt).

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Upload, Hash in DB, Hash im
erzeugten PDF via pdftotext, Ablehnung bei falschem Dateityp).
2026-08-27 21:45:03 +02:00
noroot
ae59e745c0 fix: Admin nach Login direkt zum Admin-Bereich leiten
Ein Admin-Login hat kein eigenes Produkt zu prüfen — die Pre-Publish-
Prüfung ("/") ist die Startseite für Mandanten (Creator/Agentur/Marke/
Kanzlei). Nutzer hat nach dem Login zu Recht gefragt, wieso ein Admin
dieses Formular sieht. Login leitet role=admin jetzt direkt zu /admin,
alle anderen Rollen weiterhin zu "/".
2026-08-27 19:06:05 +02:00
noroot
8e5d06aafb fix: Admin-Link in der Navigation zeigen
Nach dem Login landet jeder Nutzer (auch ein Admin, jeder Login gehört
zu einem Account) auf der normalen Startseite — ohne einen Link zu
/admin in der Navigation war der Admin-Bereich für einen Admin ohne
die URL im Kopf praktisch unerreichbar (genau das hat der Nutzer nach
dem Login gemeldet).

navData{IsAdmin} wird jetzt von jeder angemeldeten Seite (Start,
Beiträge, Beitrag-Detail, alle Admin-Seiten) an den gemeinsamen
"nav"-Template-Block durchgereicht; der Link erscheint nur für
role=admin. Test deckt beide Fälle ab (Admin sieht den Link, Mandant
nicht).
2026-08-27 19:04:04 +02:00
noroot
835ad9f0a7 feat: Admin-Bereich (Accounts, Kanzlei-Verzeichnis-Freigabe, Audit-Log)
Bislang gab es keine vom Nutzer-Rollenmodell (creator/agentur/marke/
kanzlei) getrennte Betreiber-Rolle — jede Verwaltungsaufgabe (welche
Kanzlei darf im öffentlichen Verzeichnis stehen, wer sind unsere
Accounts) wäre nur per Hand in der Datenbank möglich gewesen. Admin
ist von Anfang an als fünfte app_user-Rolle im Datenmodell verankert,
nicht nachträglich aufgesetzt.

Migration 0004:
- app_user.role erlaubt zusätzlich 'admin' (kein Self-Service-Weg
  dorthin — /register bietet die Rolle nicht an, erster Admin wird
  einmalig per SQL angelegt, siehe CLAUDE.md).
- account.verified: Freigabe fürs kostenlose Kanzlei-Verzeichnis
  (§ 49b Abs. 3 BRAO: reine Auflistung, kein Routing/keine Vermittlung).
- audit_log: append-only-Protokoll jeder Admin-Aktion (gleicher Trigger
  wie finding/extraction/evidence_package).

Neue Routen:
- GET /admin, /admin/accounts, /admin/accounts/{id}: Accounts-Übersicht
  und -Detail (Logins je Account), requireAdmin (404 statt 403 für
  angemeldete Nicht-Admins, wie beim bestehenden Mandanten-404-Muster).
- POST /admin/accounts/{id}/verifizieren: Kanzlei-Freigabe umschalten,
  schreibt einen Audit-Log-Eintrag.
- GET /admin/audit-log: Protokoll ansehen.
- GET /kanzleien: öffentliches Verzeichnis (kein Login), zeigt nur
  Accounts, die sowohl verified sind als auch einen Nutzer der Rolle
  "kanzlei" haben.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End manuell
gegen einen laufenden Server verifiziert (Admin-Login, Verify-Toggle,
Erscheinen im öffentlichen Verzeichnis, Audit-Log-Eintrag, 404 für
Nicht-Admin-Zugriff).
2026-08-27 18:17:45 +02:00
noroot
c9d71b0d54 feat: Archiv-Übersicht und Beteiligten-CRUD im Web-Layer
Bisher gab es nur Prüfen -> Archivieren, keine Möglichkeit, bereits
geprüfte Beiträge wieder anzusehen oder die Verantwortungsmatrix
(Beteiligte: wer hat vorgegeben, wer freigegeben) tatsächlich zu
pflegen — nur der Store-Layer dafür existierte schon.

Neu:
- GET /beitraege: Liste aller Beiträge des angemeldeten Mandanten
  (Plattform, Status, höchste Finding-Schwere) via
  ListSubmissionsForAccount.
- GET /beitraege/{id}: Detailseite mit Fakten, Findings, Archivieren-
  Aktion und Verantwortungsmatrix.
- POST .../beteiligte, .../beteiligte/{pid}/aktualisieren,
  .../beteiligte/{pid}/loeschen: echtes CRUD statt nur Ansicht, per
  htmx ohne Seiten-Reload.

Mandantentrennung wie beim bestehenden Archiv-Download: fremde
Beiträge und fremde Beteiligte (auch über eine erratene participant_id)
liefern 404, nicht 403 — sonst würde eine 403 die Existenz der Ressource
bei einem anderen Mandanten bestätigen.

Web-Store-Interface um die bereits fertigen Store-Methoden erweitert,
fakeStore in server_test.go entsprechend nachgezogen. Volle Testsuite
inkl. echter Postgres-Tests (./scripts/test.sh) grün.
2026-08-27 17:54:33 +02:00
noroot
6df1d2961b feat: add participant CRUD and per-account submission listing
Participant (the Verantwortungsmatrix — who briefed, who approved) is
not append-only like finding/extraction/evidence_package; getting a
role wrong and correcting it isn't rewriting evidence, so full CRUD is
legitimate here: Create/List/Get/Update/Delete. UpdateParticipant sets
approved_at the first time freigegeben flips to true and never moves it
again on subsequent no-op updates — it marks when approval first
happened, not "last touched".

ListSubmissionsForAccount is the query the upcoming archive overview
needs: every submission for a tenant plus a findings count and highest
severity, computed with the same anti-join ListCurrentFindings already
uses for "currently valid" findings.

Also fixed a real bug this surfaced: CreateFinding let a nil Sources
slice reach a NOT NULL TEXT[] column, which Postgres rejects with an
unhelpful constraint error instead of a clear message. It now normalizes
nil to an empty slice before inserting — matters for any future rule
that ships without a fundstelle entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 17:46:31 +02:00
55 changed files with 4577 additions and 38 deletions

104
CLAUDE.md
View File

@@ -112,6 +112,7 @@ Managed Postgres in der EU (DSGVO).
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
│ ├── dossier/ # PDF-Erzeugung
│ ├── socialconnect/ # OAuth-Flow Instagram/TikTok (Plattform-Verbindung)
│ ├── store/ # Postgres, Migrationen
│ └── web/ # Handler, Templates
├── rules/ # YAML-Regeln, versioniert im Git
@@ -139,14 +140,42 @@ 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
eigene Organisation); jede Submission gehört genau einem Account.
`verified` markiert einen Kanzlei-Account als für das öffentliche,
kostenlose Kanzlei-Verzeichnis (`GET /kanzleien`) freigegeben — nur
vom Admin-Bereich aus setzbar, nie vom Mandanten selbst
- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort-
Hash, Rolle)
Hash, Rolle). Rolle ist eine von `creator`, `agentur`, `marke`,
`kanzlei` **oder `admin`**. `admin` ist Betreiber-Personal
(Netcell-IT), nicht an einen Mandanten-Geschäftszweck gebunden,
zuständig für den Admin-Bereich (`/admin/...`: Accounts-Übersicht,
Kanzlei-Verzeichnis-Freigabe, Audit-Log). Es gibt **keine**
Selbstregistrierung für `admin` über `POST /register` (das Formular
bietet die Rolle nicht an) — der erste Admin wird einmalig per SQL
angelegt:
```sql
INSERT INTO account (name) VALUES ('Deklarix Admin') RETURNING id;
INSERT INTO app_user (account_id, email, password_hash, role)
VALUES ('<account-id>', '<login>', '<bcrypt-hash>', 'admin');
```
(bcrypt-Hash z. B. über `internal/auth.HashPassword` in einem
Wegwerf-`cmd/`-Programm erzeugen, da `internal/` von außerhalb des
Moduls nicht importierbar ist)
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst
eine echte Tabelle statt zustandsloser signierter Tokens, damit
Logout eine Sitzung wirklich beendet
- `audit_log` — Protokoll der Admin-Aktionen (wer hat wann welchen
Account wie verändert); append-only aus demselben Grund wie
`finding`/`extraction`/`evidence_package`
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
- `asset`Bild oder Datei, Pfad, SHA-256
- `asset` — hochgeladenes Standbild, Pfad, SHA-256; append-only aus
demselben Grund wie `finding`/`extraction`/`evidence_package` — ein
Beweisstück wird nicht nachträglich ausgetauscht. `purpose` = `initial`
(das Beweisfoto beim Prüfen, optional) oder `insights` (siehe
„Insights-Erinnerung" unten; ein Beitrag kann mehrere `insights`-Assets
über die Zeit bekommen). `GetLatestAssetForSubmission` berücksichtigt
nur `initial`, damit ein späterer Insights-Upload nie den beim
Archivieren referenzierten Original-Screenshot verdrängt
- `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,
@@ -156,10 +185,17 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
- `participant` — Beteiligter an einer Submission mit Rolle
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
- `platform_connection` — die per OAuth hergestellte Verbindung eines
Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
Abschnitt „Plattform-Verbindung (OAuth)" unten). NICHT append-only —
Tokens laufen ab und werden erneuert, eine Verbindung kann getrennt
und neu hergestellt werden; höchstens eine Verbindung pro
Account+Plattform (`UNIQUE(account_id, platform)`)
**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.
**Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`,
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man
Zeilen ändern kann, ist keines mehr.
**Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte
JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash.
@@ -178,6 +214,58 @@ gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
sonst würde die Antwort selbst verraten, dass die ID existiert.
**Plattform-Verbindung (OAuth):** Jeder Kunde kann optional seinen
eigenen Instagram- oder TikTok-Account verbinden (`GET /verbindungen`),
damit die Beweissicherung einen veröffentlichten Beitrag künftig direkt
per API abrufen kann, statt ihn manuell hochzuladen — reiner
Authorization-Code-Flow, jeder Kunde autorisiert nur seinen eigenen
Account (`internal/socialconnect`, Persistenz in `platform_connection`).
Der manuelle Standbild-Upload bleibt der primäre Weg und funktioniert
unabhängig davon weiter; OAuth reduziert nur Reibung, ist kein
Ersatz für die Pre-Publish-Prüfung (die läuft zwingend vor
Veröffentlichung, wenn auf der Plattform noch nichts existiert — dafür
kann OAuth nichts abrufen).
Technisch ist der Flow fertig (Connector-Interface, CSRF-Schutz per
State-Cookie, Token-Speicherung), aber **ohne aktive Meta-/TikTok-
Freigabe nutzlos**: Instagram (`instagram_business_basic`) und TikTok
(Login Kit + Content Posting API) verlangen jeweils eine einmalige,
plattformseitige Prüfung des Deklarix-Betreiberkontos (Meta Business
Verification + App Review: ca. 24 Wochen; TikTok-Audit: ca. 12
Wochen), bevor sich beliebige Kunden selbst verbinden können. Bis dahin
lässt sich mit bis zu 25 (Meta) bzw. 10 (TikTok) manuell eingetragenen
Testern trotzdem schon mit einem echten Piloten testen. Ohne gesetzte
Konfiguration (`INSTAGRAM_CLIENT_ID`/`_SECRET`,
`TIKTOK_CLIENT_KEY`/`_SECRET`, `PUBLIC_BASE_URL`) zeigt
`GET /verbindungen` beide Plattformen als „noch nicht konfiguriert"
ohne Verbinden-Button — kein Absturz, kein stiller Fallback.
**Vorsicht bei künftigen Änderungen:** Instagram-/TikTok-Endpunkte,
Scopes und Token-Formate in `internal/socialconnect` wurden ohne echte
Zugangsdaten gegen die Entwicklerdokumentation gebaut, nie gegen die
echte API verifiziert — vor dem ersten echten Verbindungsversuch mit
realen Credentials die Konstanten in `internal/socialconnect/*.go` noch
einmal gegen die dann aktuelle Meta-/TikTok-Dokumentation prüfen.
**Insights-Erinnerung:** Story-Insights hält Instagram nach eigener
Aussage nur 24 Stunden vor — danach sind sie auch über den offiziellen
Datenexport nicht mehr zu bekommen, und der ursprüngliche Standbild-
Screenshot beim Prüfen (der direkt beim Veröffentlichen entsteht, bevor
nennenswerte Kennzahlen existieren) kann sie naturgemäß nicht erfassen.
`GET /beitraege/{id}` zeigt deshalb bei veröffentlichten `story`-
Beiträgen eine Erinnerung, solange keine `insights`-Asset existiert
(`internal/web/insights_reminder.go`, `computeInsightsReminder` —
reine Produktentscheidung zum Erinnerungs-Timing, keine Rechtsnorm,
daher bewusst nicht in `rules/*.yaml`). `POST /beitraege/{id}/insights`
speichert einen zusätzlichen Screenshot als `asset` mit
`purpose='insights'`, gehasht wie jedes andere Beweisstück — aber
NICHT im Metadaten-Hash des ursprünglichen Dossiers enthalten (das
wird beim Archivieren einmalig fixiert). Bewusst nur ein In-App-
Banner in dieser ersten Ausbaustufe, kein Mail-/Push-Versand — dafür
fehlt aktuell ein SMTP-Relay/Versanddienst; vor einer echten
Benachrichtigung per E-Mail ist das eine offene Rückfrage (welcher
Versanddienst, welche Absenderdomain/SPF/DKIM).
---
## Go Commands
@@ -371,7 +459,9 @@ sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, TSA_URL)
# DOSSIER_DIR, ASSET_DIR, TSA_URL, PUBLIC_BASE_URL,
# INSTAGRAM_CLIENT_ID/_SECRET, TIKTOK_CLIENT_KEY/_SECRET — letztere vier
# optional, ohne sie zeigt /verbindungen nur "nicht konfiguriert")
# Logs prüfen
journalctl -u deklarix -f

View File

@@ -9,6 +9,7 @@ import (
"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/socialconnect"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
@@ -48,7 +49,26 @@ func main() {
dossierDir = "dossiers"
}
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
assetDir := os.Getenv("ASSET_DIR")
if assetDir == "" {
assetDir = "assets"
}
connectors := map[string]socialconnect.Connector{}
publicBaseURL := os.Getenv("PUBLIC_BASE_URL")
if publicBaseURL != "" {
if id, secret := os.Getenv("INSTAGRAM_CLIENT_ID"), os.Getenv("INSTAGRAM_CLIENT_SECRET"); id != "" && secret != "" {
connectors["instagram"] = socialconnect.NewInstagramConnector(id, secret, publicBaseURL+"/oauth/instagram/callback")
}
if key, secret := os.Getenv("TIKTOK_CLIENT_KEY"), os.Getenv("TIKTOK_CLIENT_SECRET"); key != "" && secret != "" {
connectors["tiktok"] = socialconnect.NewTikTokConnector(key, secret, publicBaseURL+"/oauth/tiktok/callback")
}
}
if len(connectors) == 0 {
log.Print("keine Plattform-Verbindungen konfiguriert (INSTAGRAM_CLIENT_ID/TIKTOK_CLIENT_KEY/PUBLIC_BASE_URL fehlen) — /verbindungen zeigt nur manuelle Beweissicherung an")
}
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir, connectors)
if err != nil {
log.Fatalf("web server: %v", err)
}

View File

@@ -0,0 +1,82 @@
// Package socialconnect implementiert den OAuth-Authorization-Code-Flow,
// mit dem ein Kunde seinen eigenen Instagram- oder TikTok-Account mit
// Deklarix verbindet — Ziel ist, dass die Beweissicherung einen
// veröffentlichten Beitrag später direkt abrufen kann, statt ihn manuell
// hochladen zu müssen. Reine HTTP-Logik gegen die jeweilige Plattform-
// API, keine Datenbankzugriffe — Persistenz der Verbindung liegt in
// internal/store (platform_connection).
//
// WICHTIG: Instagram- und TikTok-Endpunkte, Scopes und Token-Formate
// ändern sich häufiger als andere APIs. Vor dem ersten echten
// Verbindungsversuch mit realen Client-Credentials die Konstanten hier
// gegen die aktuelle Meta-/TikTok-Entwicklerdokumentation prüfen —
// dieser Code wurde ohne echte Zugangsdaten gebaut und gegen die
// Dokumentation, nicht gegen die echte API, verifiziert.
package socialconnect
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Token ist das Ergebnis eines erfolgreichen Code-Tauschs.
type Token struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time // Nullwert, wenn die Plattform keine Ablaufzeit liefert
PlatformUserID string
}
// Connector kapselt den OAuth-Flow einer einzelnen Plattform. *InstagramConnector
// und *TikTokConnector erfüllen dieses Interface; internal/web hält eine
// Menge konfigurierter Connectors (nur die, für die echte Client-
// Credentials gesetzt sind — siehe cmd/deklarix/main.go).
type Connector interface {
// Platform ist der interne Bezeichner ("instagram" | "tiktok"), wie
// er auch in platform_connection.platform gespeichert wird.
Platform() string
AuthorizationURL(state string) string
Exchange(ctx context.Context, code string) (Token, error)
}
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return doJSON(client, req, out)
}
func getJSON(ctx context.Context, client *http.Client, endpoint string, query url.Values, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil)
if err != nil {
return err
}
return doJSON(client, req, out)
}
func doJSON(client *http.Client, req *http.Request, out any) error {
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("antwort lesen: %w", err)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("unerwarteter Status %d: %s", resp.StatusCode, string(body))
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("antwort parsen: %w", err)
}
return nil
}

View File

@@ -0,0 +1,103 @@
package socialconnect
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
const (
instagramDefaultAuthorizeURL = "https://api.instagram.com/oauth/authorize"
instagramDefaultTokenURL = "https://api.instagram.com/oauth/access_token"
instagramDefaultLongLivedTokenURL = "https://graph.instagram.com/access_token"
// instagram_business_basic ist die einzige Berechtigung, die wir
// brauchen (Profil + Medien lesen) — mehr zu verlangen verzögert nur
// den App-Review, siehe Paket-Kommentar.
instagramScope = "instagram_business_basic"
)
// InstagramConnector implementiert Connector für Instagram (Instagram
// API with Instagram Login). Der Flow läuft zweistufig: der
// Autorisierungscode wird zuerst gegen ein 1 Stunde gültiges Token
// getauscht, das anschließend gegen ein 60 Tage gültiges langlebiges
// Token getauscht wird — ein einzelner API-Aufruf reicht dafür nicht.
type InstagramConnector struct {
ClientID string
ClientSecret string
RedirectURL string
// Überschreibbar für Tests (Default: die echten Instagram-Endpunkte).
AuthorizeURL string
TokenURL string
LongLivedTokenURL string
HTTPClient *http.Client
}
// NewInstagramConnector erstellt einen InstagramConnector mit den
// echten Instagram-Endpunkten.
func NewInstagramConnector(clientID, clientSecret, redirectURL string) *InstagramConnector {
return &InstagramConnector{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
AuthorizeURL: instagramDefaultAuthorizeURL,
TokenURL: instagramDefaultTokenURL,
LongLivedTokenURL: instagramDefaultLongLivedTokenURL,
HTTPClient: http.DefaultClient,
}
}
func (c *InstagramConnector) Platform() string { return "instagram" }
func (c *InstagramConnector) AuthorizationURL(state string) string {
v := url.Values{
"client_id": {c.ClientID},
"redirect_uri": {c.RedirectURL},
"scope": {instagramScope},
"response_type": {"code"},
"state": {state},
}
return c.AuthorizeURL + "?" + v.Encode()
}
func (c *InstagramConnector) Exchange(ctx context.Context, code string) (Token, error) {
form := url.Values{
"client_id": {c.ClientID},
"client_secret": {c.ClientSecret},
"grant_type": {"authorization_code"},
"redirect_uri": {c.RedirectURL},
"code": {code},
}
var short struct {
AccessToken string `json:"access_token"`
UserID any `json:"user_id"` // liefert Instagram mal als Zahl, mal als String
}
if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &short); err != nil {
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: %w", err)
}
if short.AccessToken == "" {
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: kein access_token in der Antwort")
}
long := url.Values{
"grant_type": {"ig_exchange_token"},
"client_secret": {c.ClientSecret},
"access_token": {short.AccessToken},
}
var longResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := getJSON(ctx, c.HTTPClient, c.LongLivedTokenURL, long, &longResp); err != nil {
return Token{}, fmt.Errorf("socialconnect: instagram long-lived token exchange: %w", err)
}
return Token{
AccessToken: longResp.AccessToken,
ExpiresAt: time.Now().Add(time.Duration(longResp.ExpiresIn) * time.Second),
PlatformUserID: fmt.Sprint(short.UserID),
}, nil
}

View File

@@ -0,0 +1,82 @@
package socialconnect
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestInstagramAuthorizationURL(t *testing.T) {
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
u := c.AuthorizationURL("state-abc")
for _, want := range []string{
"https://api.instagram.com/oauth/authorize?",
"client_id=client-123",
"state=state-abc",
"scope=instagram_business_basic",
"response_type=code",
} {
if !strings.Contains(u, want) {
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
}
}
}
func TestInstagramExchangeSuccess(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
if r.FormValue("code") != "der-code" {
t.Errorf("code = %q, want der-code", r.FormValue("code"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"short-lived-token","user_id":"17841400000000000"}`))
}))
defer tokenServer.Close()
longLivedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("access_token") != "short-lived-token" {
t.Errorf("access_token query = %q, want short-lived-token", r.URL.Query().Get("access_token"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"long-lived-token","expires_in":5184000}`))
}))
defer longLivedServer.Close()
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
c.TokenURL = tokenServer.URL
c.LongLivedTokenURL = longLivedServer.URL
tok, err := c.Exchange(context.Background(), "der-code")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if tok.AccessToken != "long-lived-token" {
t.Errorf("AccessToken = %q, want long-lived-token", tok.AccessToken)
}
if tok.PlatformUserID != "17841400000000000" {
t.Errorf("PlatformUserID = %q, want 17841400000000000", tok.PlatformUserID)
}
if tok.ExpiresAt.IsZero() {
t.Error("expected a non-zero ExpiresAt")
}
}
func TestInstagramExchangePropagatesTokenEndpointError(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error_message":"ungueltiger code"}`))
}))
defer tokenServer.Close()
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
c.TokenURL = tokenServer.URL
if _, err := c.Exchange(context.Background(), "falscher-code"); err == nil {
t.Fatal("expected an error when the token endpoint returns 400")
}
}

View File

@@ -0,0 +1,95 @@
package socialconnect
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
const (
tiktokDefaultAuthorizeURL = "https://www.tiktok.com/v2/auth/authorize/"
tiktokDefaultTokenURL = "https://open.tiktokapis.com/v2/oauth/token/"
// user.info.basic reicht für Profil-Grunddaten; video.list für den
// späteren Abruf veröffentlichter Videos (Beweissicherung). Mehr
// Scopes verlangen als nötig verzögert nur den Audit, siehe
// Paket-Kommentar.
tiktokScope = "user.info.basic,video.list"
)
// TikTokConnector implementiert Connector für TikTok Login Kit v2 (Web-
// Flow — PKCE ist bei TikTok nur für Desktop/Mobile-Apps Pflicht, beim
// Web-Flow schützt allein der state-Parameter gegen CSRF, siehe
// TikTok-Dokumentation "Web").
type TikTokConnector struct {
ClientKey string
ClientSecret string
RedirectURL string
// Überschreibbar für Tests (Default: die echten TikTok-Endpunkte).
AuthorizeURL string
TokenURL string
HTTPClient *http.Client
}
// NewTikTokConnector erstellt einen TikTokConnector mit den echten
// TikTok-Endpunkten.
func NewTikTokConnector(clientKey, clientSecret, redirectURL string) *TikTokConnector {
return &TikTokConnector{
ClientKey: clientKey,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
AuthorizeURL: tiktokDefaultAuthorizeURL,
TokenURL: tiktokDefaultTokenURL,
HTTPClient: http.DefaultClient,
}
}
func (c *TikTokConnector) Platform() string { return "tiktok" }
func (c *TikTokConnector) AuthorizationURL(state string) string {
v := url.Values{
"client_key": {c.ClientKey},
"redirect_uri": {c.RedirectURL},
"scope": {tiktokScope},
"response_type": {"code"},
"state": {state},
}
return c.AuthorizeURL + "?" + v.Encode()
}
func (c *TikTokConnector) Exchange(ctx context.Context, code string) (Token, error) {
form := url.Values{
"client_key": {c.ClientKey},
"client_secret": {c.ClientSecret},
"code": {code},
"grant_type": {"authorization_code"},
"redirect_uri": {c.RedirectURL},
}
var resp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
OpenID string `json:"open_id"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &resp); err != nil {
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %w", err)
}
if resp.Error != "" {
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %s: %s", resp.Error, resp.ErrorDescription)
}
if resp.AccessToken == "" {
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: kein access_token in der Antwort")
}
return Token{
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
ExpiresAt: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second),
PlatformUserID: resp.OpenID,
}, nil
}

View File

@@ -0,0 +1,70 @@
package socialconnect
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestTikTokAuthorizationURL(t *testing.T) {
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
u := c.AuthorizationURL("state-xyz")
for _, want := range []string{
"https://www.tiktok.com/v2/auth/authorize/?",
"client_key=client-key-123",
"state=state-xyz",
"response_type=code",
} {
if !strings.Contains(u, want) {
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
}
}
}
func TestTikTokExchangeSuccess(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Fatalf("ParseForm: %v", err)
}
if r.FormValue("client_key") != "client-key-123" {
t.Errorf("client_key = %q, want client-key-123", r.FormValue("client_key"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"tt-access","refresh_token":"tt-refresh","expires_in":86400,"open_id":"tt-open-id-1"}`))
}))
defer tokenServer.Close()
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
c.TokenURL = tokenServer.URL
tok, err := c.Exchange(context.Background(), "der-code")
if err != nil {
t.Fatalf("Exchange: %v", err)
}
if tok.AccessToken != "tt-access" || tok.RefreshToken != "tt-refresh" || tok.PlatformUserID != "tt-open-id-1" {
t.Errorf("unexpected token: %+v", tok)
}
}
func TestTikTokExchangePropagatesPlatformError(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TikTok liefert Fehler oft mit Status 200, Fehlerfeldern im Body.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"error":"invalid_grant","error_description":"code abgelaufen"}`))
}))
defer tokenServer.Close()
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
c.TokenURL = tokenServer.URL
_, err := c.Exchange(context.Background(), "abgelaufener-code")
if err == nil {
t.Fatal("expected an error when the platform response contains an error field")
}
if !strings.Contains(err.Error(), "invalid_grant") {
t.Errorf("error = %v, want it to mention invalid_grant", err)
}
}

View File

@@ -2,25 +2,32 @@ package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als
// eigene Organisation). Jeder Beitrag gehört genau einem Account.
// Verified ist nur für Kanzlei-Accounts relevant: ob sie im öffentlichen
// Kanzlei-Verzeichnis gelistet werden (siehe Migration 0004).
type Account struct {
ID string
Name string
Verified bool
CreatedAt time.Time
}
// CreateAccount legt einen neuen Mandanten an.
// CreateAccount legt einen neuen Mandanten an (verified startet false —
// jede Freigabe fürs Kanzlei-Verzeichnis ist eine bewusste Admin-Aktion).
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)
RETURNING id, name, verified, created_at
`, name).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
if err != nil {
return Account{}, fmt.Errorf("store: create account: %w", err)
}
@@ -31,10 +38,85 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
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)
SELECT id, name, verified, created_at FROM account WHERE id = $1
`, id).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: get account: %w", err)
}
return a, nil
}
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
// Admin-Bereich (Accounts-Verwaltung).
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, verified, created_at FROM account ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
defer rows.Close()
var out []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list accounts: %w", err)
}
return out, nil
}
// ListVerifiedKanzleien liefert alle Accounts, die fürs öffentliche
// Kanzlei-Verzeichnis freigegeben sind UND mindestens einen Nutzer der
// Rolle "kanzlei" haben — verified allein reicht nicht, falls ein Admin
// versehentlich einen Nicht-Kanzlei-Account markiert.
func (s *Store) ListVerifiedKanzleien(ctx context.Context) ([]Account, error) {
rows, err := s.Pool.Query(ctx, `
SELECT DISTINCT a.id, a.name, a.verified, a.created_at
FROM account a
JOIN app_user u ON u.account_id = a.id
WHERE a.verified = true AND u.role = 'kanzlei'
ORDER BY a.name
`)
if err != nil {
return nil, fmt.Errorf("store: list verified kanzleien: %w", err)
}
defer rows.Close()
var out []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan account: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list verified kanzleien: %w", err)
}
return out, nil
}
// SetAccountVerified setzt die Freigabe fürs Kanzlei-Verzeichnis.
func (s *Store) SetAccountVerified(ctx context.Context, id string, verified bool) (Account, error) {
var a Account
err := s.Pool.QueryRow(ctx, `
UPDATE account SET verified = $2 WHERE id = $1
RETURNING id, name, verified, created_at
`, id, verified).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, ErrNotFound
}
if err != nil {
return Account{}, fmt.Errorf("store: set account verified: %w", err)
}
return a, nil
}

View File

@@ -0,0 +1,221 @@
package store_test
import (
"context"
"testing"
)
func TestAppUserRoleAllowsAdmin(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "admin")
if err != nil {
t.Fatalf("CreateUser mit role=admin: %v", err)
}
if u.Role != "admin" {
t.Fatalf("Role = %q, want admin", u.Role)
}
}
func TestAccountVerifiedDefaultsFalseAndCanBeSet(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
acc, err := s.CreateAccount(ctx, "Kanzlei Musterfrau")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Verified {
t.Fatal("expected a new account to be unverified by default")
}
updated, err := s.SetAccountVerified(ctx, acc.ID, true)
if err != nil {
t.Fatalf("SetAccountVerified: %v", err)
}
if !updated.Verified {
t.Fatal("expected the account to be verified after SetAccountVerified(true)")
}
got, err := s.GetAccount(ctx, acc.ID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if !got.Verified {
t.Fatal("expected verified=true to persist")
}
}
func TestSetAccountVerifiedNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.SetAccountVerified(context.Background(), "00000000-0000-0000-0000-000000000000", true)
if err == nil {
t.Fatal("expected an error for an unknown account")
}
}
func TestListAccounts(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
before, err := s.ListAccounts(ctx)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
after, err := s.ListAccounts(ctx)
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if len(after) != len(before)+1 {
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
}
found := false
for _, a := range after {
if a.ID == acc.ID {
found = true
}
}
if !found {
t.Fatal("expected the newly created account in ListAccounts")
}
}
func TestListVerifiedKanzleienRequiresBothVerifiedAndKanzleiRole(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
// Verifiziert, aber kein Kanzlei-Nutzer -> darf nicht auftauchen.
verifiedNonKanzlei, err := s.CreateAccount(ctx, "Verifizierte Marke")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := s.CreateUser(ctx, verifiedNonKanzlei.ID, "marke@example.com", "hash", "marke"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.SetAccountVerified(ctx, verifiedNonKanzlei.ID, true); err != nil {
t.Fatalf("SetAccountVerified: %v", err)
}
// Kanzlei-Nutzer, aber nicht verifiziert -> darf nicht auftauchen.
unverifiedKanzlei, err := s.CreateAccount(ctx, "Unverifizierte Kanzlei")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := s.CreateUser(ctx, unverifiedKanzlei.ID, "unverifiziert@example.com", "hash", "kanzlei"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
// Beides erfuellt -> muss auftauchen.
verifiedKanzlei, err := s.CreateAccount(ctx, "Verifizierte Kanzlei")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := s.CreateUser(ctx, verifiedKanzlei.ID, "verifiziert@example.com", "hash", "kanzlei"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.SetAccountVerified(ctx, verifiedKanzlei.ID, true); err != nil {
t.Fatalf("SetAccountVerified: %v", err)
}
list, err := s.ListVerifiedKanzleien(ctx)
if err != nil {
t.Fatalf("ListVerifiedKanzleien: %v", err)
}
byID := map[string]bool{}
for _, a := range list {
byID[a.ID] = true
}
if byID[verifiedNonKanzlei.ID] {
t.Error("verified non-kanzlei account should not appear in the directory")
}
if byID[unverifiedKanzlei.ID] {
t.Error("unverified kanzlei account should not appear in the directory")
}
if !byID[verifiedKanzlei.ID] {
t.Error("expected the verified kanzlei account in the directory")
}
}
func TestListUsersForAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
otherAccID := testAccountID(t, s)
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "creator"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "agentur"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "marke"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
list, err := s.ListUsersForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListUsersForAccount: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
}
}
func TestAuditLogCreateAndList(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "admin")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "manuell freigegeben")
if err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
if entry.ActorUserID != admin.ID {
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
}
list, err := s.ListAuditLog(ctx, 10)
if err != nil {
t.Fatalf("ListAuditLog: %v", err)
}
if len(list) == 0 {
t.Fatal("expected at least one audit entry")
}
if list[0].ID != entry.ID {
t.Fatalf("expected the newest entry first, got %+v", list[0])
}
}
func TestAuditLogIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "admin")
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "")
if err != nil {
t.Fatalf("CreateAuditEntry: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
if err == nil {
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
if err == nil {
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
}
}

98
internal/store/asset.go Normal file
View File

@@ -0,0 +1,98 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Asset ist eine zu einem Beitrag hochgeladene Datei. Append-only wie
// extraction/finding/evidence_package — ein hochgeladenes Beweisstück
// wird nicht nachträglich ausgetauscht, siehe Migration. Purpose
// unterscheidet das ursprüngliche Beweisfoto beim Prüfen ("initial")
// von einem späteren Nachweis flüchtiger Kennzahlen ("insights") —
// siehe Migration 0007 und CLAUDE.md, Abschnitt Insights-Erinnerung.
type Asset struct {
ID string
SubmissionID string
Kind string
Purpose string
Path string
SHA256 string
CreatedAt time.Time
}
// CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte
// SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form,
// in der evidence_package.SHA256 seinen Hash speichert.
func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (Asset, error) {
var a Asset
err := s.Pool.QueryRow(ctx, `
INSERT INTO asset (submission_id, kind, purpose, path, sha256)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, submission_id, kind, purpose, path, sha256, created_at
`, submissionID, kind, purpose, path, sha256Hex).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
)
if err != nil {
return Asset{}, fmt.Errorf("store: create asset: %w", err)
}
return a, nil
}
// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset
// mit purpose="initial" eines Beitrags — bewusst ohne spätere
// "insights"-Assets, damit ein erneutes Archivieren immer denselben
// ursprünglichen Beweis referenziert, egal wie viele Insights-
// Screenshots danach noch hinzukommen. Liefert ErrNotFound, wenn keins
// hochgeladen wurde — das ist der Normalfall (ein Standbild ist
// optional), kein Fehler, den Aufrufer wie einen echten
// Datenbankfehler behandeln sollten.
func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) {
var a Asset
err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, kind, purpose, path, sha256, created_at
FROM asset
WHERE submission_id = $1 AND purpose = 'initial'
ORDER BY created_at DESC
LIMIT 1
`, submissionID).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Asset{}, ErrNotFound
}
if err != nil {
return Asset{}, fmt.Errorf("store: get latest asset: %w", err)
}
return a, nil
}
// ListAssetsForSubmission liefert alle Assets eines Beitrags
// (initiales Standbild und alle Insights-Nachweise), älteste zuerst.
func (s *Store) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]Asset, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, submission_id, kind, purpose, path, sha256, created_at
FROM asset WHERE submission_id = $1 ORDER BY created_at
`, submissionID)
if err != nil {
return nil, fmt.Errorf("store: list assets for submission: %w", err)
}
defer rows.Close()
var out []Asset
for rows.Next() {
var a Asset
if err := rows.Scan(&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan asset: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list assets for submission: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,152 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAssetCreateAndGetLatest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
if a.SubmissionID != sub.ID || a.Kind != "image" || a.Purpose != "initial" {
t.Fatalf("CreateAsset = %+v, unerwartete Werte", a)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != a.ID || got.SHA256 != "deadbeef" {
t.Fatalf("GetLatestAssetForSubmission = %+v, want %+v", got, a)
}
}
func TestGetLatestAssetForSubmissionNotFoundWhenNoneUploaded(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
_, err = s.GetLatestAssetForSubmission(ctx, sub.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/erstes.jpg", "erstehash"); err != nil {
t.Fatalf("CreateAsset (1): %v", err)
}
second, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/zweites.jpg", "zweitehash")
if err != nil {
t.Fatalf("CreateAsset (2): %v", err)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != second.ID {
t.Fatalf("expected the newest asset, got %+v", got)
}
}
func TestGetLatestAssetForSubmissionIgnoresInsightsAssets(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
initial, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "initialhash")
if err != nil {
t.Fatalf("CreateAsset (initial): %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "insightshash"); err != nil {
t.Fatalf("CreateAsset (insights): %v", err)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != initial.ID {
t.Fatalf("expected GetLatestAssetForSubmission to keep returning the initial asset even after an insights asset was added later, got %+v", got)
}
}
func TestListAssetsForSubmissionReturnsAllPurposes(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "h1"); err != nil {
t.Fatalf("CreateAsset (initial): %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "h2"); err != nil {
t.Fatalf("CreateAsset (insights): %v", err)
}
list, err := s.ListAssetsForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("ListAssetsForSubmission: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 assets, got %d: %+v", len(list), list)
}
if list[0].Purpose != "initial" || list[1].Purpose != "insights" {
t.Fatalf("expected initial before insights (created_at order), got %+v", list)
}
}
func TestAssetIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/x.jpg", "hash")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE asset SET sha256 = 'geaendert' WHERE id = $1`, a.ID)
if err == nil {
t.Fatal("expected UPDATE on asset to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM asset WHERE id = $1`, a.ID)
if err == nil {
t.Fatal("expected DELETE on asset to be rejected by the append-only trigger")
}
}

View File

@@ -0,0 +1,61 @@
package store
import (
"context"
"fmt"
"time"
)
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
type AuditEntry struct {
ID string
ActorUserID string
Action string
TargetType string
TargetID string
Details string
CreatedAt time.Time
}
// CreateAuditEntry protokolliert eine Admin-Aktion.
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
var e AuditEntry
err := s.Pool.QueryRow(ctx, `
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
`, actorUserID, action, targetType, targetID, details).Scan(
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
)
if err != nil {
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
}
return e, nil
}
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
FROM audit_log ORDER BY created_at DESC LIMIT $1
`, limit)
if err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan audit entry: %w", err)
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list audit log: %w", err)
}
return out, nil
}

View File

@@ -27,8 +27,14 @@ type Finding struct {
CreatedAt time.Time
}
// CreateFinding speichert ein Finding.
// CreateFinding speichert ein Finding. sources ist NOT NULL in der DB
// (TEXT[]) — ein nil-Slice (z. B. eine Regel ohne fundstelle-Eintrag)
// würde als SQL-NULL ankommen und mit einer wenig hilfreichen Constraint-
// Fehlermeldung abgelehnt; hier stattdessen auf eine leere Liste normiert.
func (s *Store) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (Finding, error) {
if sources == nil {
sources = []string{}
}
var f Finding
err := s.Pool.QueryRow(ctx, `
INSERT INTO finding (submission_id, extraction_id, rule_id, rule_version, severity, title, fix, sources)

View File

@@ -0,0 +1,6 @@
DROP TRIGGER audit_log_append_only ON audit_log;
DROP TABLE audit_log;
ALTER TABLE account DROP COLUMN verified;
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei'));

View File

@@ -0,0 +1,36 @@
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
-- verified markiert eine Kanzlei-Account als für das öffentliche,
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
-- Anwendungsschicht, nicht die Datenbank.
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
-- kann, ist kein Nachweis mehr.
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_user_id UUID NOT NULL REFERENCES app_user (id),
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TRIGGER audit_log_append_only
BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1 @@
DROP TRIGGER asset_append_only ON asset;

View File

@@ -0,0 +1,9 @@
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
-- finding/evidence_package — es nachträglich austauschen zu können,
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
-- sind.
CREATE TRIGGER asset_append_only
BEFORE UPDATE OR DELETE ON asset
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1 @@
DROP TABLE platform_connection;

View File

@@ -0,0 +1,21 @@
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
-- OAuth verbinden (siehe internal/socialconnect), damit die
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
-- abrufen kann statt ihn manuell hochzuladen. Anders als
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
-- und werden erneuert, eine Verbindung kann getrennt und neu
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
-- ON CONFLICT in UpsertPlatformConnection).
CREATE TABLE platform_connection (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES account (id),
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
platform_user_id TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, platform)
);

View File

@@ -0,0 +1 @@
ALTER TABLE asset DROP COLUMN purpose;

View File

@@ -0,0 +1,9 @@
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
-- angelegten Zeilen unverändert.
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
CHECK (purpose IN ('initial', 'insights'));

View File

@@ -0,0 +1,121 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Participant ist ein Beteiligter an einer Submission (Verantwortungs-
// matrix). Anders als finding/extraction/evidence_package ist participant
// NICHT append-only — wer vorgegeben/freigegeben hat, kann sich klären
// oder korrigieren, ohne dass das ein Beweis-Eintrag ist.
type Participant struct {
ID string
SubmissionID string
Role string
Name string
Vorgegeben bool
Freigegeben bool
ApprovedAt *time.Time
CreatedAt time.Time
}
// CreateParticipant fügt einen Beteiligten zu einer Submission hinzu.
func (s *Store) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (Participant, error) {
var p Participant
err := s.Pool.QueryRow(ctx, `
INSERT INTO participant (submission_id, role, name, vorgegeben, freigegeben, approved_at)
VALUES ($1, $2, $3, $4, $5, CASE WHEN $5 THEN now() ELSE NULL END)
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
`, submissionID, role, name, vorgegeben, freigegeben).Scan(
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
)
if err != nil {
return Participant{}, fmt.Errorf("store: create participant: %w", err)
}
return p, nil
}
// ListParticipants liefert alle Beteiligten einer Submission.
func (s *Store) ListParticipants(ctx context.Context, submissionID string) ([]Participant, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
FROM participant WHERE submission_id = $1 ORDER BY created_at
`, submissionID)
if err != nil {
return nil, fmt.Errorf("store: list participants: %w", err)
}
defer rows.Close()
var participants []Participant
for rows.Next() {
var p Participant
if err := rows.Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan participant: %w", err)
}
participants = append(participants, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list participants: %w", err)
}
return participants, nil
}
// GetParticipant liest einen Beteiligten anhand seiner ID — u. a. um vor
// einem Update/Delete zu prüfen, zu welcher Submission (und damit zu
// welchem Account) er gehört.
func (s *Store) GetParticipant(ctx context.Context, id string) (Participant, error) {
var p Participant
err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
FROM participant WHERE id = $1
`, id).Scan(&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Participant{}, ErrNotFound
}
if err != nil {
return Participant{}, fmt.Errorf("store: get participant: %w", err)
}
return p, nil
}
// UpdateParticipant setzt vorgegeben/freigegeben. approved_at wird beim
// ersten Wechsel zu freigegeben=true gesetzt und danach nicht mehr
// verändert (er hält fest, wann zuerst freigegeben wurde).
func (s *Store) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (Participant, error) {
var p Participant
err := s.Pool.QueryRow(ctx, `
UPDATE participant
SET vorgegeben = $2,
freigegeben = $3,
approved_at = CASE WHEN $3 AND approved_at IS NULL THEN now() ELSE approved_at END
WHERE id = $1
RETURNING id, submission_id, role, name, vorgegeben, freigegeben, approved_at, created_at
`, id, vorgegeben, freigegeben).Scan(
&p.ID, &p.SubmissionID, &p.Role, &p.Name, &p.Vorgegeben, &p.Freigegeben, &p.ApprovedAt, &p.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Participant{}, ErrNotFound
}
if err != nil {
return Participant{}, fmt.Errorf("store: update participant: %w", err)
}
return p, nil
}
// DeleteParticipant entfernt einen Beteiligten (z. B. versehentlich
// falsch angelegt).
func (s *Store) DeleteParticipant(ctx context.Context, id string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM participant WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("store: delete participant: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,133 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestParticipantCRUD(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)
}
p, err := s.CreateParticipant(ctx, sub.ID, "creator", "Max Mustermann", false, false)
if err != nil {
t.Fatalf("CreateParticipant: %v", err)
}
if p.ApprovedAt != nil {
t.Fatalf("ApprovedAt should be nil when freigegeben=false, got %v", p.ApprovedAt)
}
list, err := s.ListParticipants(ctx, sub.ID)
if err != nil {
t.Fatalf("ListParticipants: %v", err)
}
if len(list) != 1 || list[0].ID != p.ID {
t.Fatalf("ListParticipants = %+v, want exactly the created participant", list)
}
got, err := s.GetParticipant(ctx, p.ID)
if err != nil {
t.Fatalf("GetParticipant: %v", err)
}
if got.SubmissionID != sub.ID {
t.Fatalf("GetParticipant.SubmissionID = %q, want %q", got.SubmissionID, sub.ID)
}
updated, err := s.UpdateParticipant(ctx, p.ID, true, true)
if err != nil {
t.Fatalf("UpdateParticipant: %v", err)
}
if !updated.Vorgegeben || !updated.Freigegeben {
t.Fatalf("UpdateParticipant did not apply new flags: %+v", updated)
}
if updated.ApprovedAt == nil {
t.Fatal("expected ApprovedAt to be set once freigegeben became true")
}
firstApproval := *updated.ApprovedAt
// Ein erneutes Update (weiterhin freigegeben) darf approved_at nicht
// verschieben — es haelt fest, wann ZUERST freigegeben wurde.
updated2, err := s.UpdateParticipant(ctx, p.ID, true, true)
if err != nil {
t.Fatalf("UpdateParticipant (2): %v", err)
}
if !updated2.ApprovedAt.Equal(firstApproval) {
t.Fatalf("ApprovedAt changed on a no-op update: %v -> %v", firstApproval, *updated2.ApprovedAt)
}
if err := s.DeleteParticipant(ctx, p.ID); err != nil {
t.Fatalf("DeleteParticipant: %v", err)
}
if _, err := s.GetParticipant(ctx, p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err after delete = %v, want store.ErrNotFound", err)
}
}
func TestUpdateParticipantNotFound(t *testing.T) {
s := openTestStore(t)
_, err := s.UpdateParticipant(context.Background(), "00000000-0000-0000-0000-000000000000", true, true)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestDeleteParticipantNotFound(t *testing.T) {
s := openTestStore(t)
err := s.DeleteParticipant(context.Background(), "00000000-0000-0000-0000-000000000000")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestListSubmissionsForAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
subNoFindings, err := s.CreateSubmission(ctx, accID, "instagram", "reel", "organic")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
subWithFinding, err := s.CreateSubmission(ctx, accID, "tiktok", "video", "unmarked ad")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
// sources bewusst nil statt []string{} — CreateFinding muss das
// selbst abfangen (siehe Kommentar dort), nicht der Aufrufer.
if _, err := s.CreateFinding(ctx, subWithFinding.ID, nil, "WK-001", 1, "hoch", "t", "f", nil); err != nil {
t.Fatalf("CreateFinding: %v", err)
}
// Andere Mandanten duerfen nicht auftauchen.
otherAccID := testAccountID(t, s)
if _, err := s.CreateSubmission(ctx, otherAccID, "instagram", "reel", "anderer Mandant"); err != nil {
t.Fatalf("CreateSubmission (other account): %v", err)
}
list, err := s.ListSubmissionsForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListSubmissionsForAccount: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 submissions for this account, got %d: %+v", len(list), list)
}
byID := map[string]store.SubmissionSummary{}
for _, s := range list {
byID[s.ID] = s
}
if byID[subNoFindings.ID].FindingCount != 0 || byID[subNoFindings.ID].HighestSeverity != "" {
t.Errorf("subNoFindings summary = %+v, want 0 findings and no severity", byID[subNoFindings.ID])
}
if byID[subWithFinding.ID].FindingCount != 1 || byID[subWithFinding.ID].HighestSeverity != "hoch" {
t.Errorf("subWithFinding summary = %+v, want 1 finding, severity hoch", byID[subWithFinding.ID])
}
}

View File

@@ -0,0 +1,108 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// PlatformConnection ist die per OAuth hergestellte Verbindung eines
// Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
// internal/socialconnect). Anders als asset/finding/extraction NICHT
// append-only — ein abgelaufenes oder erneuertes Token ersetzt das alte,
// eine getrennte Verbindung wird wirklich gelöscht.
type PlatformConnection struct {
ID string
AccountID string
Platform string
PlatformUserID string
AccessToken string
RefreshToken string
ExpiresAt *time.Time
ConnectedAt time.Time
}
// UpsertPlatformConnection legt eine Verbindung an oder ersetzt die
// bestehende für dasselbe Account+Plattform-Paar (z. B. bei erneutem
// Verbinden nach Trennen, oder wenn refresh_token erneuert wurde).
func (s *Store) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (PlatformConnection, error) {
var c PlatformConnection
err := s.Pool.QueryRow(ctx, `
INSERT INTO platform_connection (account_id, platform, platform_user_id, access_token, refresh_token, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (account_id, platform) DO UPDATE SET
platform_user_id = EXCLUDED.platform_user_id,
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at,
connected_at = now()
RETURNING id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
`, accountID, platform, platformUserID, accessToken, refreshToken, expiresAt).Scan(
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
)
if err != nil {
return PlatformConnection{}, fmt.Errorf("store: upsert platform connection: %w", err)
}
return c, nil
}
// ListPlatformConnectionsForAccount liefert alle Plattform-Verbindungen
// eines Accounts.
func (s *Store) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]PlatformConnection, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
FROM platform_connection WHERE account_id = $1 ORDER BY platform
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list platform connections: %w", err)
}
defer rows.Close()
var out []PlatformConnection
for rows.Next() {
var c PlatformConnection
if err := rows.Scan(&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt); err != nil {
return nil, fmt.Errorf("store: scan platform connection: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list platform connections: %w", err)
}
return out, nil
}
// GetPlatformConnection liest die Verbindung eines Accounts zu einer
// bestimmten Plattform. Liefert ErrNotFound, wenn keine Verbindung
// besteht — der Normalfall, solange der Kunde nichts verbunden hat.
func (s *Store) GetPlatformConnection(ctx context.Context, accountID, platform string) (PlatformConnection, error) {
var c PlatformConnection
err := s.Pool.QueryRow(ctx, `
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
FROM platform_connection WHERE account_id = $1 AND platform = $2
`, accountID, platform).Scan(
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return PlatformConnection{}, ErrNotFound
}
if err != nil {
return PlatformConnection{}, fmt.Errorf("store: get platform connection: %w", err)
}
return c, nil
}
// DeletePlatformConnection trennt eine Plattform-Verbindung.
func (s *Store) DeletePlatformConnection(ctx context.Context, accountID, platform string) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM platform_connection WHERE account_id = $1 AND platform = $2`, accountID, platform)
if err != nil {
return fmt.Errorf("store: delete platform connection: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,114 @@
package store_test
import (
"context"
"errors"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
func TestPlatformConnectionUpsertGetDelete(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
expires := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Millisecond)
c, err := s.UpsertPlatformConnection(ctx, accID, "instagram", "ig-user-1", "access-1", "", &expires)
if err != nil {
t.Fatalf("UpsertPlatformConnection: %v", err)
}
if c.Platform != "instagram" || c.PlatformUserID != "ig-user-1" {
t.Fatalf("UpsertPlatformConnection = %+v, unerwartete Werte", c)
}
got, err := s.GetPlatformConnection(ctx, accID, "instagram")
if err != nil {
t.Fatalf("GetPlatformConnection: %v", err)
}
if got.AccessToken != "access-1" {
t.Fatalf("AccessToken = %q, want access-1", got.AccessToken)
}
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected exactly 1 connection, got %d", len(list))
}
if err := s.DeletePlatformConnection(ctx, accID, "instagram"); err != nil {
t.Fatalf("DeletePlatformConnection: %v", err)
}
if _, err := s.GetPlatformConnection(ctx, accID, "instagram"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
}
}
func TestPlatformConnectionUpsertReplacesExisting(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
first, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-alt", "refresh-alt", nil)
if err != nil {
t.Fatalf("UpsertPlatformConnection (1): %v", err)
}
second, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-neu", "refresh-neu", nil)
if err != nil {
t.Fatalf("UpsertPlatformConnection (2): %v", err)
}
if second.ID != first.ID {
t.Fatalf("expected the same row to be updated (same account+platform), got a new ID")
}
if second.AccessToken != "access-neu" || second.RefreshToken != "refresh-neu" {
t.Fatalf("UpsertPlatformConnection (2) = %+v, tokens wurden nicht ersetzt", second)
}
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
if err != nil {
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected exactly 1 connection after upsert-replace, got %d", len(list))
}
}
func TestGetPlatformConnectionNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
_, err := s.GetPlatformConnection(ctx, accID, "instagram")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestDeletePlatformConnectionNotFound(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
err := s.DeletePlatformConnection(ctx, accID, "tiktok")
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestPlatformConnectionIsolatedPerAccount(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accA := testAccountID(t, s)
accB := testAccountID(t, s)
if _, err := s.UpsertPlatformConnection(ctx, accA, "instagram", "ig-a", "token-a", "", nil); err != nil {
t.Fatalf("UpsertPlatformConnection (A): %v", err)
}
if _, err := s.GetPlatformConnection(ctx, accB, "instagram"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("Mandant B sollte keine Verbindung von Mandant A sehen, err = %v", err)
}
}

View File

@@ -61,6 +61,62 @@ func (s *Store) GetSubmission(ctx context.Context, id string) (Submission, error
return sub, nil
}
// SubmissionSummary ist eine Submission plus einer Kurzfassung ihrer
// aktuell gültigen Findings, wie sie eine Übersichtsliste braucht (ohne
// für jede Zeile extra ListCurrentFindings aufzurufen).
type SubmissionSummary struct {
Submission
FindingCount int
HighestSeverity string // "" wenn keine Findings
}
// ListSubmissionsForAccount liefert alle Beiträge eines Mandanten,
// neueste zuerst, mit Findings-Kurzfassung.
func (s *Store) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]SubmissionSummary, error) {
rows, err := s.Pool.Query(ctx, `
SELECT
s.id, s.account_id, s.platform, s.post_type, s.caption, s.status, s.created_at, s.updated_at,
COUNT(f.id) AS finding_count,
COALESCE(MAX(CASE f.severity WHEN 'hoch' THEN 3 WHEN 'mittel' THEN 2 WHEN 'niedrig' THEN 1 ELSE 0 END), 0) AS severity_rank
FROM submission s
LEFT JOIN finding f
ON f.submission_id = s.id
AND NOT EXISTS (SELECT 1 FROM finding f2 WHERE f2.supersedes = f.id)
WHERE s.account_id = $1
GROUP BY s.id
ORDER BY s.created_at DESC
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list submissions for account: %w", err)
}
defer rows.Close()
var out []SubmissionSummary
for rows.Next() {
var sub SubmissionSummary
var severityRank int
if err := rows.Scan(
&sub.ID, &sub.AccountID, &sub.Platform, &sub.PostType, &sub.Caption, &sub.Status, &sub.CreatedAt, &sub.UpdatedAt,
&sub.FindingCount, &severityRank,
); err != nil {
return nil, fmt.Errorf("store: scan submission summary: %w", err)
}
switch severityRank {
case 3:
sub.HighestSeverity = "hoch"
case 2:
sub.HighestSeverity = "mittel"
case 1:
sub.HighestSeverity = "niedrig"
}
out = append(out, sub)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list submissions for account: %w", err)
}
return out, 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

View File

@@ -75,3 +75,29 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
}
return u, nil
}
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
// Admin-Bereich (Account-Detailansicht).
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, account_id, email, password_hash, role, created_at
FROM app_user WHERE account_id = $1 ORDER BY created_at
`, accountID)
if err != nil {
return nil, fmt.Errorf("store: list users for account: %w", err)
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan user: %w", err)
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list users for account: %w", err)
}
return out, nil
}

View File

@@ -0,0 +1,217 @@
package web
import (
"net/http"
)
type kanzleiListItem struct {
Name string
}
type kanzleiListData struct {
Title string
Kanzleien []kanzleiListItem
}
// handlePublicKanzleiList zeigt das öffentliche, kostenlose Kanzlei-
// Verzeichnis — keine Anmeldung nötig. Bewusst nur Name, kein Kontakt-
// Button/Routing (siehe CLAUDE.md, § 49b Abs. 3 BRAO): Nutzer wählen
// selbst, es gibt keine Vermittlung.
func (s *Server) handlePublicKanzleiList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListVerifiedKanzleien(r.Context())
if err != nil {
http.Error(w, "Verzeichnis konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := kanzleiListData{Title: "Kanzlei-Verzeichnis"}
for _, a := range accounts {
data.Kanzleien = append(data.Kanzleien, kanzleiListItem{Name: a.Name})
}
if err := s.templates.ExecuteTemplate(w, "kanzleien", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminDashboardData struct {
Title string
Nav navData
AccountCount int
UnverifiedCount int
RecentAuditCount int
}
// handleAdminDashboard zeigt eine kurze Übersicht als Einstieg in den
// Admin-Bereich.
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
accounts, err := s.store.ListAccounts(ctx)
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
unverified := 0
for _, a := range accounts {
if !a.Verified {
unverified++
}
}
auditLog, err := s.store.ListAuditLog(ctx, 5)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminDashboardData{
Title: "Admin", Nav: navFor(r), AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog),
}
if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminAccountListItem struct {
ID string
Name string
Verified bool
CreatedAt string
}
type adminAccountListData struct {
Title string
Nav navData
Accounts []adminAccountListItem
}
// handleAdminAccountList listet alle Mandanten der Plattform.
func (s *Server) handleAdminAccountList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListAccounts(r.Context())
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAccountListData{Title: "Accounts", Nav: navFor(r)}
for _, a := range accounts {
data.Accounts = append(data.Accounts, adminAccountListItem{
ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "admin-accounts", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminUserView struct {
Email string
Role string
}
type adminAccountDetailData struct {
Title string
Nav navData
AccountID string
Name string
Verified bool
HasKanzlei bool
Users []adminUserView
CreatedAt string
}
// handleAdminAccountDetail zeigt einen Mandanten mit seinen Logins und —
// falls mindestens ein Login die Rolle "kanzlei" hat — der Möglichkeit,
// die Freigabe fürs öffentliche Kanzlei-Verzeichnis zu setzen.
func (s *Server) handleAdminAccountDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAccountDetailData{
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
}
for _, u := range users {
data.Users = append(data.Users, adminUserView{Email: u.Email, Role: u.Role})
if u.Role == "kanzlei" {
data.HasKanzlei = true
}
}
if err := s.templates.ExecuteTemplate(w, "admin-account-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAdminSetVerified schaltet die Freigabe fürs Kanzlei-Verzeichnis
// um und protokolliert die Aktion im Audit-Log.
func (s *Server) handleAdminSetVerified(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := r.PathValue("id")
ctx := r.Context()
if _, err := s.store.GetAccount(ctx, accountID); err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
verified := r.FormValue("verified") == "true"
updated, err := s.store.SetAccountVerified(ctx, accountID, verified)
if err != nil {
http.Error(w, "Freigabe konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError)
return
}
action := "account.unverified"
details := "Kanzlei-Verzeichnis: Freigabe entzogen"
if updated.Verified {
action = "account.verified"
details = "Kanzlei-Verzeichnis: Freigabe erteilt"
}
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, action, "account", accountID, details); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/accounts/"+accountID, http.StatusSeeOther)
}
type adminAuditEntryView struct {
CreatedAt string
Action string
TargetType string
TargetID string
Details string
}
type adminAuditLogData struct {
Title string
Nav navData
Entries []adminAuditEntryView
}
// handleAdminAuditLog zeigt das Protokoll der Admin-Aktionen.
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.ListAuditLog(r.Context(), 200)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
for _, e := range entries {
data.Entries = append(data.Entries, adminAuditEntryView{
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
})
}
if err := s.templates.ExecuteTemplate(w, "admin-audit-log", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,198 @@
package web_test
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
)
func TestAdminRoutesRejectNonAdminWith404(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
for _, path := range []string{"/admin", "/admin/accounts", "/admin/audit-log"} {
resp := getWithCookie(t, s, cookie, path)
if resp.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404 for a non-admin user", path, resp.Code)
}
}
}
func TestAdminRoutesRedirectToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, nil, "/admin")
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
}
}
func TestAdminDashboardAccessibleForAdmin(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
resp := getWithCookie(t, s, adminCookie, "/admin")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
}
// TestNavShowsAdminLinkOnlyForAdmins deckt genau den gemeldeten Fall ab:
// nach der Anmeldung als Admin landet man auf der normalen Startseite
// (jeder Nutzer hat einen Account+Login, auch ein Admin) — ohne einen
// sichtbaren Weg zu /admin wäre der Admin-Bereich für einen Admin, der
// die URL nicht auswendig kennt, praktisch unerreichbar.
func TestNavShowsAdminLinkOnlyForAdmins(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
tenantCookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
adminResp := getWithCookie(t, s, adminCookie, "/")
if !strings.Contains(adminResp.Body.String(), `href="/admin"`) {
t.Errorf("expected an /admin nav link for an admin user, got: %s", adminResp.Body.String())
}
tenantResp := getWithCookie(t, s, tenantCookie, "/")
if strings.Contains(tenantResp.Body.String(), `href="/admin"`) {
t.Errorf("expected no /admin nav link for a non-admin user, got: %s", tenantResp.Body.String())
}
}
func TestAdminAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
seedAccount(t, fs, "Mandant A", "a@example.com")
seedAccount(t, fs, "Mandant B", "b@example.com")
resp := getWithCookie(t, s, adminCookie, "/admin/accounts")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Admin"} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in the admin account list, got: %s", want, body)
}
}
}
func TestAdminAccountDetailShowsUsersAndVerifyToggleOnlyForKanzlei(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
creatorCookie := seedAccount(t, fs, "Nur Creator", "creator@example.com")
_ = creatorCookie
var creatorAccID string
for id, acc := range fs.accounts {
if acc.Name == "Nur Creator" {
creatorAccID = id
}
}
kanzleiCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
_ = kanzleiCookie
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
creatorResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+creatorAccID)
if creatorResp.Code != http.StatusOK {
t.Fatalf("status = %d", creatorResp.Code)
}
if strings.Contains(creatorResp.Body.String(), "verifizieren") {
t.Errorf("expected no verify action for a non-kanzlei account, got: %s", creatorResp.Body.String())
}
kanzleiResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID)
if kanzleiResp.Code != http.StatusOK {
t.Fatalf("status = %d", kanzleiResp.Code)
}
if !strings.Contains(kanzleiResp.Body.String(), "kanzlei@example.com") {
t.Errorf("expected the kanzlei user's email on the account detail page, got: %s", kanzleiResp.Body.String())
}
if !strings.Contains(kanzleiResp.Body.String(), "/admin/accounts/"+kanzleiAccID+"/verifizieren") {
t.Errorf("expected a verify action for a kanzlei account, got: %s", kanzleiResp.Body.String())
}
}
func TestAdminVerifyAddsAccountToPublicDirectoryAndAuditLog(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
// Vor der Freigabe taucht die Kanzlei nicht im oeffentlichen
// Verzeichnis auf.
before := getWithCookie(t, s, nil, "/kanzleien")
if strings.Contains(before.Body.String(), "Kanzlei Musterfrau") {
t.Fatalf("kanzlei should not be public before verification, got: %s", before.Body.String())
}
verifyResp := postForm(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
if verifyResp.Code != http.StatusSeeOther {
t.Fatalf("verify status = %d, want 303, body: %s", verifyResp.Code, verifyResp.Body.String())
}
after := getWithCookie(t, s, nil, "/kanzleien")
if !strings.Contains(after.Body.String(), "Kanzlei Musterfrau") {
t.Fatalf("expected the kanzlei to be listed publicly after verification, got: %s", after.Body.String())
}
if len(fs.auditLog) != 1 {
t.Fatalf("expected exactly one audit entry, got %d", len(fs.auditLog))
}
if fs.auditLog[0].Action != "account.verified" || fs.auditLog[0].TargetID != kanzleiAccID {
t.Errorf("unexpected audit entry: %+v", fs.auditLog[0])
}
auditPageResp := getWithCookie(t, s, adminCookie, "/admin/audit-log")
if !strings.Contains(auditPageResp.Body.String(), "account.verified") {
t.Errorf("expected the audit entry on the audit log page, got: %s", auditPageResp.Body.String())
}
}
func TestAdminVerifyRejectsNonAdmin(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
tenantCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
resp := postForm(t, s, tenantCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a non-admin actor", resp.Code)
}
if fs.accounts[kanzleiAccID].Verified {
t.Fatal("account should not have been verified by a non-admin request")
}
}
func TestPublicKanzleiDirectoryRequiresNoLogin(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{Platform: "instagram"}})
req := getWithCookie(t, s, nil, "/kanzleien")
if req.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 without any session", req.Code)
}
}

View File

@@ -0,0 +1,306 @@
package web
import (
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
type submissionListItem struct {
ID string
Platform string
PostType string
Status string
CreatedAt string
FindingCount int
HighestSeverity string
}
type submissionListData struct {
Title string
Nav navData
Submissions []submissionListItem
}
// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des
// angemeldeten Mandanten mit Kurzfassung der Findings.
func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID)
if err != nil {
http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := submissionListData{Title: "Beiträge", Nav: navFor(r)}
for _, sum := range summaries {
data.Submissions = append(data.Submissions, submissionListItem{
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"),
FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity,
})
}
if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type participantView struct {
ID string
Role string
Name string
Vorgegeben bool
Freigegeben bool
ApprovedAt string // leer, wenn noch nicht freigegeben
}
type insightsAssetView struct {
CreatedAt string
SHA256 string
}
type submissionDetailData struct {
Title string
Nav navData
SubmissionID string
Platform string
PostType string
Caption string
Status string
CreatedAt string
CanArchive bool
IsPublished bool
DossierURL string
Findings []findingView
Participants []participantView
InsightsReminder insightsReminder
InsightsAssets []insightsAssetView
}
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag
// eines anderen Accounts wird wie ein nicht existierender behandelt.
func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) {
sub, err := s.store.GetSubmission(r.Context(), id)
if err != nil {
return store.Submission{}, err
}
if sub.AccountID != currentUser(r).AccountID {
return store.Submission{}, store.ErrNotFound
}
return sub, nil
}
func toParticipantViews(participants []store.Participant) []participantView {
views := make([]participantView, len(participants))
for i, p := range participants {
v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben}
if p.ApprovedAt != nil {
v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04")
}
views[i] = v
}
return views
}
// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings,
// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung.
func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID)
if err != nil {
http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
findings := make([]findingView, len(storeFindings))
for i, f := range storeFindings {
findings[i] = findingView{
RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity,
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
}
}
participants, err := s.store.ListParticipants(ctx, sub.ID)
if err != nil {
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
assets, err := s.store.ListAssetsForSubmission(ctx, sub.ID)
if err != nil {
http.Error(w, "Assets konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
var insightsAssets []insightsAssetView
hasInsightsAsset := false
for _, a := range assets {
if a.Purpose == "insights" {
hasInsightsAsset = true
insightsAssets = append(insightsAssets, insightsAssetView{
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), SHA256: a.SHA256,
})
}
}
var reminder insightsReminder
if sub.Status == "published" {
if pkg, err := s.store.GetLatestEvidencePackage(ctx, sub.ID); err == nil {
reminder = computeInsightsReminder(sub.PostType, pkg.CreatedAt, time.Now(), hasInsightsAsset)
}
}
data := submissionDetailData{
Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
InsightsReminder: reminder, InsightsAssets: insightsAssets,
}
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAddInsightsAsset speichert einen zusätzlichen Screenshot der
// Kennzahlen (Insights) eines bereits veröffentlichten Beitrags — siehe
// insightsReminder. Nutzt dieselbe Validierung wie das initiale
// Standbild beim Prüfen (readUploadedAsset/storeAsset), nur mit
// purpose="insights" statt "initial" und als eigener, jederzeit
// wiederholbarer Upload statt einmalig beim Anlegen der Submission.
func (s *Server) handleAddInsightsAsset(w http.ResponseWriter, r *http.Request) {
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
return
}
asset, err := s.readUploadedAsset(r, "insights_standbild")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if asset == nil {
http.Error(w, "kein Standbild hochgeladen", http.StatusBadRequest)
return
}
if err := s.storeAsset(r.Context(), sub.ID, "insights", asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/beitraege/"+sub.ID, http.StatusSeeOther)
}
type participantListData struct {
SubmissionID string
Participants []participantView
}
// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für
// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu
// geladen werden muss.
func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) {
participants, err := s.store.ListParticipants(r.Context(), submissionID)
if err != nil {
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)}
if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil {
http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu.
func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) {
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
role := r.FormValue("role")
name := r.FormValue("name")
if role == "" || name == "" {
http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest)
return
}
if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil {
http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, sub.ID)
}
// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich
// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein
// fremder Mandant über eine erratene participant_id einen Beteiligten
// eines anderen Accounts ändern oder löschen.
func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) {
p, err := s.store.GetParticipant(r.Context(), participantID)
if err != nil {
return store.Participant{}, err
}
if p.SubmissionID != submissionIDFromURL {
return store.Participant{}, store.ErrNotFound
}
if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil {
return store.Participant{}, err
}
return p, nil
}
// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten.
func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
submissionID := r.PathValue("id")
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
if err != nil {
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
vorgegeben := r.FormValue("vorgegeben") == "on"
freigegeben := r.FormValue("freigegeben") == "on"
if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil {
http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, submissionID)
}
// handleDeleteParticipant entfernt einen Beteiligten.
func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
submissionID := r.PathValue("id")
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
if err != nil {
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil {
http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, submissionID)
}

View File

@@ -0,0 +1,202 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
)
// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die
// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen
// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal
// auszuschreiben.
func checkAndReturnSubID(t *testing.T, s interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}, cookie *http.Cookie) string {
t.Helper()
form := checkForm()
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
const marker = `name="submission_id" value="`
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a submission_id field in the result, got: %s", body)
}
rest := body[idx+len(marker):]
return rest[:strings.Index(rest, `"`)]
}
func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
_ = checkAndReturnSubID(t, s, cookieB)
resp := getWithCookie(t, s, cookieA, "/beitraege")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "/beitraege/"+subA) {
t.Errorf("expected Mandant A's own submission link, got: %s", body)
}
// Mandant B hat genau einen eigenen Beitrag, keinen von A.
respB := getWithCookie(t, s, cookieB, "/beitraege")
if strings.Contains(respB.Body.String(), "/beitraege/"+subA) {
t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String())
}
}
func TestSubmissionListRequiresSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/beitraege", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
}
}
func TestSubmissionDetailShowsFactsAndFindings(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,
}})
subID := checkAndReturnSubID(t, s, cookie)
_ = fs
resp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "WK-004") {
t.Errorf("expected the finding to be shown, got: %s", body)
}
if !strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected an archive option for a checked submission, got: %s", body)
}
}
func TestSubmissionDetailTenantIsolation(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code)
}
}
func TestParticipantAddUpdateDeleteFlow(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
subID := checkAndReturnSubID(t, s, cookie)
addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{
"role": {"creator"}, "name": {"Max Mustermann"},
})
if addResp.Code != http.StatusOK {
t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String())
}
if !strings.Contains(addResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if !strings.Contains(detailResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String())
}
// Beteiligten-ID aus dem Update-Formular extrahieren.
body := addResp.Body.String()
const marker = "/beteiligte/"
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a participant action URL, got: %s", body)
}
rest := body[idx+len(marker):]
pID := rest[:strings.Index(rest, "/")]
updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{
"vorgegeben": {"on"}, "freigegeben": {"on"},
})
if updateResp.Code != http.StatusOK {
t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String())
}
if !strings.Contains(updateResp.Body.String(), "freigegeben am") {
t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String())
}
deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusOK {
t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
}
if strings.Contains(deleteResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String())
}
}
func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
// Mandant B darf für As Beitrag gar keinen Beteiligten anlegen.
addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{
"role": {"creator"}, "name": {"Fremd"},
})
if addResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code)
}
p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false)
if err != nil {
t.Fatalf("CreateParticipant: %v", err)
}
updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{
"vorgegeben": {"on"},
})
if updateResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code)
}
deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code)
}
if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil {
t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err)
}
}

View File

@@ -0,0 +1,191 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
// tinyPNG ist das kleinstmögliche gültige PNG (1x1 transparent) — genug,
// um einen echten Datei-Upload zu simulieren, ohne eine Bilddatei aus
// dem Repo laden zu müssen.
var tinyPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
// postCheckWithImage stellt eine echte multipart/form-data-Anfrage wie
// der Browser sie schickt (im Gegensatz zu postForm, das urlencoded
// postet) — checkForm()-Felder plus ein optionales "standbild".
func postCheckWithImage(t *testing.T, s *web.Server, cookie *http.Cookie, imageBytes []byte, contentType string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for key, val := range checkForm() {
if err := mw.WriteField(key, val[0]); err != nil {
t.Fatalf("WriteField(%s): %v", key, err)
}
}
if imageBytes != nil {
part, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="standbild"; filename="screenshot.png"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(imageBytes); err != nil {
t.Fatalf("Write image bytes: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("multipart Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/pruefen", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestCheckWithImageUploadStoresAsset(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var subID string
for id := range fs.submissions {
subID = id
}
if subID == "" {
t.Fatal("expected a submission to have been created")
}
asset, err := fs.GetLatestAssetForSubmission(context.Background(), subID)
if err != nil {
t.Fatalf("expected an asset to be stored, got err: %v", err)
}
if asset.Kind != "image" || asset.SHA256 == "" {
t.Errorf("unexpected asset: %+v", asset)
}
}
func TestCheckWithoutImageStoresNoAsset(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postForm(t, s, cookie, "/pruefen", checkForm())
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var subID string
for id := range fs.submissions {
subID = id
}
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
t.Fatal("expected no asset when none was uploaded")
}
}
func TestCheckRejectsNonImageUpload(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postCheckWithImage(t, s, cookie, []byte("kein bild, nur text"), "text/plain")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a non-image upload, body: %s", resp.Code, resp.Body.String())
}
if len(fs.submissions) != 0 {
t.Error("expected no submission to be created when the upload is rejected")
}
}
func TestCheckRejectsOversizedUpload(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
tooLarge := bytes.Repeat([]byte{0xff}, 9<<20) // 9 MiB > 8 MiB Limit
resp := postCheckWithImage(t, s, cookie, tooLarge, "image/png")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for an oversized upload, body: %s", resp.Code, resp.Body.String())
}
}
// TestArchiveMetadataHashDiffersWhenAssetPresent prüft schwarz-verpackt
// (ohne PDF-Interna zu kennen — der Dossier-Content wird komprimiert,
// ein hex-Hash taucht daher nicht als durchsuchbarer String in den
// PDF-Rohbytes auf, siehe internal/dossier/content_test.go für die
// Prüfung auf Ebene der PDF-Inhaltsstruktur), dass ein hochgeladenes
// Standbild tatsächlich in den archivierten Metadaten-Hash einfließt:
// zwei sonst identische Beiträge, einer mit, einer ohne Bild, müssen
// unterschiedliche evidence_package.SHA256 ergeben.
func TestArchiveMetadataHashDiffersWhenAssetPresent(t *testing.T) {
fakeEx := fakeExtractor{
facts: rules.Facts{Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone},
raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`),
}
s, fs, cookie := newAuthedTestServer(t, fakeEx)
withImageResp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
if withImageResp.Code != http.StatusOK {
t.Fatalf("check (mit Bild) status = %d, body: %s", withImageResp.Code, withImageResp.Body.String())
}
var withImageSubID string
for id := range fs.submissions {
withImageSubID = id
}
archiveWithImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withImageSubID}})
if archiveWithImage.Code != http.StatusOK {
t.Fatalf("archive (mit Bild) status = %d, body: %s", archiveWithImage.Code, archiveWithImage.Body.String())
}
pkgWithImage, err := fs.GetLatestEvidencePackage(context.Background(), withImageSubID)
if err != nil {
t.Fatalf("GetLatestEvidencePackage (mit Bild): %v", err)
}
withoutImageResp := postForm(t, s, cookie, "/pruefen", checkForm())
if withoutImageResp.Code != http.StatusOK {
t.Fatalf("check (ohne Bild) status = %d, body: %s", withoutImageResp.Code, withoutImageResp.Body.String())
}
var withoutImageSubID string
for id := range fs.submissions {
if id != withImageSubID {
withoutImageSubID = id
}
}
archiveWithoutImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withoutImageSubID}})
if archiveWithoutImage.Code != http.StatusOK {
t.Fatalf("archive (ohne Bild) status = %d, body: %s", archiveWithoutImage.Code, archiveWithoutImage.Body.String())
}
pkgWithoutImage, err := fs.GetLatestEvidencePackage(context.Background(), withoutImageSubID)
if err != nil {
t.Fatalf("GetLatestEvidencePackage (ohne Bild): %v", err)
}
if pkgWithImage.SHA256 == pkgWithoutImage.SHA256 {
t.Fatal("expected different metadata hashes for an archived submission with vs. without an uploaded asset")
}
}

View File

@@ -96,6 +96,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
return
}
// Ein Admin-Login hat keinen eigenen Beitrag zu prüfen — die
// Pre-Publish-Prüfung ("/") ist die Startseite für Mandanten, für
// einen Admin ist der Admin-Bereich der sinnvolle Einstieg.
if user.Role == "admin" {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -1,19 +1,96 @@
package web
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"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"
"github.com/netcell-it/deklarix/internal/store"
)
// maxAssetSize begrenzt ein hochgeladenes Standbild auf 8 MiB — genug für
// einen Screenshot, nicht genug, um den Server mit Uploads zu fluten.
const maxAssetSize = 8 << 20
// uploadedAsset ist ein bereits gelesenes und geprüftes Standbild, das
// nach dem Anlegen der Submission (die submission_id als Fremdschlüssel
// braucht) tatsächlich gespeichert wird. Getrennt von storeAsset, damit
// ein ungültiger Upload (falscher Typ, zu groß) *vor* dem Anlegen der
// Submission scheitert, statt eine Beitrags-Zeile ohne Asset zu hinterlassen.
type uploadedAsset struct {
data []byte
extension string
sha256Hex string
}
// readUploadedAsset liest das optionale Datei-Feld fieldName. Liefert
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request, fieldName string) (*uploadedAsset, error) {
file, header, err := r.FormFile(fieldName)
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile.
if errors.Is(err, http.ErrMissingFile) || errors.Is(err, http.ErrNotMultipart) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err)
}
defer file.Close()
if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") {
return nil, fmt.Errorf("nur Bilddateien sind als Standbild erlaubt")
}
data, err := io.ReadAll(io.LimitReader(file, maxAssetSize+1))
if err != nil {
return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err)
}
if len(data) > maxAssetSize {
return nil, fmt.Errorf("Standbild ist zu groß (max. %d MB)", maxAssetSize/(1<<20))
}
ext := filepath.Ext(header.Filename)
if ext == "" {
ext = ".bin"
}
return &uploadedAsset{data: data, extension: ext, sha256Hex: hex.EncodeToString(evidence.HashBytes(data))}, nil
}
// storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und
// speichert die Asset-Zeile. purpose ist "initial" (das Beweisfoto beim
// Prüfen, ein Dateiname pro Submission reicht) oder "insights" (kann
// mehrfach vorkommen, braucht daher einen eindeutigen Dateinamen).
func (s *Server) storeAsset(ctx context.Context, submissionID, purpose string, ua *uploadedAsset) error {
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
}
filename := submissionID + ua.extension
if purpose != "initial" {
filename = fmt.Sprintf("%s-%s-%d%s", submissionID, purpose, time.Now().UnixNano(), ua.extension)
}
path := filepath.Join(s.assetDir, filename)
if err := os.WriteFile(path, ua.data, 0o640); err != nil {
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err)
}
if _, err := s.store.CreateAsset(ctx, submissionID, "image", purpose, path, ua.sha256Hex); err != nil {
return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
}
return nil
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
@@ -21,10 +98,12 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
type indexData struct {
Title string
Nav navData
}
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 {
data := indexData{Title: "Pre-Publish-Prüfung", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
@@ -52,8 +131,14 @@ type resultData struct {
// 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)
r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
// ErrNotMultipart ist kein Fehlerfall: ParseMultipartForm ruft intern
// zuerst ParseForm auf, das Formularfelder auch aus einem klassischen
// urlencoded-Body liest (kein Standbild dabei, aber alle anderen
// Felder sind trotzdem gültig) — nur ein wirklich kaputter oder zu
// großer Body soll hier abbrechen.
if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
return
}
@@ -66,6 +151,12 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
asset, err := s.readUploadedAsset(r, "standbild")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
accountID := currentUser(r).AccountID
@@ -86,6 +177,13 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
if asset != nil {
if err := s.storeAsset(ctx, sub.ID, "initial", asset); err != nil {
http.Error(w, 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)
@@ -180,14 +278,33 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
}
}
var assetHash []byte
var assetSHA256Hex string
switch asset, assetErr := s.store.GetLatestAssetForSubmission(ctx, submissionID); {
case assetErr == nil:
assetSHA256Hex = asset.SHA256
assetHash, err = hex.DecodeString(asset.SHA256)
if err != nil {
http.Error(w, "gespeicherter Asset-Hash konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
case errors.Is(assetErr, store.ErrNotFound):
// Kein Standbild hochgeladen — das ist erlaubt, siehe CLAUDE.md
// (Standbild ist kein Pflichtfeld der Prüfung).
default:
http.Error(w, "Asset konnte nicht geladen werden: "+assetErr.Error(), http.StatusInternalServerError)
return
}
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})
SubmissionID string
Platform string
PostType string
Caption string
Facts rules.Facts
Findings []rules.Finding
AssetSHA256Hex string
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings, assetSHA256Hex})
if err != nil {
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
@@ -217,6 +334,7 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
},
Facts: facts,
Findings: dossierFindings,
AssetHash: assetHash,
MetadataHash: metadataHash,
TimestampToken: timestampToken,
GeneratedAt: time.Now(),

View File

@@ -0,0 +1,132 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
// postInsightsUpload lädt einen Insights-Screenshot für einen Beitrag hoch.
func postInsightsUpload(t *testing.T, s *web.Server, cookie *http.Cookie, submissionID string, imageBytes []byte) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="insights_standbild"; filename="insights.png"`},
"Content-Type": {"image/png"},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(imageBytes); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("multipart Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/beitraege/"+submissionID+"/insights", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestStorySubmissionShowsInsightsReminderAfterArchiving(t *testing.T) {
s, fs, cookie := newAuthedTestServer(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}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
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())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
}
body := detailResp.Body.String()
if !strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected an insights reminder for a freshly archived story, got: %s", body)
}
_ = fs
}
func TestInsightsUploadClearsReminder(t *testing.T) {
s, _, cookie := newAuthedTestServer(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}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
if resp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}); resp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", resp.Code, resp.Body.String())
}
uploadResp := postInsightsUpload(t, s, cookie, subID, tinyPNG)
if uploadResp.Code != http.StatusSeeOther {
t.Fatalf("insights upload status = %d, want 303, body: %s", uploadResp.Code, uploadResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
body := detailResp.Body.String()
if strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected the reminder to be gone after securing insights, got: %s", body)
}
if !strings.Contains(body, "Gesicherte Insights-Nachweise") {
t.Errorf("expected the secured insights section, got: %s", body)
}
}
func TestInsightsUploadRejectsForeignSubmission(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subID := checkAndReturnSubID2(t, s, cookieA, "story")
resp := postInsightsUpload(t, s, cookieB, subID, tinyPNG)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant insights upload status = %d, want 404", resp.Code)
}
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
t.Fatal("expected no asset from a rejected cross-tenant upload")
}
}
// checkAndReturnSubID2 ist checkAndReturnSubID mit ueberschreibbarem post_type.
func checkAndReturnSubID2(t *testing.T, s interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}, cookie *http.Cookie, postType string) string {
t.Helper()
form := checkForm("post_type", postType)
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
const marker = `name="submission_id" value="`
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a submission_id field in the result, got: %s", body)
}
rest := body[idx+len(marker):]
return rest[:strings.Index(rest, `"`)]
}

View File

@@ -0,0 +1,47 @@
package web
import (
"fmt"
"time"
)
// storyInsightsWindow ist das Zeitfenster, in dem Instagram nach
// eigener Aussage Story-Insights überhaupt vorhält — danach sind sie
// auch über den offiziellen Datenexport nicht mehr zu bekommen. Reine
// Produktentscheidung (Erinnerungs-Timing), keine Rechtsnorm — bewusst
// hier als Konstante und nicht in rules/*.yaml, das ist ausschließlich
// für die Kennzeichnungsprüfung reserviert (siehe CLAUDE.md).
const storyInsightsWindow = 24 * time.Hour
// insightsReminder beschreibt, ob und wie dringend eine Erinnerung
// angezeigt werden soll, die Kennzahlen (Insights) eines veröffentlichten
// Beitrags per Screenshot zu sichern, bevor sie unwiederbringlich
// verschwinden.
type insightsReminder struct {
Show bool
Urgent bool // Fenster läuft noch
Expired bool // Fenster ist wahrscheinlich schon vorbei
Message string
}
// computeInsightsReminder berechnet den Erinnerungsstatus. Nur für
// "story"-Beiträge relevant (siehe storyInsightsWindow); alle anderen
// Beitragstypen bekommen keine Erinnerung, da ihre Kennzahlen nicht auf
// dieselbe Art flüchtig sind.
func computeInsightsReminder(postType string, publishedAt time.Time, now time.Time, hasInsightsAsset bool) insightsReminder {
if postType != "story" || hasInsightsAsset || publishedAt.IsZero() {
return insightsReminder{}
}
elapsed := now.Sub(publishedAt)
if elapsed >= storyInsightsWindow {
return insightsReminder{
Show: true, Expired: true,
Message: "Das Zeitfenster für Story-Insights ist bei Instagram nach eigener Aussage abgelaufen (24 Stunden) — die Kennzahlen sind wahrscheinlich nicht mehr abrufbar.",
}
}
remainingHours := int((storyInsightsWindow - elapsed).Hours()) + 1
return insightsReminder{
Show: true, Urgent: true,
Message: fmt.Sprintf("Noch ca. %d Stunde(n), um die Story-Insights zu sichern, bevor sie bei Instagram verschwinden.", remainingHours),
}
}

View File

@@ -0,0 +1,45 @@
package web
import (
"testing"
"time"
)
func TestComputeInsightsReminderOnlyForStory(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("feed", now.Add(-time.Hour), now, false)
if r.Show {
t.Errorf("expected no reminder for a non-story post type, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWhenAlreadySecured(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-time.Hour), now, true)
if r.Show {
t.Errorf("expected no reminder once an insights asset already exists, got %+v", r)
}
}
func TestComputeInsightsReminderUrgentWithinWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-2*time.Hour), now, false)
if !r.Show || !r.Urgent || r.Expired {
t.Fatalf("expected an urgent, non-expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderExpiredAfterWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-25*time.Hour), now, false)
if !r.Show || !r.Expired || r.Urgent {
t.Fatalf("expected an expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWithoutPublishTime(t *testing.T) {
r := computeInsightsReminder("story", time.Time{}, time.Now(), false)
if r.Show {
t.Errorf("expected no reminder without a known publish time, got %+v", r)
}
}

View File

@@ -68,6 +68,41 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireAdmin schützt den Admin-Bereich. Ohne Sitzung geht es wie bei
// requirePage zu /login; mit einer Sitzung, aber ohne Admin-Rolle, gibt
// es 404 statt 403 — sonst würde eine 403 einem angemeldeten, aber
// unprivilegierten Nutzer verraten, dass unter dieser URL überhaupt
// etwas existiert.
func (s *Server) requireAdmin(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
}
if user.Role != "admin" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
}
}
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
// currentUser zu geben — die Navigation braucht nur, ob ein Admin-Link
// gezeigt werden soll.
type navData struct {
IsAdmin bool
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireAdmin aufrufbar (braucht
// currentUser).
func navFor(r *http.Request) navData {
return navData{IsAdmin: currentUser(r).Role == "admin"}
}
// 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,

View File

@@ -0,0 +1,142 @@
package web
import (
"errors"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
)
const oauthStateCookieName = "deklarix_oauth_state"
// knownPlatforms sind alle Plattformen, die die Verbindungs-Übersicht
// anzeigt — unabhängig davon, ob dafür schon ein Connector konfiguriert
// ist (siehe cmd/deklarix/main.go). Eine unkonfigurierte Plattform zeigt
// "nicht konfiguriert" statt eines Verbinden-Buttons.
var knownPlatforms = []string{"instagram", "tiktok"}
type connectionView struct {
Platform string
Configured bool
Connected bool
ConnectedAt string
}
type connectionsData struct {
Title string
Nav navData
Connections []connectionView
}
// handleConnectionsList zeigt, welche Plattformen der Account verbunden
// hat — Grundlage für die spätere automatische Beweissicherung
// (Post per API statt manuellem Screenshot-Upload abrufen).
func (s *Server) handleConnectionsList(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
existing, err := s.store.ListPlatformConnectionsForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Verbindungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
connectedAt := map[string]string{}
for _, c := range existing {
connectedAt[c.Platform] = c.ConnectedAt.Format("02.01.2006 15:04")
}
data := connectionsData{Title: "Verbindungen", Nav: navFor(r)}
for _, platform := range knownPlatforms {
_, configured := s.connectors[platform]
at, connected := connectedAt[platform]
data.Connections = append(data.Connections, connectionView{
Platform: platform, Configured: configured, Connected: connected, ConnectedAt: at,
})
}
if err := s.templates.ExecuteTemplate(w, "verbindungen", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleOAuthStart leitet zum Consent-Screen der Plattform weiter. Der
// state-Wert wird in einem kurzlebigen Cookie gehalten und beim
// Callback gegengeprüft — Schutz gegen CSRF (ein Angreifer könnte sonst
// einen fremden Autorisierungscode gegen das Konto des Opfers
// einschleusen).
func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
connector, ok := s.connectors[r.PathValue("platform")]
if !ok {
http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound)
return
}
state, err := auth.NewSessionToken()
if err != nil {
http.Error(w, "Anfrage konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: oauthStateCookieName, Value: state, Path: "/oauth",
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(10 * time.Minute),
})
http.Redirect(w, r, connector.AuthorizationURL(state), http.StatusSeeOther)
}
// handleOAuthCallback verarbeitet die Rückleitung von der Plattform:
// state prüfen, Code gegen ein Token tauschen, Verbindung speichern.
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
connector, ok := s.connectors[r.PathValue("platform")]
if !ok {
http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound)
return
}
// Der Nutzer hat die Autorisierung abgelehnt — kein Fehler unsererseits.
if errParam := r.URL.Query().Get("error"); errParam != "" {
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
return
}
stateCookie, err := r.Cookie(oauthStateCookieName)
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "ungültiger oder abgelaufener State-Parameter", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: oauthStateCookieName, Value: "", Path: "/oauth", MaxAge: -1})
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "kein Autorisierungscode erhalten", http.StatusBadRequest)
return
}
token, err := connector.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
var expiresAt *time.Time
if !token.ExpiresAt.IsZero() {
expiresAt = &token.ExpiresAt
}
accountID := currentUser(r).AccountID
if _, err := s.store.UpsertPlatformConnection(r.Context(), accountID, connector.Platform(), token.PlatformUserID, token.AccessToken, token.RefreshToken, expiresAt); err != nil {
http.Error(w, "Verbindung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
}
// handleDisconnect trennt eine Plattform-Verbindung.
func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
platform := r.PathValue("platform")
if err := s.store.DeletePlatformConnection(r.Context(), accountID, platform); err != nil && !errors.Is(err, store.ErrNotFound) {
http.Error(w, "Verbindung konnte nicht getrennt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
}

View File

@@ -0,0 +1,225 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/socialconnect"
)
// fakeConnector ist ein socialconnect.Connector-Fake für Tests — kein
// echter HTTP-Aufruf gegen Instagram/TikTok nötig.
type fakeConnector struct {
platform string
token socialconnect.Token
err error
}
func (f fakeConnector) Platform() string { return f.platform }
func (f fakeConnector) AuthorizationURL(state string) string {
return "https://provider.example/authorize?state=" + state + "&platform=" + f.platform
}
func (f fakeConnector) Exchange(ctx context.Context, code string) (socialconnect.Token, error) {
if f.err != nil {
return socialconnect.Token{}, f.err
}
return f.token, nil
}
func TestConnectionsListShowsUnconfiguredPlatformsWithoutConnectButton(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, cookie, "/verbindungen")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if strings.Contains(body, "/oauth/instagram/start") || strings.Contains(body, "/oauth/tiktok/start") {
t.Errorf("expected no connect links when no connector is configured, got: %s", body)
}
if !strings.Contains(body, "instagram") || !strings.Contains(body, "tiktok") {
t.Errorf("expected both platforms to be listed regardless of configuration, got: %s", body)
}
}
func TestConnectionsListShowsConnectButtonWhenConfigured(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
resp := getWithCookie(t, s, cookie, "/verbindungen")
body := resp.Body.String()
if !strings.Contains(body, "/oauth/instagram/start") {
t.Errorf("expected an Instagram connect link, got: %s", body)
}
if strings.Contains(body, "/oauth/tiktok/start") {
t.Errorf("expected no TikTok connect link (not configured), got: %s", body)
}
}
func TestOAuthStartRedirectsToProviderAndSetsStateCookie(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/start", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", w.Code)
}
loc := w.Header().Get("Location")
if !strings.HasPrefix(loc, "https://provider.example/authorize?") {
t.Fatalf("Location = %q, want a redirect to the provider", loc)
}
var stateCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == "deklarix_oauth_state" {
stateCookie = c
}
}
if stateCookie == nil || stateCookie.Value == "" {
t.Fatal("expected a non-empty deklarix_oauth_state cookie to be set")
}
}
func TestOAuthStartRejectsUnconfiguredPlatform(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, cookie, "/oauth/instagram/start")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for an unconfigured platform", resp.Code)
}
}
func TestOAuthCallbackStoresConnectionOnValidState(t *testing.T) {
fs := newFakeStore()
expires := time.Now().Add(60 * 24 * time.Hour)
connectors := map[string]socialconnect.Connector{
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{
AccessToken: "ig-token", PlatformUserID: "ig-user-1", ExpiresAt: expires,
}},
}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=gueltiger-state", nil)
req.AddCookie(cookie)
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "gueltiger-state"})
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", w.Code, w.Body.String())
}
if loc := w.Header().Get("Location"); loc != "/verbindungen" {
t.Fatalf("Location = %q, want /verbindungen", loc)
}
var accID string
for id := range fs.accounts {
accID = id
}
conn, err := fs.GetPlatformConnection(context.Background(), accID, "instagram")
if err != nil {
t.Fatalf("expected a stored connection, got err: %v", err)
}
if conn.AccessToken != "ig-token" || conn.PlatformUserID != "ig-user-1" {
t.Errorf("unexpected connection: %+v", conn)
}
}
func TestOAuthCallbackRejectsMismatchedState(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{AccessToken: "x", PlatformUserID: "y"}},
}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=falscher-state", nil)
req.AddCookie(cookie)
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "anderer-state"})
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a state mismatch", w.Code)
}
}
func TestOAuthCallbackHandlesUserDenial(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{
"instagram": fakeConnector{platform: "instagram"},
}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?error=access_denied", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (redirect back, no error page)", w.Code)
}
}
func TestDisconnectRemovesConnection(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
var accID string
for id := range fs.accounts {
accID = id
}
if _, err := fs.UpsertPlatformConnection(context.Background(), accID, "instagram", "u1", "tok", "", nil); err != nil {
t.Fatalf("UpsertPlatformConnection: %v", err)
}
resp := postForm(t, s, cookie, "/verbindungen/instagram/trennen", url.Values{})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if _, err := fs.GetPlatformConnection(context.Background(), accID, "instagram"); err == nil {
t.Fatal("expected the connection to be gone after disconnect")
}
}
func TestConnectionsIsolatedPerTenant(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
var accA string
for id, acc := range fs.accounts {
if acc.Name == "Mandant A" {
accA = id
}
}
if _, err := fs.UpsertPlatformConnection(context.Background(), accA, "instagram", "u1", "tok", "", nil); err != nil {
t.Fatalf("UpsertPlatformConnection: %v", err)
}
respA := getWithCookie(t, s, cookieA, "/verbindungen")
if !strings.Contains(respA.Body.String(), "verbunden seit") {
t.Errorf("expected Mandant A to see their own connection, got: %s", respA.Body.String())
}
respB := getWithCookie(t, s, cookieB, "/verbindungen")
if strings.Contains(respB.Body.String(), "verbunden seit") {
t.Errorf("expected Mandant B to see no connection, got: %s", respB.Body.String())
}
}

View File

@@ -16,6 +16,7 @@ import (
"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/socialconnect"
"github.com/netcell-it/deklarix/internal/store"
)
@@ -48,14 +49,35 @@ type Store interface {
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)
ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error)
CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error)
ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error)
GetParticipant(ctx context.Context, id string) (store.Participant, error)
UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error)
DeleteParticipant(ctx context.Context, id string) error
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
ListAccounts(ctx context.Context) ([]store.Account, error)
ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error)
SetAccountVerified(ctx context.Context, id string, verified bool) (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)
ListUsersForAccount(ctx context.Context, accountID 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
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error)
UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error)
ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error)
DeletePlatformConnection(ctx context.Context, accountID, platform string) error
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -66,13 +88,20 @@ type Server struct {
store Store
timestamper evidence.Timestamper
dossierDir string
assetDir string
connectors map[string]socialconnect.Connector
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) {
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
// assetDir das Verzeichnis für hochgeladene Standbilder. connectors
// enthält nur die Plattformen, für die echte Client-Credentials
// konfiguriert sind (siehe cmd/deklarix/main.go) — eine leere oder nil
// Map ist gültig, dann zeigt /verbindungen "nicht konfiguriert" statt
// eines Verbinden-Buttons.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string, connectors map[string]socialconnect.Connector) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
@@ -84,6 +113,8 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
store: st,
timestamper: timestamper,
dossierDir: dossierDir,
assetDir: assetDir,
connectors: connectors,
templates: tmpl,
}
@@ -98,6 +129,22 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
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.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList))
mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail))
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
mux.HandleFunc("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset))
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList))
mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart))
mux.HandleFunc("GET /oauth/{platform}/callback", s.requirePage(s.handleOAuthCallback))
mux.HandleFunc("POST /verbindungen/{platform}/trennen", s.requireAPI(s.handleDisconnect))
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard))
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))
mux.HandleFunc("POST /admin/accounts/{id}/verifizieren", s.requireAdmin(s.handleAdminSetVerified))
mux.HandleFunc("GET /admin/audit-log", s.requireAdmin(s.handleAdminAuditLog))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux

View File

@@ -26,6 +26,7 @@ import (
"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/socialconnect"
"github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web"
)
@@ -66,18 +67,28 @@ type fakeStore struct {
extractions map[string]store.Extraction
findings map[string][]store.Finding
evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant
auditLog []store.AuditEntry
assets map[string][]store.Asset // submissionID -> alle Assets, aeltestes zuerst
// platformConnections ist verschachtelt nach accountID -> platform,
// wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle.
platformConnections map[string]map[string]store.PlatformConnection
}
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{},
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{},
participants: map[string]store.Participant{},
assets: map[string][]store.Asset{},
platformConnections: map[string]map[string]store.PlatformConnection{},
}
}
@@ -94,6 +105,98 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
return acc, nil
}
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
return acc, nil
}
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Account
for _, acc := range f.accounts {
out = append(out, acc)
}
return out, nil
}
func (f *fakeStore) ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Account
for _, acc := range f.accounts {
if !acc.Verified {
continue
}
hasKanzlei := false
for _, u := range f.users {
if u.AccountID == acc.ID && u.Role == "kanzlei" {
hasKanzlei = true
break
}
}
if hasKanzlei {
out = append(out, acc)
}
}
return out, nil
}
func (f *fakeStore) SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error) {
f.mu.Lock()
defer f.mu.Unlock()
acc, ok := f.accounts[id]
if !ok {
return store.Account{}, store.ErrNotFound
}
acc.Verified = verified
f.accounts[id] = acc
return acc, nil
}
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.User
for _, u := range f.users {
if u.AccountID == accountID {
out = append(out, u)
}
}
return out, nil
}
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
e := store.AuditEntry{
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
TargetID: targetID, Details: details, CreatedAt: time.Now(),
}
f.auditLog = append(f.auditLog, e)
return e, nil
}
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
f.mu.Lock()
defer f.mu.Unlock()
// Neueste zuerst, wie die echte Store-Implementierung (ORDER BY
// created_at DESC) — hier reicht eine Umkehrung der Einfuegereihenfolge.
out := make([]store.AuditEntry, len(f.auditLog))
for i, e := range f.auditLog {
out[len(f.auditLog)-1-i] = e
}
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
f.mu.Lock()
defer f.mu.Unlock()
@@ -247,6 +350,195 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
return pkg, nil
}
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a := store.Asset{
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Purpose: purpose,
Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
}
f.assets[submissionID] = append(f.assets[submissionID], a)
return a, nil
}
// GetLatestAssetForSubmission liefert wie die echte Store-Implementierung
// nur das zuletzt hochgeladene Asset mit purpose="initial".
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
var latest store.Asset
found := false
for _, a := range f.assets[submissionID] {
if a.Purpose == "initial" {
latest = a
found = true
}
}
if !found {
return store.Asset{}, store.ErrNotFound
}
return latest, nil
}
func (f *fakeStore) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.assets[submissionID], nil
}
func (f *fakeStore) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) {
f.mu.Lock()
defer f.mu.Unlock()
byPlatform, ok := f.platformConnections[accountID]
if !ok {
byPlatform = map[string]store.PlatformConnection{}
f.platformConnections[accountID] = byPlatform
}
c := store.PlatformConnection{
ID: f.newID(), AccountID: accountID, Platform: platform, PlatformUserID: platformUserID,
AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, ConnectedAt: time.Now(),
}
if existing, ok := byPlatform[platform]; ok {
c.ID = existing.ID
}
byPlatform[platform] = c
return c, nil
}
func (f *fakeStore) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.PlatformConnection
for _, c := range f.platformConnections[accountID] {
out = append(out, c)
}
return out, nil
}
func (f *fakeStore) GetPlatformConnection(ctx context.Context, accountID, platform string) (store.PlatformConnection, error) {
f.mu.Lock()
defer f.mu.Unlock()
c, ok := f.platformConnections[accountID][platform]
if !ok {
return store.PlatformConnection{}, store.ErrNotFound
}
return c, nil
}
func (f *fakeStore) DeletePlatformConnection(ctx context.Context, accountID, platform string) error {
f.mu.Lock()
defer f.mu.Unlock()
byPlatform, ok := f.platformConnections[accountID]
if !ok {
return store.ErrNotFound
}
if _, ok := byPlatform[platform]; !ok {
return store.ErrNotFound
}
delete(byPlatform, platform)
return nil
}
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.SubmissionSummary
for _, sub := range f.submissions {
if sub.AccountID != accountID {
continue
}
sum := store.SubmissionSummary{Submission: sub}
rank := 0
for _, finding := range f.findings[sub.ID] {
sum.FindingCount++
r := severityRank(finding.Severity)
if r > rank {
rank = r
sum.HighestSeverity = finding.Severity
}
}
out = append(out, sum)
}
return out, nil
}
func severityRank(severity string) int {
switch severity {
case "hoch":
return 3
case "mittel":
return 2
case "niedrig":
return 1
default:
return 0
}
}
func (f *fakeStore) CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error) {
f.mu.Lock()
defer f.mu.Unlock()
p := store.Participant{
ID: f.newID(), SubmissionID: submissionID, Role: role, Name: name,
Vorgegeben: vorgegeben, Freigegeben: freigegeben, CreatedAt: time.Now(),
}
if freigegeben {
now := time.Now()
p.ApprovedAt = &now
}
f.participants[p.ID] = p
return p, nil
}
func (f *fakeStore) ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []store.Participant
for _, p := range f.participants {
if p.SubmissionID == submissionID {
out = append(out, p)
}
}
return out, nil
}
func (f *fakeStore) GetParticipant(ctx context.Context, id string) (store.Participant, error) {
f.mu.Lock()
defer f.mu.Unlock()
p, ok := f.participants[id]
if !ok {
return store.Participant{}, store.ErrNotFound
}
return p, nil
}
func (f *fakeStore) UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error) {
f.mu.Lock()
defer f.mu.Unlock()
p, ok := f.participants[id]
if !ok {
return store.Participant{}, store.ErrNotFound
}
p.Vorgegeben = vorgegeben
p.Freigegeben = freigegeben
if freigegeben && p.ApprovedAt == nil {
now := time.Now()
p.ApprovedAt = &now
}
f.participants[id] = p
return p, nil
}
func (f *fakeStore) DeleteParticipant(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.participants[id]; !ok {
return store.ErrNotFound
}
delete(f.participants, id)
return 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.
@@ -305,7 +597,12 @@ func loadRealRules(t *testing.T) []rules.Rule {
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())
return newServerWithConnectors(t, ex, fs, nil)
}
func newServerWithConnectors(t *testing.T, ex web.Extractor, fs *fakeStore, connectors map[string]socialconnect.Connector) *web.Server {
t.Helper()
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir(), connectors)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
@@ -315,6 +612,11 @@ func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
// 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()
return seedAccountWithRole(t, fs, accountName, email, "creator")
}
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
t.Helper()
ctx := context.Background()
acc, err := fs.CreateAccount(ctx, accountName)
@@ -325,7 +627,7 @@ func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.C
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := fs.CreateUser(ctx, acc.ID, email, hash, "creator")
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
@@ -483,6 +785,30 @@ func TestLoginWithCorrectPassword(t *testing.T) {
}
}
func TestLoginRedirectsAdminToAdminDashboard(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
hash, err := auth.HashPassword("admin-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Deklarix Admin")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := fs.CreateUser(context.Background(), acc.ID, "admin@example.com", hash, "admin"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"admin@example.com"}, "password": {"admin-passwort"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if loc := resp.Header().Get("Location"); loc != "/admin" {
t.Fatalf("Location = %q, want /admin for an admin login", loc)
}
}
func TestLoginRejectsWrongPassword(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)

View File

@@ -76,10 +76,23 @@ a {
nav {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
padding: 12px 16px;
}
nav a {
color: var(--color-muted);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
nav a:hover {
color: var(--color-text);
}
/* Formulare: großzügige Touch-Ziele (min. 44px Höhe), volle Breite auf
dem Handy. */
form {
@@ -232,6 +245,145 @@ nav button {
border-left: 4px solid var(--color-niedrig);
}
.status {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--radius);
background: var(--color-border);
color: var(--color-muted);
}
.status-published {
background: var(--color-niedrig-bg);
color: var(--color-niedrig);
}
.status-checked,
.status-mittel {
background: var(--color-mittel-bg);
color: var(--color-mittel);
}
.beitraege-liste {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beitraege-liste li a {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
}
.beteiligte {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.beteiligter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
.beteiligter-kopf {
flex: 1 1 100%;
}
.beteiligter .rolle {
color: var(--color-muted);
font-weight: 400;
}
.beteiligter-form {
flex-direction: row;
align-items: center;
gap: 12px;
margin: 0;
flex: 1 1 auto;
}
.beteiligter-form label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
margin: 0;
}
.beteiligter-form input[type="checkbox"] {
width: auto;
min-height: 0;
}
.beteiligter form:last-child {
margin: 0;
}
button.entfernen {
margin: 0;
background: transparent;
color: var(--color-hoch);
border: 1px solid var(--color-hoch-bg);
box-shadow: none;
min-height: 36px;
padding: 6px 14px;
font-size: 0.875rem;
}
.admin-kacheln {
list-style: none;
margin: 0 0 16px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-kacheln a {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 14px 16px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
color: var(--color-text);
text-decoration: none;
font-weight: 500;
}
.kanzlei-eintrag {
display: block;
padding: 12px 14px;
border-radius: var(--radius-md);
box-shadow: var(--shadow);
background: #fff;
}
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
Rand statt volle Breite, sonst bleibt alles identisch. */
@media (min-width: 640px) {

View File

@@ -0,0 +1,39 @@
{{define "admin-account-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/admin/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
</li>
{{end}}
</ul>
{{if .HasKanzlei}}
<h2>Kanzlei-Verzeichnis</h2>
{{if .Verified}}
<p>Status: <span class="status status-published">verifiziert — im öffentlichen Verzeichnis gelistet</span></p>
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
<input type="hidden" name="verified" value="false">
<button type="submit">Freigabe entziehen</button>
</form>
{{else}}
<p>Status: <span class="status status-mittel">nicht verifiziert — noch nicht gelistet</span></p>
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
<input type="hidden" name="verified" value="true">
<button type="submit">Fürs Verzeichnis freigeben</button>
</form>
{{end}}
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,30 @@
{{define "admin-accounts"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/admin">&larr; Admin</a></p>
<h1>Accounts</h1>
{{if not .Accounts}}
<p class="hinweis">Noch keine Accounts.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Accounts}}
<li>
<a href="/admin/accounts/{{.ID}}">
<strong>{{.Name}}</strong> · {{.CreatedAt}}
{{if .Verified}}
<span class="status status-published">verifiziert</span>
{{else}}
<span class="status status-mittel">nicht verifiziert</span>
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "admin-audit-log"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/admin">&larr; Admin</a></p>
<h1>Audit-Log</h1>
{{if not .Entries}}
<p class="hinweis">Noch keine Einträge.</p>
{{else}}
<ul class="beteiligte">
{{range .Entries}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — <strong>{{.Action}}</strong> ({{.TargetType}} {{.TargetID}})</div>
{{if .Details}}<p>{{.Details}}</p>{{end}}
</li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "admin-dashboard"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Admin</h1>
<ul class="admin-kacheln">
<li><a href="/admin/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
<li><a href="/admin/accounts">Unverifizierte Kanzleien <span class="status status-mittel">{{.UnverifiedCount}}</span></a></li>
<li><a href="/admin/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
<li><a href="/kanzleien">Öffentliches Kanzlei-Verzeichnis ansehen</a></li>
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,33 @@
{{define "beitraege"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Beiträge</h1>
{{if not .Submissions}}
<p class="hinweis">Noch keine Beiträge geprüft.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Submissions}}
<li>
<a href="/beitraege/{{.ID}}">
<strong>{{.Platform}}</strong> · {{.PostType}} · {{.CreatedAt}}
<span class="status status-{{.Status}}">{{.Status}}</span>
{{if .HighestSeverity}}
<span class="finding-{{.HighestSeverity}}">{{.FindingCount}} Finding(s), höchste: {{.HighestSeverity}}</span>
{{else}}
<span class="keine-findings">keine Findings</span>
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
<p><a href="/">Neuen Beitrag prüfen</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,85 @@
{{define "beitrag"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/beitraege">&larr; Alle Beiträge</a></p>
<h1>{{.Platform}} · {{.PostType}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
<p>{{.Caption}}</p>
<h2>Findings</h2>
{{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="#archiv-ergebnis" hx-swap="innerHTML">
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
<button type="submit">Als veröffentlicht markieren &amp; archivieren</button>
</form>
<div id="archiv-ergebnis"></div>
{{end}}
{{if .IsPublished}}
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
{{if .InsightsReminder.Show}}
<div class="{{if .InsightsReminder.Expired}}fehler{{else}}rueckfrage{{end}}">
<p>{{.InsightsReminder.Message}}</p>
<form method="post" action="/beitraege/{{.SubmissionID}}/insights" enctype="multipart/form-data">
<label for="insights_standbild">Insights-Screenshot</label>
<input type="file" id="insights_standbild" name="insights_standbild" accept="image/*" required>
<button type="submit">Insights jetzt sichern</button>
</form>
</div>
{{end}}
{{if .InsightsAssets}}
<h2>Gesicherte Insights-Nachweise</h2>
<ul class="beteiligte">
{{range .InsightsAssets}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — SHA-256: {{.SHA256}}</div>
</li>
{{end}}
</ul>
{{end}}
{{end}}
<h2>Verantwortungsmatrix</h2>
{{template "beteiligte-liste" .}}
<form hx-post="/beitraege/{{.SubmissionID}}/beteiligte" hx-target="#beteiligte" hx-swap="outerHTML">
<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="name">Name</label>
<input type="text" id="name" name="name" required>
<button type="submit">Beteiligten hinzufügen</button>
</form>
<p class="disclaimer">
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
Prüfung im Einzelfall.
</p>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,28 @@
{{define "beteiligte-liste"}}
<div id="beteiligte">
{{if not .Participants}}
<p class="hinweis">Noch keine Beteiligten erfasst.</p>
{{else}}
<ul class="beteiligte">
{{range .Participants}}
<li class="beteiligter">
<div class="beteiligter-kopf">
<strong>{{.Name}}</strong> <span class="rolle">({{.Role}})</span>
</div>
<form class="beteiligter-form"
hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/aktualisieren"
hx-target="#beteiligte" hx-swap="outerHTML" hx-trigger="change">
<label><input type="checkbox" name="vorgegeben" {{if .Vorgegeben}}checked{{end}}> Vorgegeben</label>
<label><input type="checkbox" name="freigegeben" {{if .Freigegeben}}checked{{end}}> Freigegeben</label>
{{if .ApprovedAt}}<span class="hinweis">freigegeben am {{.ApprovedAt}}</span>{{end}}
</form>
<form hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/loeschen"
hx-target="#beteiligte" hx-swap="outerHTML" hx-confirm="Beteiligten wirklich entfernen?">
<button type="submit" class="entfernen">Entfernen</button>
</form>
</li>
{{end}}
</ul>
{{end}}
</div>
{{end}}

View File

@@ -2,12 +2,12 @@
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .}}
{{template "nav" .Nav}}
<div class="page">
<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">
<form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML" hx-encoding="multipart/form-data" enctype="multipart/form-data">
<label for="platform">Plattform</label>
<select id="platform" name="platform" required>
<option value="instagram">Instagram</option>
@@ -38,6 +38,13 @@
<label for="caption">Caption</label>
<textarea id="caption" name="caption" rows="6" required></textarea>
<label for="standbild">Standbild (optional)</label>
<input type="file" id="standbild" name="standbild" accept="image/*">
<p class="hinweis">
Screenshot des veröffentlichten Beitrags — wird Teil des
Nachweis-Dossiers, sobald der Beitrag archiviert wird.
</p>
<button type="submit">Prüfen</button>
</form>

View File

@@ -0,0 +1,23 @@
{{define "kanzleien"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Kanzlei-Verzeichnis</h1>
<p class="hinweis">
Kostenloses Verzeichnis auf Deklarix verifizierter Kanzleien. Keine
Vermittlung, keine Empfehlung — die Auswahl trifft allein der Nutzer.
</p>
{{if not .Kanzleien}}
<p class="hinweis">Noch keine Kanzlei gelistet.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Kanzleien}}
<li><span class="kanzlei-eintrag">{{.Name}}</span></li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -8,6 +8,10 @@
{{define "nav"}}
<nav>
<a href="/">Prüfen</a>
<a href="/beitraege">Beiträge</a>
<a href="/verbindungen">Verbindungen</a>
{{if .IsAdmin}}<a href="/admin">Admin</a>{{end}}
<form method="post" action="/logout" style="display:inline">
<button type="submit">Abmelden</button>
</form>

View File

@@ -0,0 +1,41 @@
{{define "verbindungen"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Verbindungen</h1>
<p class="hinweis">
Verbinde deinen eigenen Instagram- oder TikTok-Account, damit die
Beweissicherung veröffentlichte Beiträge künftig direkt abrufen kann.
Ohne Verbindung funktioniert die Prüfung wie gewohnt mit manuellem
Screenshot-Upload.
</p>
<ul class="beteiligte">
{{range .Connections}}
<li class="beteiligter">
<div class="beteiligter-kopf">
<strong>{{.Platform}}</strong>
{{if .Connected}}
<span class="status status-published">verbunden seit {{.ConnectedAt}}</span>
{{else if .Configured}}
<span class="status status-mittel">nicht verbunden</span>
{{else}}
<span class="status">noch nicht konfiguriert</span>
{{end}}
</div>
{{if .Connected}}
<form method="post" action="/verbindungen/{{.Platform}}/trennen">
<button type="submit" class="entfernen">Trennen</button>
</form>
{{else if .Configured}}
<p><a href="/oauth/{{.Platform}}/start">Verbinden</a></p>
{{end}}
</li>
{{end}}
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -13,11 +13,25 @@ RULES_DIR=/usr/share/deklarix/rules
# Archivieren eines Beitrags automatisch angelegt.
DOSSIER_DIR=/var/lib/deklarix/dossiers
# Wo hochgeladene Standbilder abgelegt werden. Wird bei der
# Pre-Publish-Prüfung automatisch angelegt.
ASSET_DIR=/var/lib/deklarix/assets
# 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
# Optionale Plattform-Verbindung (OAuth, siehe CLAUDE.md "Plattform-
# Verbindung (OAuth)"). Ohne PUBLIC_BASE_URL und die Credentials der
# jeweiligen Plattform zeigt /verbindungen nur "nicht konfiguriert" —
# kein Absturz, keine dieser vier Variablen ist Pflicht.
#PUBLIC_BASE_URL=https://app.deklarix.de
#INSTAGRAM_CLIENT_ID=
#INSTAGRAM_CLIENT_SECRET=
#TIKTOK_CLIENT_KEY=
#TIKTOK_CLIENT_SECRET=
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-