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>
130 lines
3.3 KiB
Go
130 lines
3.3 KiB
Go
package dossier
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/go-pdf/fpdf"
|
|
)
|
|
|
|
//go:embed assets/cp1252.map
|
|
var assetsFS embed.FS
|
|
|
|
// Generate erzeugt das PDF-Dossier für data und schreibt es nach w.
|
|
func Generate(w io.Writer, data Data) error {
|
|
content, err := BuildContent(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return Render(w, content)
|
|
}
|
|
|
|
// Render zeichnet ein bereits aufbereitetes Content als PDF nach w.
|
|
func Render(w io.Writer, c Content) error {
|
|
tr, err := unicodeTranslator()
|
|
if err != nil {
|
|
return fmt.Errorf("dossier: unicode translator: %w", err)
|
|
}
|
|
|
|
pdf := fpdf.New("P", "mm", "A4", "")
|
|
pdf.SetTitle(tr(c.Title), false)
|
|
pdf.AddPage()
|
|
|
|
heading := func(text string) {
|
|
pdf.SetFont("Helvetica", "B", 12)
|
|
pdf.CellFormat(0, 8, tr(text), "", 1, "L", false, 0, "")
|
|
pdf.SetFont("Helvetica", "", 10)
|
|
}
|
|
line := func(format string, args ...any) {
|
|
pdf.CellFormat(0, 6, tr(fmt.Sprintf(format, args...)), "", 1, "L", false, 0, "")
|
|
}
|
|
paragraph := func(text string) {
|
|
pdf.MultiCell(0, 5, tr(text), "", "L", false)
|
|
}
|
|
|
|
pdf.SetFont("Helvetica", "B", 16)
|
|
pdf.CellFormat(0, 10, tr(c.Title), "", 1, "L", false, 0, "")
|
|
pdf.SetFont("Helvetica", "", 10)
|
|
line("Erzeugt am %s", c.GeneratedAt.Format("02.01.2006 15:04 MST"))
|
|
pdf.Ln(4)
|
|
|
|
heading("Beitrag")
|
|
line("Plattform: %s Typ: %s", c.Platform, c.PostType)
|
|
line("Eingereicht am: %s", c.SubmittedAt.Format("02.01.2006 15:04"))
|
|
paragraph("Caption: " + c.Caption)
|
|
pdf.Ln(2)
|
|
|
|
heading("Kennzeichnung")
|
|
line("Vorhanden: %s Wortlaut: %q Vor Kürzung sichtbar: %s",
|
|
yesNo(c.DisclosurePresent), c.DisclosureWording, yesNo(c.DisclosureBeforeCut))
|
|
pdf.Ln(2)
|
|
|
|
heading("Findings")
|
|
if len(c.Findings) == 0 {
|
|
line("Keine Findings.")
|
|
}
|
|
for _, f := range c.Findings {
|
|
pdf.SetFont("Helvetica", "B", 10)
|
|
line("%s v%d — %s (%s)", f.RuleID, f.Version, f.Title, f.Severity)
|
|
pdf.SetFont("Helvetica", "", 10)
|
|
paragraph("Korrektur: " + f.Fix)
|
|
if len(f.Sources) > 0 {
|
|
paragraph("Fundstellen: " + strings.Join(f.Sources, "; "))
|
|
}
|
|
pdf.Ln(1)
|
|
}
|
|
pdf.Ln(2)
|
|
|
|
heading("Verantwortungsmatrix")
|
|
if len(c.Participants) == 0 {
|
|
line("Keine Beteiligten hinterlegt.")
|
|
}
|
|
for _, p := range c.Participants {
|
|
line("%s: %s (vorgegeben: %s, freigegeben: %s)",
|
|
p.Role, p.Name, yesNo(p.Vorgegeben), yesNo(p.Freigegeben))
|
|
}
|
|
pdf.Ln(2)
|
|
|
|
heading("Beweiskette")
|
|
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)
|
|
|
|
pdf.SetFont("Helvetica", "I", 8)
|
|
paragraph(c.Disclaimer)
|
|
|
|
if err := pdf.Error(); err != nil {
|
|
return fmt.Errorf("dossier: pdf aufbauen: %w", err)
|
|
}
|
|
if err := pdf.Output(w); err != nil {
|
|
return fmt.Errorf("dossier: pdf schreiben: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func yesNo(b bool) string {
|
|
if b {
|
|
return "ja"
|
|
}
|
|
return "nein"
|
|
}
|
|
|
|
// unicodeTranslator übersetzt UTF-8 (z. B. Umlaute) in die cp1252-
|
|
// Kodierung der Helvetica-Kernschriftart. Die Map-Datei ist eingebettet,
|
|
// damit Deklarix trotz PDF-Erzeugung ein einzelnes Binary bleibt.
|
|
func unicodeTranslator() (func(string) string, error) {
|
|
data, err := assetsFS.ReadFile("assets/cp1252.map")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fpdf.UnicodeTranslator(bytes.NewReader(data))
|
|
}
|