feat: wire persistence into the web layer (check → archive → dossier)
This is the piece that turns a Pre-Publish-Prüfung into an actual
archived, provable record instead of a one-off form response.
extract.Client.Extract now returns Result{Facts, RawJSON} instead of
just Facts — RawJSON is the model's exact, unmodified JSON, which is
what belongs in extraction.payload (the audit trail), not a re-encoded
view through our own Facts struct. extract.ParsePayload reconstructs
Facts from a stored payload later, reusing the same parsing/validation
path Extract uses (including the enum guard), so a previously-saved
extraction can be read back exactly as it would have been the first
time.
internal/dossier.BuildContent no longer requires AssetHash: most checks
right now are caption-only (no image/video upload wired yet), and
inventing a placeholder hash for a nonexistent asset would itself be an
integrity problem in an evidence tool. Content shows "kein Asset
hinterlegt" instead.
internal/web gains a narrow Store interface (mirroring the Extractor
pattern — only the methods these handlers use, not the full
*store.Store) so its test suite stays network/DB-free via an in-memory
fake:
- POST /pruefen persists submission + extraction + findings and marks
the submission "checked". A needsClarification result persists the
extraction (there's something worth keeping) but no findings and no
status change, and the template omits the archive option entirely.
- POST /veroeffentlichen re-derives Facts from the stored payload, hashes
the canonical submission+facts+findings metadata, gets an RFC-3161
timestamp, generates the PDF dossier to disk, and persists the
evidence_package row before marking the submission "published".
- GET /dossier/{id} serves the generated PDF.
Tested end-to-end offline: a fake Timestamper builds a real, structurally
valid self-signed RFC-3161 response so the full check→archive→download
flow runs against an in-memory store, verifying the downloaded bytes are
an actual PDF and the dossier file lands on disk — without hitting a
real database, TSA, or the Claude API.
cmd/deklarix/main.go now wires store.Store, evidence.NewHTTPTimestamper
(TSA_URL, default FreeTSA), and DOSSIER_DIR (default "dossiers") into
web.NewServer alongside the extractor and rule set.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -126,7 +126,10 @@ Fünf Tabellen, mehr braucht der MVP nicht:
|
|||||||
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
|
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
|
||||||
- `asset` — Bild oder Datei, Pfad, SHA-256
|
- `asset` — Bild oder Datei, Pfad, SHA-256
|
||||||
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
|
- `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
|
- `evidence_package` — Dossier, Hash, Zeitstempel-Token, erzeugt am
|
||||||
- `participant` — Beteiligter an einer Submission mit Rolle
|
- `participant` — Beteiligter an einer Submission mit Rolle
|
||||||
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
||||||
@@ -330,7 +333,8 @@ git push origin main
|
|||||||
sudo systemctl start deklarix
|
sudo systemctl start deklarix
|
||||||
sudo systemctl status 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
|
# Logs prüfen
|
||||||
journalctl -u deklarix -f
|
journalctl -u deklarix -f
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
@@ -47,7 +48,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Printf("%d Regeln aus %s geladen", len(ruleSet), rulesDir)
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("web server: %v", err)
|
log.Fatalf("web server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,9 +101,11 @@ func BuildContent(data Data) (Content, error) {
|
|||||||
if len(data.TimestampToken) == 0 {
|
if len(data.TimestampToken) == 0 {
|
||||||
return Content{}, fmt.Errorf("dossier: timestamp token fehlt")
|
return Content{}, fmt.Errorf("dossier: timestamp token fehlt")
|
||||||
}
|
}
|
||||||
if len(data.AssetHash) == 0 {
|
// AssetHash ist optional: nicht jede Prüfung hat ein hochgeladenes
|
||||||
return Content{}, fmt.Errorf("dossier: asset hash fehlt")
|
// 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 {
|
if len(data.MetadataHash) == 0 {
|
||||||
return Content{}, fmt.Errorf("dossier: metadata hash fehlt")
|
return Content{}, fmt.Errorf("dossier: metadata hash fehlt")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,20 +115,28 @@ func TestBuildContentRejectsMissingTimestampToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildContentRejectsMissingHashes(t *testing.T) {
|
func TestBuildContentRejectsMissingMetadataHash(t *testing.T) {
|
||||||
data := validData(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
|
data.MetadataHash = nil
|
||||||
if _, err := dossier.BuildContent(data); err == nil {
|
if _, err := dossier.BuildContent(data); err == nil {
|
||||||
t.Fatal("expected error for missing metadata hash, got 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) {
|
func TestBuildContentSuccess(t *testing.T) {
|
||||||
data := validData(t)
|
data := validData(t)
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ func Render(w io.Writer, c Content) error {
|
|||||||
pdf.Ln(2)
|
pdf.Ln(2)
|
||||||
|
|
||||||
heading("Beweiskette")
|
heading("Beweiskette")
|
||||||
|
if c.AssetHashHex != "" {
|
||||||
line("Asset-Hash (SHA-256): %s", 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("Metadaten-Hash (SHA-256): %s", c.MetadataHashHex)
|
||||||
line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST"))
|
line("RFC-3161-Zeitstempel: %s", c.TimestampedAt.Format("02.01.2006 15:04:05 MST"))
|
||||||
pdf.Ln(4)
|
pdf.Ln(4)
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ type Input struct {
|
|||||||
ImageData []byte
|
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.
|
// Client ruft die Claude API zur Fakten-Extraktion auf.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
apiKey string
|
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
|
// ungültiger Enum-Wert) wird ein Fehler zurückgegeben statt stumm ein
|
||||||
// Zero-Value-Facts zu liefern — ein falsches "keine Gegenleistung" wäre
|
// Zero-Value-Facts zu liefern — ein falsches "keine Gegenleistung" wäre
|
||||||
// hier schlimmer als ein sichtbarer Fehler.
|
// 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 {
|
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
|
var content []contentBlock
|
||||||
if len(in.ImageData) > 0 {
|
if len(in.ImageData) > 0 {
|
||||||
if in.ImageMediaType == "" {
|
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{
|
content = append(content, contentBlock{
|
||||||
Type: "image",
|
Type: "image",
|
||||||
@@ -132,12 +142,12 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) {
|
|||||||
|
|
||||||
payload, err := json.Marshal(reqBody)
|
payload, err := json.Marshal(reqBody)
|
||||||
if err != nil {
|
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))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/messages", bytes.NewReader(payload))
|
||||||
if err != nil {
|
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("content-type", "application/json")
|
||||||
req.Header.Set("x-api-key", c.apiKey)
|
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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil {
|
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 resp.StatusCode != http.StatusOK {
|
||||||
if msg.Error != nil {
|
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
|
var toolUse *responseBlock
|
||||||
@@ -169,22 +179,43 @@ func (c *Client) Extract(ctx context.Context, in Input) (rules.Facts, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if toolUse == nil {
|
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
|
var args extractionArgs
|
||||||
if err := json.Unmarshal(toolUse.Input, &args); err != nil {
|
if err := json.Unmarshal(raw, &args); err != nil {
|
||||||
return rules.Facts{}, fmt.Errorf("extract: tool-input parse: %w", err)
|
return rules.Facts{}, fmt.Errorf("tool-input parse: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
consideration, err := parseConsideration(args.Consideration)
|
consideration, err := parseConsideration(args.Consideration)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rules.Facts{}, fmt.Errorf("extract: %w", err)
|
return rules.Facts{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules.Facts{
|
return rules.Facts{
|
||||||
Platform: in.Platform,
|
Platform: platform,
|
||||||
Jurisdiction: in.Jurisdiction,
|
Jurisdiction: jurisdiction,
|
||||||
Consideration: consideration,
|
Consideration: consideration,
|
||||||
DisclosurePresent: args.DisclosurePresent,
|
DisclosurePresent: args.DisclosurePresent,
|
||||||
DisclosureWording: args.DisclosureWording,
|
DisclosureWording: args.DisclosureWording,
|
||||||
|
|||||||
@@ -103,8 +103,15 @@ func TestExtractSuccess(t *testing.T) {
|
|||||||
DisclosureWording: "Werbung",
|
DisclosureWording: "Werbung",
|
||||||
DisclosureBeforeCut: false,
|
DisclosureBeforeCut: false,
|
||||||
}
|
}
|
||||||
if got != want {
|
if got.Facts != want {
|
||||||
t.Fatalf("Extract() = %+v, want %+v", got, 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)
|
toolChoice, _ := gotBody["tool_choice"].(map[string]any)
|
||||||
@@ -131,8 +138,8 @@ func TestExtractPassesThroughUnclear(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Extract: %v", err)
|
t.Fatalf("Extract: %v", err)
|
||||||
}
|
}
|
||||||
if got.Consideration != rules.ConsiderationUnclear {
|
if got.Facts.Consideration != rules.ConsiderationUnclear {
|
||||||
t.Fatalf("Consideration = %q, want unklar", got.Consideration)
|
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) {
|
func TestExtractPropagatesAPIError(t *testing.T) {
|
||||||
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusTooManyRequests)
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"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/extract"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
)
|
)
|
||||||
@@ -33,13 +39,18 @@ type findingView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type resultData struct {
|
type resultData struct {
|
||||||
|
SubmissionID string
|
||||||
NeedsClarification bool
|
NeedsClarification bool
|
||||||
|
CanArchive bool
|
||||||
Findings []findingView
|
Findings []findingView
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1)
|
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1),
|
||||||
// gefolgt von der Regelauswertung (Stufe 2). Speichert noch nichts —
|
// Regelauswertung (Stufe 2), und speichert Submission + Extraction +
|
||||||
// das ist der nächste Schritt (Verdrahtung über internal/store).
|
// 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) {
|
func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
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")
|
platform := r.FormValue("platform")
|
||||||
|
postType := r.FormValue("post_type")
|
||||||
caption := r.FormValue("caption")
|
caption := r.FormValue("caption")
|
||||||
if platform == "" || caption == "" {
|
if platform == "" || postType == "" || caption == "" {
|
||||||
http.Error(w, "Plattform und Caption sind Pflichtfelder", http.StatusBadRequest)
|
http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
facts, err := s.extractor.Extract(r.Context(), extract.Input{
|
ctx := r.Context()
|
||||||
|
|
||||||
|
result, err := s.extractor.Extract(ctx, extract.Input{
|
||||||
Platform: platform,
|
Platform: platform,
|
||||||
Jurisdiction: "DE",
|
Jurisdiction: "DE",
|
||||||
Caption: caption,
|
Caption: caption,
|
||||||
@@ -63,21 +77,180 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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}
|
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 {
|
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{
|
data.Findings = append(data.Findings, findingView{
|
||||||
RuleID: f.RuleID,
|
RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity),
|
||||||
Version: f.RuleVersion,
|
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
||||||
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 {
|
if err := s.templates.ExecuteTemplate(w, "result", data); err != nil {
|
||||||
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.html
|
//go:embed templates/*.html
|
||||||
@@ -28,7 +30,24 @@ var staticFS embed.FS
|
|||||||
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
|
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
|
||||||
// Claude-API-Aufrufe zu machen.
|
// Claude-API-Aufrufe zu machen.
|
||||||
type Extractor interface {
|
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.
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||||
@@ -36,23 +55,36 @@ type Server struct {
|
|||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
extractor Extractor
|
extractor Extractor
|
||||||
ruleSet []rules.Rule
|
ruleSet []rules.Rule
|
||||||
|
store Store
|
||||||
|
timestamper evidence.Timestamper
|
||||||
|
dossierDir string
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
||||||
// einmal beim Start geladen, nicht pro Request.
|
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
||||||
func NewServer(extractor Extractor, ruleSet []rules.Rule) (*Server, error) {
|
// 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")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health", s.handleHealth)
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
mux.HandleFunc("GET /{$}", s.handleIndex)
|
mux.HandleFunc("GET /{$}", s.handleIndex)
|
||||||
mux.HandleFunc("POST /pruefen", s.handleCheck)
|
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))
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
||||||
s.mux = mux
|
s.mux = mux
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,217 @@ package web_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/asn1"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/digitorus/timestamp"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ─── Fakes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type fakeExtractor struct {
|
type fakeExtractor struct {
|
||||||
facts rules.Facts
|
facts rules.Facts
|
||||||
|
raw []byte
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (rules.Facts, error) {
|
func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (extract.Result, error) {
|
||||||
return f.facts, f.err
|
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 {
|
func loadRealRules(t *testing.T) []rules.Rule {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -34,13 +225,15 @@ func loadRealRules(t *testing.T) []rules.Rule {
|
|||||||
|
|
||||||
func newTestServer(t *testing.T, ex web.Extractor) *web.Server {
|
func newTestServer(t *testing.T, ex web.Extractor) *web.Server {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := web.NewServer(ex, loadRealRules(t))
|
s, err := web.NewServer(ex, loadRealRules(t), newFakeStore(), fakeTimestamper{}, t.TempDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestHandleHealth(t *testing.T) {
|
func TestHandleHealth(t *testing.T) {
|
||||||
s := newTestServer(t, fakeExtractor{})
|
s := newTestServer(t, fakeExtractor{})
|
||||||
|
|
||||||
@@ -67,7 +260,7 @@ func TestHandleIndexRendersForm(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 200", w.Code)
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
body := w.Body.String()
|
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) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("index body missing %q\nbody: %s", want, body)
|
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()
|
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")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
s.ServeHTTP(w, req)
|
s.ServeHTTP(w, req)
|
||||||
return w
|
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) {
|
func TestHandleCheckRejectsMissingFields(t *testing.T) {
|
||||||
s := newTestServer(t, fakeExtractor{})
|
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 {
|
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) {
|
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 {
|
if w.Code != http.StatusBadGateway {
|
||||||
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
|
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleCheckRendersFinding(t *testing.T) {
|
func TestHandleCheckPersistsSubmissionExtractionAndFindings(t *testing.T) {
|
||||||
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
|
fs := newFakeStore()
|
||||||
Platform: "instagram",
|
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
|
||||||
Jurisdiction: "DE",
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
|
||||||
Consideration: rules.ConsiderationPaid,
|
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
|
||||||
DisclosurePresent: true,
|
}, raw: []byte(`{"gegenleistung":"bezahlt"}`)}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
|
||||||
DisclosureWording: "Werbung",
|
if err != nil {
|
||||||
DisclosureBeforeCut: false,
|
t.Fatalf("NewServer: %v", err)
|
||||||
}})
|
}
|
||||||
|
|
||||||
w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"Werbung, schaut mal..."}})
|
w := postForm(t, s, "/pruefen", checkForm())
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
t.Fatalf("status = %d, want 200, body: %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
body := w.Body.String()
|
if !strings.Contains(w.Body.String(), "WK-004") {
|
||||||
if !strings.Contains(body, "WK-004") {
|
t.Fatalf("expected WK-004 in result, got: %s", w.Body.String())
|
||||||
t.Errorf("expected WK-004 finding in result, got: %s", body)
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestHandleCheckRendersNeedsClarificationAndDoesNotOfferArchive(t *testing.T) {
|
||||||
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
|
fs := newFakeStore()
|
||||||
Platform: "instagram",
|
s, err := web.NewServer(fakeExtractor{facts: rules.Facts{
|
||||||
Jurisdiction: "DE",
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationUnclear,
|
||||||
Consideration: rules.ConsiderationUnclear,
|
}}, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
|
||||||
}})
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer: %v", err)
|
||||||
w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"..."}})
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w := postForm(t, s, "/pruefen", checkForm())
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if strings.Contains(body, "WK-") {
|
if strings.Contains(body, "WK-") {
|
||||||
t.Errorf("expected no rule findings for an unclear extraction, got: %s", body)
|
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") {
|
if !strings.Contains(body, "nicht sicher bestimmt") {
|
||||||
t.Errorf("expected a clarification message, got: %s", body)
|
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{
|
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
Platform: "instagram",
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||||
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 {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", w.Code)
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
}
|
}
|
||||||
@@ -174,10 +461,7 @@ func TestHandleCheckCleanCaseHasNoFindings(t *testing.T) {
|
|||||||
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
|
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
|
||||||
t.Errorf("expected the no-findings message, got: %s", body)
|
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) }
|
|
||||||
|
|||||||
13
internal/web/templates/archived.html
Normal file
13
internal/web/templates/archived.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{{define "archived"}}
|
||||||
|
<p class="archiviert">
|
||||||
|
Beitrag archiviert und mit RFC-3161-Zeitstempel versehen
|
||||||
|
({{.TimestampedAt}}).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a>
|
||||||
|
</p>
|
||||||
|
<p class="disclaimer">
|
||||||
|
Dieses Dossier dokumentiert die durchgeführte Prüfung. Es ist keine
|
||||||
|
Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
@@ -9,6 +9,14 @@
|
|||||||
<option value="tiktok">TikTok</option>
|
<option value="tiktok">TikTok</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<label for="post_type">Beitragstyp</label>
|
||||||
|
<select id="post_type" name="post_type" required>
|
||||||
|
<option value="feed">Feed</option>
|
||||||
|
<option value="reel">Reel</option>
|
||||||
|
<option value="story">Story</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<label for="caption">Caption</label>
|
<label for="caption">Caption</label>
|
||||||
<textarea id="caption" name="caption" rows="6" required></textarea>
|
<textarea id="caption" name="caption" rows="6" required></textarea>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,14 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
|
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CanArchive}}
|
||||||
|
<form hx-post="/veroeffentlichen" hx-target="#ergebnis" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
|
||||||
|
<button type="submit">Als veröffentlicht markieren & archivieren</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<p class="disclaimer">
|
<p class="disclaimer">
|
||||||
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
||||||
Prüfung im Einzelfall.
|
Prüfung im Einzelfall.
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ PORT=8080
|
|||||||
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
|
# /usr/share/deklarix/rules installiert (siehe scripts/build.sh).
|
||||||
RULES_DIR=/usr/share/deklarix/rules
|
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
|
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL und
|
||||||
# ANTHROPIC_API_KEY. Beide auskommentiert lassen, bis echte Werte
|
# ANTHROPIC_API_KEY. Beide auskommentiert lassen, bis echte Werte
|
||||||
# eingetragen sind: postinst prüft genau diese beiden Zeilen, um den
|
# eingetragen sind: postinst prüft genau diese beiden Zeilen, um den
|
||||||
|
|||||||
Reference in New Issue
Block a user