diff --git a/CLAUDE.md b/CLAUDE.md index e0ccbc4..5b66b7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,10 @@ Fünf Tabellen, mehr braucht der MVP nicht: - `submission` — ein eingereichter Beitrag, Status, Zeitpunkte - `asset` — Bild oder Datei, Pfad, SHA-256 - `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version -- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere, Text +- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere, + Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert, + kein Verweis auf die aktuelle Regel-YAML — die kann sich inzwischen + geändert haben) - `evidence_package` — Dossier, Hash, Zeitstempel-Token, erzeugt am - `participant` — Beteiligter an einer Submission mit Rolle (`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur @@ -330,7 +333,8 @@ git push origin main sudo systemctl start deklarix sudo systemctl status deklarix -# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, ANTHROPIC_API_KEY, RULES_DIR) +# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, ANTHROPIC_API_KEY, +# RULES_DIR, DOSSIER_DIR, TSA_URL) # Logs prüfen journalctl -u deklarix -f diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index a4d9c74..40ea0aa 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" + "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" @@ -47,7 +48,14 @@ func main() { } log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir) - server, err := web.NewServer(extractor, ruleSet) + timestamper := evidence.NewHTTPTimestamper(os.Getenv("TSA_URL")) + + dossierDir := os.Getenv("DOSSIER_DIR") + if dossierDir == "" { + dossierDir = "dossiers" + } + + server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir) if err != nil { log.Fatalf("web server: %v", err) } diff --git a/internal/dossier/content.go b/internal/dossier/content.go index 8ab9cb2..8ff9986 100644 --- a/internal/dossier/content.go +++ b/internal/dossier/content.go @@ -101,9 +101,11 @@ func BuildContent(data Data) (Content, error) { if len(data.TimestampToken) == 0 { return Content{}, fmt.Errorf("dossier: timestamp token fehlt") } - if len(data.AssetHash) == 0 { - return Content{}, fmt.Errorf("dossier: asset hash fehlt") - } + // AssetHash ist optional: nicht jede Prüfung hat ein hochgeladenes + // Bild/Video (aktuell reine Caption-Prüfungen). Ein erfundener + // Platzhalter-Hash für ein nicht existierendes Asset wäre selbst ein + // Integritätsproblem — also zeigt das Dossier stattdessen "kein + // Asset hinterlegt" an, siehe Render. if len(data.MetadataHash) == 0 { return Content{}, fmt.Errorf("dossier: metadata hash fehlt") } diff --git a/internal/dossier/content_test.go b/internal/dossier/content_test.go index e5a144f..5e48026 100644 --- a/internal/dossier/content_test.go +++ b/internal/dossier/content_test.go @@ -115,20 +115,28 @@ func TestBuildContentRejectsMissingTimestampToken(t *testing.T) { } } -func TestBuildContentRejectsMissingHashes(t *testing.T) { +func TestBuildContentRejectsMissingMetadataHash(t *testing.T) { data := validData(t) - data.AssetHash = nil - if _, err := dossier.BuildContent(data); err == nil { - t.Fatal("expected error for missing asset hash, got nil") - } - - data = validData(t) data.MetadataHash = nil if _, err := dossier.BuildContent(data); err == nil { t.Fatal("expected error for missing metadata hash, got nil") } } +func TestBuildContentAllowsMissingAssetHash(t *testing.T) { + // Reine Caption-Pruefungen ohne hochgeladenes Bild/Video haben kein + // Asset zum Hashen — das ist kein Fehlerfall. + data := validData(t) + data.AssetHash = nil + content, err := dossier.BuildContent(data) + if err != nil { + t.Fatalf("BuildContent: %v", err) + } + if content.AssetHashHex != "" { + t.Fatalf("AssetHashHex = %q, want empty when no asset was provided", content.AssetHashHex) + } +} + func TestBuildContentSuccess(t *testing.T) { data := validData(t) diff --git a/internal/dossier/render.go b/internal/dossier/render.go index cc9fc5d..3b84bef 100644 --- a/internal/dossier/render.go +++ b/internal/dossier/render.go @@ -89,7 +89,11 @@ func Render(w io.Writer, c Content) error { pdf.Ln(2) heading("Beweiskette") - line("Asset-Hash (SHA-256): %s", c.AssetHashHex) + if c.AssetHashHex != "" { + line("Asset-Hash (SHA-256): %s", c.AssetHashHex) + } else { + line("Asset-Hash: kein Asset hinterlegt (reine Caption-Prüfung)") + } line("Metadaten-Hash (SHA-256): %s", c.MetadataHashHex) line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST")) pdf.Ln(4) diff --git a/internal/extract/client.go b/internal/extract/client.go index ded8d60..802ebc0 100644 --- a/internal/extract/client.go +++ b/internal/extract/client.go @@ -46,6 +46,16 @@ type Input struct { ImageData []byte } +// Result ist die Ausgabe von Extract: die für das Regelwerk +// aufbereiteten Facts, plus RawJSON — das exakte, unveränderte JSON, das +// das Modell zurückgegeben hat. RawJSON gehört unverändert in +// extraction.payload (siehe Datenmodell); Facts ist eine abgeleitete +// Sicht darauf und nicht der Beweis-Eintrag selbst. +type Result struct { + Facts rules.Facts + RawJSON []byte +} + // Client ruft die Claude API zur Fakten-Extraktion auf. type Client struct { apiKey string @@ -100,15 +110,15 @@ func (c *Client) ModelVersion() string { return c.model } // ungültiger Enum-Wert) wird ein Fehler zurückgegeben statt stumm ein // Zero-Value-Facts zu liefern — ein falsches "keine Gegenleistung" wäre // hier schlimmer als ein sichtbarer Fehler. -func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) { +func (c *Client) Extract(ctx context.Context, in Input) (Result, error) { if in.Caption == "" && len(in.ImageData) == 0 { - return rules.Facts{}, fmt.Errorf("extract: caption und bild sind beide leer") + return Result{}, fmt.Errorf("extract: caption und bild sind beide leer") } var content []contentBlock if len(in.ImageData) > 0 { if in.ImageMediaType == "" { - return rules.Facts{}, fmt.Errorf("extract: ImageMediaType fehlt für vorhandenes Bild") + return Result{}, fmt.Errorf("extract: ImageMediaType fehlt für vorhandenes Bild") } content = append(content, contentBlock{ Type: "image", @@ -132,12 +142,12 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) { payload, err := json.Marshal(reqBody) if err != nil { - return rules.Facts{}, fmt.Errorf("extract: request marshal: %w", err) + return Result{}, fmt.Errorf("extract: request marshal: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(payload)) if err != nil { - return rules.Facts{}, fmt.Errorf("extract: request bauen: %w", err) + return Result{}, fmt.Errorf("extract: request bauen: %w", err) } req.Header.Set("content-type", "application/json") req.Header.Set("x-api-key", c.apiKey) @@ -145,20 +155,20 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) { resp, err := c.httpClient.Do(req) if err != nil { - return rules.Facts{}, fmt.Errorf("extract: request fehlgeschlagen: %w", err) + return Result{}, fmt.Errorf("extract: request fehlgeschlagen: %w", err) } defer resp.Body.Close() var msg messageResponse if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { - return rules.Facts{}, fmt.Errorf("extract: response decode: %w", err) + return Result{}, fmt.Errorf("extract: response decode: %w", err) } if resp.StatusCode != http.StatusOK { if msg.Error != nil { - return rules.Facts{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message) + return Result{}, fmt.Errorf("extract: API-Fehler (%s): %s", msg.Error.Type, msg.Error.Message) } - return rules.Facts{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode) + return Result{}, fmt.Errorf("extract: API-Status %d", resp.StatusCode) } var toolUse *responseBlock @@ -169,22 +179,43 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) { } } if toolUse == nil { - return rules.Facts{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name) + return Result{}, fmt.Errorf("extract: keine tool_use-Antwort für %q enthalten", extractionTool.Name) } + facts, err := parseArgsToFacts(toolUse.Input, in.Platform, in.Jurisdiction) + if err != nil { + return Result{}, fmt.Errorf("extract: %w", err) + } + + return Result{Facts: facts, RawJSON: toolUse.Input}, nil +} + +// ParsePayload rekonstruiert Facts aus einem zuvor gespeicherten +// RawJSON-Payload (z. B. aus extraction.payload). platform/jurisdiction +// müssen erneut mitgegeben werden — sie sind, wie bei Extract, nie Teil +// des Modell-Payloads. +func ParsePayload(payload []byte, platform, jurisdiction string) (rules.Facts, error) { + facts, err := parseArgsToFacts(payload, platform, jurisdiction) + if err != nil { + return rules.Facts{}, fmt.Errorf("extract: %w", err) + } + return facts, nil +} + +func parseArgsToFacts(raw []byte, platform, jurisdiction string) (rules.Facts, error) { var args extractionArgs - if err := json.Unmarshal(toolUse.Input, &args); err != nil { - return rules.Facts{}, fmt.Errorf("extract: tool-input parse: %w", err) + if err := json.Unmarshal(raw, &args); err != nil { + return rules.Facts{}, fmt.Errorf("tool-input parse: %w", err) } consideration, err := parseConsideration(args.Consideration) if err != nil { - return rules.Facts{}, fmt.Errorf("extract: %w", err) + return rules.Facts{}, err } return rules.Facts{ - Platform: in.Platform, - Jurisdiction: in.Jurisdiction, + Platform: platform, + Jurisdiction: jurisdiction, Consideration: consideration, DisclosurePresent: args.DisclosurePresent, DisclosureWording: args.DisclosureWording, diff --git a/internal/extract/client_test.go b/internal/extract/client_test.go index 236af63..ad3dc60 100644 --- a/internal/extract/client_test.go +++ b/internal/extract/client_test.go @@ -103,8 +103,15 @@ func TestExtractSuccess(t *testing.T) { DisclosureWording: "Werbung", DisclosureBeforeCut: false, } - if got != want { - t.Fatalf("Extract() = %+v, want %+v", got, want) + if got.Facts != want { + t.Fatalf("Extract().Facts = %+v, want %+v", got.Facts, want) + } + var rawArgs map[string]any + if err := json.Unmarshal(got.RawJSON, &rawArgs); err != nil { + t.Fatalf("RawJSON does not parse as JSON: %v", err) + } + if rawArgs["gegenleistung"] != "bezahlt" { + t.Fatalf("RawJSON = %s, expected it to contain the model's raw field names", got.RawJSON) } toolChoice, _ := gotBody["tool_choice"].(map[string]any) @@ -131,8 +138,8 @@ func TestExtractPassesThroughUnclear(t *testing.T) { if err != nil { t.Fatalf("Extract: %v", err) } - if got.Consideration != rules.ConsiderationUnclear { - t.Fatalf("Consideration = %q, want unklar", got.Consideration) + if got.Facts.Consideration != rules.ConsiderationUnclear { + t.Fatalf("Consideration = %q, want unklar", got.Facts.Consideration) } } @@ -174,6 +181,34 @@ func TestExtractRejectsMalformedToolInput(t *testing.T) { } } +func TestParsePayloadRoundTrip(t *testing.T) { + payload := []byte(`{"gegenleistung":"sachbezug","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Anzeige","kennzeichnung_vor_kuerzung":true}`) + + facts, err := extract.ParsePayload(payload, "tiktok", "DE") + if err != nil { + t.Fatalf("ParsePayload: %v", err) + } + + want := rules.Facts{ + Platform: "tiktok", + Jurisdiction: "DE", + Consideration: rules.ConsiderationInKind, + DisclosurePresent: true, + DisclosureWording: "Anzeige", + DisclosureBeforeCut: true, + } + if facts != want { + t.Fatalf("ParsePayload() = %+v, want %+v", facts, want) + } +} + +func TestParsePayloadRejectsInvalidConsideration(t *testing.T) { + payload := []byte(`{"gegenleistung":"vielleicht","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`) + if _, err := extract.ParsePayload(payload, "tiktok", "DE"); err == nil { + t.Fatal("expected error for out-of-enum stored payload, got nil") + } +} + func TestExtractPropagatesAPIError(t *testing.T) { c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 7d99bde..f6eef43 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1,9 +1,15 @@ package web import ( + "encoding/hex" "fmt" "net/http" + "os" + "path/filepath" + "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" ) @@ -33,13 +39,18 @@ type findingView struct { } type resultData struct { + SubmissionID string NeedsClarification bool + CanArchive bool Findings []findingView } -// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1) -// gefolgt von der Regelauswertung (Stufe 2). Speichert noch nichts — -// das ist der nächste Schritt (Verdrahtung über internal/store). +// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1), +// Regelauswertung (Stufe 2), und speichert Submission + Extraction + +// Findings. Die eigentliche Archivierung (Hash, Zeitstempel, Dossier) +// passiert erst in handleArchive, wenn der Beitrag tatsächlich +// 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) @@ -47,13 +58,16 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { } platform := r.FormValue("platform") + postType := r.FormValue("post_type") caption := r.FormValue("caption") - if platform == "" || caption == "" { - http.Error(w, "Plattform und Caption sind Pflichtfelder", http.StatusBadRequest) + if platform == "" || postType == "" || caption == "" { + http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest) return } - facts, err := s.extractor.Extract(r.Context(), extract.Input{ + ctx := r.Context() + + result, err := s.extractor.Extract(ctx, extract.Input{ Platform: platform, Jurisdiction: "DE", Caption: caption, @@ -63,21 +77,180 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { return } - findings, needsClarification := rules.Evaluate(s.ruleSet, facts) + sub, err := s.store.CreateSubmission(ctx, platform, postType, caption) + if err != nil { + http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) + return + } - data := resultData{NeedsClarification: needsClarification} - for _, f := range findings { - data.Findings = append(data.Findings, findingView{ - RuleID: f.RuleID, - Version: f.RuleVersion, - Severity: string(f.Severity), - Title: f.Title, - Fix: f.Fix, - Sources: f.Sources, - }) + 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) + return + } + + findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts) + + data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification} + if !needsClarification { + extractionID := ext.ID + for _, f := range findings { + if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil { + http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) + return + } + data.Findings = append(data.Findings, findingView{ + RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity), + Title: f.Title, Fix: f.Fix, Sources: f.Sources, + }) + } + if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil { + http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) + return + } + data.CanArchive = true } if err := s.templates.ExecuteTemplate(w, "result", data); err != nil { http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError) } } + +type archivedData struct { + SubmissionID string + DossierURL string + TimestampedAt string +} + +// handleArchive markiert einen geprüften Beitrag als veröffentlicht und +// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier. +// Setzt eine vorherige Prüfung voraus (Submission + Extraction + +// Findings müssen bereits existieren). +func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + submissionID := r.FormValue("submission_id") + if submissionID == "" { + http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest) + return + } + + ctx := r.Context() + + sub, err := s.store.GetSubmission(ctx, submissionID) + if err != nil { + http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound) + return + } + + ext, err := s.store.GetLatestExtraction(ctx, submissionID) + if err != nil { + http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound) + return + } + facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE") + if err != nil { + http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID) + if err != nil { + http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) + return + } + dossierFindings := make([]rules.Finding, len(storeFindings)) + for i, f := range storeFindings { + dossierFindings[i] = rules.Finding{ + RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity), + Title: f.Title, Fix: f.Fix, Sources: f.Sources, + } + } + + 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}) + if err != nil { + http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash) + if err != nil { + http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway) + return + } + + if err := os.MkdirAll(s.dossierDir, 0o750); err != nil { + http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) + return + } + dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf") + dossierFile, err := os.Create(dossierPath) + if err != nil { + http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError) + return + } + defer dossierFile.Close() + + genErr := dossier.Generate(dossierFile, dossier.Data{ + Submission: dossier.Submission{ + Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt, + }, + Facts: facts, + Findings: dossierFindings, + MetadataHash: metadataHash, + TimestampToken: timestampToken, + GeneratedAt: time.Now(), + }) + if genErr != nil { + http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError) + return + } + + if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil { + http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) + return + } + if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil { + http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError) + return + } + + timestampedAt, err := evidence.TimestampTime(timestampToken) + if err != nil { + http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + data := archivedData{ + SubmissionID: submissionID, + DossierURL: "/dossier/" + submissionID, + TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"), + } + if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil { + http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +// handleDossierDownload liefert das erzeugte PDF-Dossier eines +// archivierten Beitrags aus. +func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + pkg, err := s.store.GetLatestEvidencePackage(r.Context(), id) + if err != nil { + http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/pdf") + http.ServeFile(w, r, pkg.DossierPath) +} diff --git a/internal/web/server.go b/internal/web/server.go index bd0f708..5284686 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -14,8 +14,10 @@ import ( "html/template" "net/http" + "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 @@ -28,31 +30,61 @@ var staticFS embed.FS // *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte // Claude-API-Aufrufe zu machen. type Extractor interface { - Extract(ctx context.Context, in extract.Input) (rules.Facts, error) + 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, 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) } // Server bündelt Routing und Abhängigkeiten der Web-Schicht. type Server struct { - mux *http.ServeMux - extractor Extractor - ruleSet []rules.Rule - templates *template.Template + 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. -func NewServer(extractor Extractor, ruleSet []rules.Rule) (*Server, error) { +// 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, templates: tmpl} + 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 /{$}", s.handleIndex) mux.HandleFunc("POST /pruefen", s.handleCheck) + mux.HandleFunc("POST /veroeffentlichen", s.handleArchive) + mux.HandleFunc("GET /dossier/{id}", s.handleDossierDownload) mux.Handle("GET /static/", http.FileServerFS(staticFS)) s.mux = mux diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 12cb472..8329399 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -2,27 +2,218 @@ package web_test import ( "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + "math/big" "net/http" "net/http/httptest" "net/url" "os" "strings" + "sync" "testing" + "time" + + "github.com/digitorus/timestamp" "github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/rules" + "github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/web" ) +// ─── Fakes ──────────────────────────────────────────────────────────── + type fakeExtractor struct { facts rules.Facts + raw []byte err error } -func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (rules.Facts, error) { - return f.facts, f.err +func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (extract.Result, error) { + if f.err != nil { + return extract.Result{}, f.err + } + raw := f.raw + if raw == nil { + raw = []byte(`{}`) + } + return extract.Result{Facts: f.facts, RawJSON: raw}, nil } +func (f fakeExtractor) ModelVersion() string { return "fake-model-v0" } + +// fakeStore ist eine In-Memory-Implementierung von web.Store, damit die +// Handler-Tests keine echte Postgres-Instanz brauchen. +type fakeStore struct { + mu sync.Mutex + nextID int + submissions map[string]store.Submission + extractions map[string]store.Extraction + findings map[string][]store.Finding + evidencePkgs map[string]store.EvidencePackage +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + submissions: map[string]store.Submission{}, + extractions: map[string]store.Extraction{}, + findings: map[string][]store.Finding{}, + evidencePkgs: map[string]store.EvidencePackage{}, + } +} + +func (f *fakeStore) newID() string { + f.nextID++ + return fmt.Sprintf("id-%d", f.nextID) +} + +func (f *fakeStore) CreateSubmission(ctx context.Context, platform, postType, caption string) (store.Submission, error) { + f.mu.Lock() + defer f.mu.Unlock() + sub := store.Submission{ + ID: f.newID(), Platform: platform, PostType: postType, Caption: caption, + Status: "draft", CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + f.submissions[sub.ID] = sub + return sub, nil +} + +func (f *fakeStore) GetSubmission(ctx context.Context, id string) (store.Submission, error) { + f.mu.Lock() + defer f.mu.Unlock() + sub, ok := f.submissions[id] + if !ok { + return store.Submission{}, fmt.Errorf("fakeStore: submission %s nicht gefunden", id) + } + return sub, nil +} + +func (f *fakeStore) SetSubmissionStatus(ctx context.Context, id, status string) error { + f.mu.Lock() + defer f.mu.Unlock() + sub, ok := f.submissions[id] + if !ok { + return fmt.Errorf("fakeStore: submission %s nicht gefunden", id) + } + sub.Status = status + f.submissions[id] = sub + return nil +} + +func (f *fakeStore) CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error) { + f.mu.Lock() + defer f.mu.Unlock() + ext := store.Extraction{ + ID: f.newID(), SubmissionID: submissionID, Payload: payload, + ModelVersion: modelVersion, PromptVersion: promptVersion, CreatedAt: time.Now(), + } + f.extractions[submissionID] = ext + return ext, nil +} + +func (f *fakeStore) GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error) { + f.mu.Lock() + defer f.mu.Unlock() + ext, ok := f.extractions[submissionID] + if !ok { + return store.Extraction{}, fmt.Errorf("fakeStore: keine Extraktion fuer %s", submissionID) + } + return ext, nil +} + +func (f *fakeStore) CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error) { + f.mu.Lock() + defer f.mu.Unlock() + finding := store.Finding{ + ID: f.newID(), SubmissionID: submissionID, ExtractionID: extractionID, + RuleID: ruleID, RuleVersion: ruleVersion, Severity: severity, + Title: title, Fix: fix, Sources: sources, CreatedAt: time.Now(), + } + f.findings[submissionID] = append(f.findings[submissionID], finding) + return finding, nil +} + +func (f *fakeStore) ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.findings[submissionID], nil +} + +func (f *fakeStore) CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error) { + f.mu.Lock() + defer f.mu.Unlock() + pkg := store.EvidencePackage{ + ID: f.newID(), SubmissionID: submissionID, DossierPath: dossierPath, + SHA256: sha256Hex, TimestampToken: timestampToken, CreatedAt: time.Now(), + } + f.evidencePkgs[submissionID] = pkg + return pkg, nil +} + +func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error) { + f.mu.Lock() + defer f.mu.Unlock() + pkg, ok := f.evidencePkgs[submissionID] + if !ok { + return store.EvidencePackage{}, fmt.Errorf("fakeStore: kein evidence package fuer %s", submissionID) + } + return pkg, 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. +type fakeTimestamper struct{} + +func (fakeTimestamper) Timestamp(ctx context.Context, hash []byte) ([]byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + now := time.Now() + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "deklarix-test-tsa"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageTimeStamping}, + } + certDER, err := x509.CreateCertificate(rand.Reader, certTemplate, certTemplate, &key.PublicKey, key) + if err != nil { + return nil, err + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + ts := timestamp.Timestamp{ + HashAlgorithm: crypto.SHA256, + HashedMessage: hash, + Time: now, + SerialNumber: big.NewInt(1), + Policy: asn1.ObjectIdentifier{1, 2, 3}, + } + respDER, err := ts.CreateResponse(cert, key) + if err != nil { + return nil, err + } + parsed, err := timestamp.ParseResponse(respDER) + if err != nil { + return nil, err + } + return parsed.RawToken, nil +} + +// ─── Test-Setup ─────────────────────────────────────────────────────── + func loadRealRules(t *testing.T) []rules.Rule { t.Helper() rs, err := rules.Load(os.DirFS("../../rules")) @@ -34,13 +225,15 @@ func loadRealRules(t *testing.T) []rules.Rule { func newTestServer(t *testing.T, ex web.Extractor) *web.Server { t.Helper() - s, err := web.NewServer(ex, loadRealRules(t)) + s, err := web.NewServer(ex, loadRealRules(t), newFakeStore(), fakeTimestamper{}, t.TempDir()) if err != nil { t.Fatalf("NewServer: %v", err) } return s } +// ─── Tests ──────────────────────────────────────────────────────────── + func TestHandleHealth(t *testing.T) { s := newTestServer(t, fakeExtractor{}) @@ -67,7 +260,7 @@ func TestHandleIndexRendersForm(t *testing.T) { t.Fatalf("status = %d, want 200", w.Code) } body := w.Body.String() - for _, want := range []string{`name="platform"`, `name="caption"`, `hx-post="/pruefen"`} { + for _, want := range []string{`name="platform"`, `name="post_type"`, `name="caption"`, `hx-post="/pruefen"`} { if !strings.Contains(body, want) { t.Errorf("index body missing %q\nbody: %s", want, body) } @@ -89,64 +282,87 @@ func TestHandleStaticServesHTMX(t *testing.T) { } } -func postCheck(t *testing.T, s *web.Server, form url.Values) *httptest.ResponseRecorder { +func postForm(t *testing.T, s *web.Server, path string, form url.Values) *httptest.ResponseRecorder { t.Helper() - req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode())) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.ServeHTTP(w, req) return w } +func checkForm(extra ...string) url.Values { + v := url.Values{"platform": {"instagram"}, "post_type": {"reel"}, "caption": {"..."}} + for i := 0; i+1 < len(extra); i += 2 { + v.Set(extra[i], extra[i+1]) + } + return v +} + func TestHandleCheckRejectsMissingFields(t *testing.T) { s := newTestServer(t, fakeExtractor{}) - w := postCheck(t, s, url.Values{"platform": {"instagram"}}) + w := postForm(t, s, "/pruefen", url.Values{"platform": {"instagram"}}) if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400 for missing caption", w.Code) + t.Fatalf("status = %d, want 400 for missing fields", w.Code) } } func TestHandleCheckPropagatesExtractionError(t *testing.T) { - s := newTestServer(t, fakeExtractor{err: errTest}) + s := newTestServer(t, fakeExtractor{err: fmt.Errorf("simulierter Extraktionsfehler")}) - w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"x"}}) + w := postForm(t, s, "/pruefen", checkForm()) if w.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502 when extraction fails", w.Code) } } -func TestHandleCheckRendersFinding(t *testing.T) { - s := newTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", - Jurisdiction: "DE", - Consideration: rules.ConsiderationPaid, - DisclosurePresent: true, - DisclosureWording: "Werbung", - DisclosureBeforeCut: false, - }}) - - w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"Werbung, schaut mal..."}}) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) +func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) { + fs := newFakeStore() + s, err := web.NewServer(fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid, + DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false, + }, raw: []byte(`{"gegenleistung":"bezahlt"}`)}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) + if err != nil { + t.Fatalf("NewServer: %v", err) } - body := w.Body.String() - if !strings.Contains(body, "WK-004") { - t.Errorf("expected WK-004 finding in result, got: %s", body) + + w := postForm(t, s, "/pruefen", checkForm()) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "WK-004") { + t.Fatalf("expected WK-004 in result, got: %s", w.Body.String()) + } + + if len(fs.submissions) != 1 { + t.Fatalf("expected 1 persisted submission, got %d", len(fs.submissions)) + } + var subID string + for id, sub := range fs.submissions { + subID = id + if sub.Status != "checked" { + t.Errorf("submission status = %q, want checked", sub.Status) + } + } + if _, ok := fs.extractions[subID]; !ok { + t.Error("expected an extraction to be persisted") + } + if len(fs.findings[subID]) != 1 || fs.findings[subID][0].RuleID != "WK-004" { + t.Errorf("expected exactly one persisted WK-004 finding, got %+v", fs.findings[subID]) } } -func TestHandleCheckRendersNeedsClarification(t *testing.T) { - s := newTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", - Jurisdiction: "DE", - Consideration: rules.ConsiderationUnclear, - }}) - - w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"..."}}) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) +func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) { + fs := newFakeStore() + s, err := web.NewServer(fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear, + }}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) + if err != nil { + t.Fatalf("NewServer: %v", err) } + + w := postForm(t, s, "/pruefen", checkForm()) body := w.Body.String() if strings.Contains(body, "WK-") { t.Errorf("expected no rule findings for an unclear extraction, got: %s", body) @@ -154,16 +370,87 @@ func TestHandleCheckRendersNeedsClarification(t *testing.T) { if !strings.Contains(body, "nicht sicher bestimmt") { t.Errorf("expected a clarification message, got: %s", body) } + if strings.Contains(body, "veroeffentlichen") || strings.Contains(body, "/veroeffentlichen") { + t.Errorf("expected no archive option when clarification is needed, got: %s", body) + } + for _, sub := range fs.submissions { + if sub.Status == "checked" { + t.Error("submission should not be marked checked when clarification is needed") + } + } } -func TestHandleCheckCleanCaseHasNoFindings(t *testing.T) { +func TestFullCheckThenArchiveFlow(t *testing.T) { + fs := newFakeStore() + s, err := web.NewServer(fakeExtractor{facts: rules.Facts{ + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid, + DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false, + }, raw: []byte(`{"gegenleistung":"bezahlt","kennzeichnung_vorhanden":true,"kennzeichnung_wortlaut":"Werbung","kennzeichnung_vor_kuerzung":false}`)}, + loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + checkResp := postForm(t, s, "/pruefen", checkForm()) + if checkResp.Code != http.StatusOK { + t.Fatalf("check status = %d, body: %s", checkResp.Code, checkResp.Body.String()) + } + if !strings.Contains(checkResp.Body.String(), `name="submission_id"`) { + t.Fatalf("expected a hidden submission_id field in the result, got: %s", checkResp.Body.String()) + } + + var subID string + for id := range fs.submissions { + subID = id + } + + archiveResp := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {subID}}) + if archiveResp.Code != http.StatusOK { + t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String()) + } + if !strings.Contains(archiveResp.Body.String(), "/dossier/"+subID) { + t.Fatalf("expected a dossier download link, got: %s", archiveResp.Body.String()) + } + + pkg, ok := fs.evidencePkgs[subID] + if !ok { + t.Fatal("expected an evidence package to be persisted") + } + if fs.submissions[subID].Status != "published" { + t.Errorf("submission status = %q, want published", fs.submissions[subID].Status) + } + + downloadReq := httptest.NewRequest(http.MethodGet, "/dossier/"+subID, nil) + downloadReq.SetPathValue("id", subID) + downloadW := httptest.NewRecorder() + s.ServeHTTP(downloadW, downloadReq) + if downloadW.Code != http.StatusOK { + t.Fatalf("download status = %d", downloadW.Code) + } + if !strings.HasPrefix(downloadW.Body.String(), "%PDF-") { + t.Fatal("downloaded dossier does not start with the PDF header") + } + + if _, err := os.Stat(pkg.DossierPath); err != nil { + t.Fatalf("dossier file does not exist on disk: %v", err) + } +} + +func TestHandleArchiveRejectsUnknownSubmission(t *testing.T) { + s := newTestServer(t, fakeExtractor{}) + + w := postForm(t, s, "/veroeffentlichen", url.Values{"submission_id": {"does-not-exist"}}) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for an unknown submission", w.Code) + } +} + +func TestHandleCheckCleanCaseHasNoFindingsAndOffersArchive(t *testing.T) { s := newTestServer(t, fakeExtractor{facts: rules.Facts{ - Platform: "instagram", - Jurisdiction: "DE", - Consideration: rules.ConsiderationNone, + Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone, }}) - w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"ein ganz normaler Post"}}) + w := postForm(t, s, "/pruefen", checkForm()) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } @@ -174,10 +461,7 @@ func TestHandleCheckCleanCaseHasNoFindings(t *testing.T) { if !strings.Contains(body, "Keine Kennzeichnungsrisiken") { t.Errorf("expected the no-findings message, got: %s", body) } + if !strings.Contains(body, "/veroeffentlichen") { + t.Errorf("expected an archive option even with no findings (still a real check), got: %s", body) + } } - -var errTest = extractError("simulierter Extraktionsfehler") - -type extractError string - -func (e extractError) Error() string { return string(e) } diff --git a/internal/web/templates/archived.html b/internal/web/templates/archived.html new file mode 100644 index 0000000..d8ff6d9 --- /dev/null +++ b/internal/web/templates/archived.html @@ -0,0 +1,13 @@ +{{define "archived"}} +

