Compare commits

...

6 Commits

Author SHA1 Message Date
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
38 changed files with 2882 additions and 30 deletions

View File

@@ -139,14 +139,38 @@ 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 (optional bei der Prüfung), Pfad,
SHA-256; append-only aus demselben Grund wie `finding`/`extraction`/
`evidence_package` — ein Beweisstück wird nicht nachträglich
ausgetauscht
- `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,
@@ -157,9 +181,10 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
**Append-only.** Kein UPDATE auf `finding`, `extraction` oder
`evidence_package`. Korrekturen sind neue Zeilen mit Verweis auf die alte.
Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
**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.
@@ -371,7 +396,7 @@ 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)
# Logs prüfen
journalctl -u deklarix -f

View File

@@ -48,7 +48,12 @@ func main() {
dossierDir = "dossiers"
}
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
assetDir := os.Getenv("ASSET_DIR")
if assetDir == "" {
assetDir = "assets"
}
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir)
if err != nil {
log.Fatalf("web server: %v", 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")
}
}

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

@@ -0,0 +1,65 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Asset ist eine zu einem Beitrag hochgeladene Datei (aktuell: das
// Standbild/der Screenshot). Append-only wie extraction/finding/
// evidence_package — ein hochgeladenes Beweisstück wird nicht
// nachträglich ausgetauscht, siehe Migration.
type Asset struct {
ID string
SubmissionID string
Kind 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, path, sha256Hex string) (Asset, error) {
var a Asset
err := s.Pool.QueryRow(ctx, `
INSERT INTO asset (submission_id, kind, path, sha256)
VALUES ($1, $2, $3, $4)
RETURNING id, submission_id, kind, path, sha256, created_at
`, submissionID, kind, path, sha256Hex).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &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
// eines Beitrags. Liefert ErrNotFound, wenn kein Asset 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, path, sha256, created_at
FROM asset
WHERE submission_id = $1
ORDER BY created_at DESC
LIMIT 1
`, submissionID).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &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
}

View File

@@ -0,0 +1,99 @@
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", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
if a.SubmissionID != sub.ID || a.Kind != "image" {
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", "/tmp/erstes.jpg", "erstehash"); err != nil {
t.Fatalf("CreateAsset (1): %v", err)
}
second, err := s.CreateAsset(ctx, sub.ID, "image", "/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 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", "/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,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

@@ -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,239 @@
package web
import (
"net/http"
"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 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
}
// 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
}
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),
}
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
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,90 @@
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 "standbild"-Feld. Liefert
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
file, header, err := r.FormFile("standbild")
// 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.
func (s *Server) storeAsset(ctx context.Context, submissionID string, ua *uploadedAsset) error {
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
}
path := filepath.Join(s.assetDir, submissionID+ua.extension)
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", 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 +92,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 +125,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 +145,12 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
asset, err := s.readUploadedAsset(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context()
accountID := currentUser(r).AccountID
@@ -86,6 +171,13 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
if asset != nil {
if err := s.storeAsset(ctx, sub.ID, 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 +272,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 +328,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

@@ -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

@@ -48,14 +48,30 @@ 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, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -66,13 +82,15 @@ type Server struct {
store Store
timestamper evidence.Timestamper
dossierDir string
assetDir string
templates *template.Template
}
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
// assetDir das Verzeichnis für hochgeladene Standbilder.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
@@ -84,6 +102,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
store: st,
timestamper: timestamper,
dossierDir: dossierDir,
assetDir: assetDir,
templates: tmpl,
}
@@ -98,6 +117,17 @@ 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("GET /kanzleien", s.handlePublicKanzleiList)
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

@@ -66,6 +66,9 @@ 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 -> zuletzt hochgeladenes Asset
}
func newFakeStore() *fakeStore {
@@ -78,6 +81,8 @@ func newFakeStore() *fakeStore {
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{},
}
}
@@ -94,6 +99,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 +344,126 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
return pkg, nil
}
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a := store.Asset{
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
}
f.assets[submissionID] = a
return a, nil
}
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.assets[submissionID]
if !ok {
return store.Asset{}, store.ErrNotFound
}
return a, 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 +522,7 @@ 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())
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
@@ -315,6 +532,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 +547,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 +705,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,63 @@
{{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>
{{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,9 @@
{{define "nav"}}
<nav>
<a href="/">Prüfen</a>
<a href="/beitraege">Beiträge</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

@@ -13,6 +13,10 @@ 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.