Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.
- internal/socialconnect: Connector-Interface + InstagramConnector/
TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
(State-Cookie gegen CSRF, Redirect zum Consent-Screen),
GET /oauth/{platform}/callback (State prüfen, Code tauschen,
Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
"nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.
Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
156 lines
8.0 KiB
Go
156 lines
8.0 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/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, path, sha256Hex string) (store.Asset, error)
|
|
GetLatestAssetForSubmission(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("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)
|
|
}
|