+ Beitrag archiviert und mit RFC-3161-Zeitstempel versehen + ({{.TimestampedAt}}). +

+

+ Nachweis-Dossier (PDF) herunterladen +

+

+ Dieses Dossier dokumentiert die durchgeführte Prüfung. Es ist keine + Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall. +

+{{end}} diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index d459e28..569f42a 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -9,6 +9,14 @@ + + + diff --git a/internal/web/templates/result.html b/internal/web/templates/result.html index 3a31983..15415e4 100644 --- a/internal/web/templates/result.html +++ b/internal/web/templates/result.html @@ -20,6 +20,14 @@ {{else}}

Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.

{{end}} + +{{if .CanArchive}} +
+ + +
+{{end}} +

Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall. diff --git a/packaging/etc/deklarix/deklarix.env.example b/packaging/etc/deklarix/deklarix.env.example index 3c515a4..590daaf 100644 --- a/packaging/etc/deklarix/deklarix.env.example +++ b/packaging/etc/deklarix/deklarix.env.example @@ -9,6 +9,15 @@ PORT=8080 # /usr/share/deklarix/rules installiert (siehe scripts/build.sh). RULES_DIR=/usr/share/deklarix/rules +# Wo erzeugte Nachweis-Dossiers (PDF) abgelegt werden. Wird beim +# Archivieren eines Beitrags automatisch angelegt. +DOSSIER_DIR=/var/lib/deklarix/dossiers + +# RFC-3161-Zeitstempeldienst. Leer = FreeTSA.org (frei, aber NICHT +# eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem +# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen. +#TSA_URL=https://freetsa.org/tsr + # Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL und # ANTHROPIC_API_KEY. Beide auskommentiert lassen, bis echte Werte # eingetragen sind: postinst prüft genau diese beiden Zeilen, um den