// 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/socialconnect" "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) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) DeletePlatformConnection(ctx context.Context, accountID, platform string) error } // Server bündelt Routing und Abhängigkeiten der Web-Schicht. type Server struct { mux *http.ServeMux extractor Extractor ruleSet []rules.Rule store Store timestamper evidence.Timestamper dossierDir string assetDir string connectors map[string]socialconnect.Connector templates *template.Template } // NewServer erstellt den Server. ruleSet kommt von rules.Load und wird // einmal beim Start geladen, nicht pro Request. dossierDir ist das // Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden; // assetDir das Verzeichnis für hochgeladene Standbilder. connectors // enthält nur die Plattformen, für die echte Client-Credentials // konfiguriert sind (siehe cmd/deklarix/main.go) — eine leere oder nil // Map ist gültig, dann zeigt /verbindungen "nicht konfiguriert" statt // eines Verbinden-Buttons. func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string, connectors map[string]socialconnect.Connector) (*Server, error) { tmpl, err := template.ParseFS(templatesFS, "templates/*.html") if err != nil { return nil, fmt.Errorf("web: templates parsen: %w", err) } s := &Server{ extractor: extractor, ruleSet: ruleSet, store: st, timestamper: timestamper, dossierDir: dossierDir, assetDir: assetDir, connectors: connectors, 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("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset)) mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList) mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList)) mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart)) mux.HandleFunc("GET /oauth/{platform}/callback", s.requirePage(s.handleOAuthCallback)) mux.HandleFunc("POST /verbindungen/{platform}/trennen", s.requireAPI(s.handleDisconnect)) mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard)) mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList)) mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail)) mux.HandleFunc("POST /admin/accounts/{id}/verifizieren", s.requireAdmin(s.handleAdminSetVerified)) mux.HandleFunc("GET /admin/audit-log", s.requireAdmin(s.handleAdminAuditLog)) mux.Handle("GET /static/", http.FileServerFS(staticFS)) s.mux = mux 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) }