Files
deklarix/internal/web/server.go
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

136 lines
6.6 KiB
Go

// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
// Account — siehe middleware.go.
package web
import (
"context"
"embed"
"fmt"
"html/template"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
// Tests injizieren einen Fake, um Facts unabhängig von der echten
// Erkennungslogik vorzugeben.
type Extractor interface {
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
ModelVersion() string
}
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
GetSubmission(ctx context.Context, id string) (store.Submission, error)
SetSubmissionStatus(ctx context.Context, id, status string) error
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
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)
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
extractor Extractor
ruleSet []rules.Rule
store Store
timestamper evidence.Timestamper
dossierDir string
templates *template.Template
}
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
}
s := &Server{
extractor: extractor,
ruleSet: ruleSet,
store: st,
timestamper: timestamper,
dossierDir: dossierDir,
templates: tmpl,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /register", s.handleRegisterForm)
mux.HandleFunc("POST /register", s.handleRegister)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
mux.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
return s, nil
}
// ServeHTTP macht Server zu einem http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}