From db373e51ced6a316c8b1ad4405ab619619bb86bc Mon Sep 17 00:00:00 2001 From: noroot Date: Thu, 27 Aug 2026 14:18:01 +0200 Subject: [PATCH] feat: add PDF dossier generation (internal/dossier) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content assembly (BuildContent) is separate from PDF drawing (Render), so the actual business logic — what goes into the evidence dossier, in what form, with which mandatory fields — is unit-testable without parsing PDF bytes. BuildContent refuses to produce a dossier missing its evidentiary fields (timestamp token, asset/metadata hash, platform) rather than emitting one with silently empty proof sections. Every dossier carries the "this is not legal advice" disclaimer required by CLAUDE.md's guardrails. Uses github.com/go-pdf/fpdf (actively maintained fork of jung-kurt/ gofpdf, no dependencies beyond the Go stdlib) for rendering. Its core fonts use cp1252 internally, so a small cp1252.map (copied from the fpdf module, embedded via go:embed) drives UnicodeTranslator — German umlauts render correctly without needing an external font file at runtime, keeping Deklarix a single binary. Verified visually with pdftotext/pdfinfo against a generated sample. Tests build a real, structurally valid RFC-3161 token offline (a throwaway self-signed cert + timestamp.Timestamp.CreateResponse), so BuildContent/Render/Generate are fully tested without hitting a real TSA — unlike the network-gated integration test in internal/evidence. Also adds evidence.TimestampTime(), extracted from the parsing logic already used by the TSA client, since the dossier needs to show the timestamped time to a human reader. Co-Authored-By: Claude Sonnet 5 --- go.mod | 1 + go.sum | 2 + internal/dossier/assets/cp1252.map | 251 +++++++++++++++++++++++++++++ internal/dossier/content.go | 155 ++++++++++++++++++ internal/dossier/content_test.go | 158 ++++++++++++++++++ internal/dossier/render.go | 125 ++++++++++++++ internal/dossier/render_test.go | 51 ++++++ internal/evidence/parse.go | 18 +++ internal/evidence/parse_test.go | 13 ++ internal/evidence/tsa_test.go | 8 + 10 files changed, 782 insertions(+) create mode 100644 internal/dossier/assets/cp1252.map create mode 100644 internal/dossier/content.go create mode 100644 internal/dossier/content_test.go create mode 100644 internal/dossier/render.go create mode 100644 internal/dossier/render_test.go create mode 100644 internal/evidence/parse.go create mode 100644 internal/evidence/parse_test.go diff --git a/go.mod b/go.mod index 50709c9..54eb167 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.6 require ( github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea + github.com/go-pdf/fpdf v0.9.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.10.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index f275412..87feb21 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= diff --git a/internal/dossier/assets/cp1252.map b/internal/dossier/assets/cp1252.map new file mode 100644 index 0000000..dd490e5 --- /dev/null +++ b/internal/dossier/assets/cp1252.map @@ -0,0 +1,251 @@ +!00 U+0000 .notdef +!01 U+0001 .notdef +!02 U+0002 .notdef +!03 U+0003 .notdef +!04 U+0004 .notdef +!05 U+0005 .notdef +!06 U+0006 .notdef +!07 U+0007 .notdef +!08 U+0008 .notdef +!09 U+0009 .notdef +!0A U+000A .notdef +!0B U+000B .notdef +!0C U+000C .notdef +!0D U+000D .notdef +!0E U+000E .notdef +!0F U+000F .notdef +!10 U+0010 .notdef +!11 U+0011 .notdef +!12 U+0012 .notdef +!13 U+0013 .notdef +!14 U+0014 .notdef +!15 U+0015 .notdef +!16 U+0016 .notdef +!17 U+0017 .notdef +!18 U+0018 .notdef +!19 U+0019 .notdef +!1A U+001A .notdef +!1B U+001B .notdef +!1C U+001C .notdef +!1D U+001D .notdef +!1E U+001E .notdef +!1F U+001F .notdef +!20 U+0020 space +!21 U+0021 exclam +!22 U+0022 quotedbl +!23 U+0023 numbersign +!24 U+0024 dollar +!25 U+0025 percent +!26 U+0026 ampersand +!27 U+0027 quotesingle +!28 U+0028 parenleft +!29 U+0029 parenright +!2A U+002A asterisk +!2B U+002B plus +!2C U+002C comma +!2D U+002D hyphen +!2E U+002E period +!2F U+002F slash +!30 U+0030 zero +!31 U+0031 one +!32 U+0032 two +!33 U+0033 three +!34 U+0034 four +!35 U+0035 five +!36 U+0036 six +!37 U+0037 seven +!38 U+0038 eight +!39 U+0039 nine +!3A U+003A colon +!3B U+003B semicolon +!3C U+003C less +!3D U+003D equal +!3E U+003E greater +!3F U+003F question +!40 U+0040 at +!41 U+0041 A +!42 U+0042 B +!43 U+0043 C +!44 U+0044 D +!45 U+0045 E +!46 U+0046 F +!47 U+0047 G +!48 U+0048 H +!49 U+0049 I +!4A U+004A J +!4B U+004B K +!4C U+004C L +!4D U+004D M +!4E U+004E N +!4F U+004F O +!50 U+0050 P +!51 U+0051 Q +!52 U+0052 R +!53 U+0053 S +!54 U+0054 T +!55 U+0055 U +!56 U+0056 V +!57 U+0057 W +!58 U+0058 X +!59 U+0059 Y +!5A U+005A Z +!5B U+005B bracketleft +!5C U+005C backslash +!5D U+005D bracketright +!5E U+005E asciicircum +!5F U+005F underscore +!60 U+0060 grave +!61 U+0061 a +!62 U+0062 b +!63 U+0063 c +!64 U+0064 d +!65 U+0065 e +!66 U+0066 f +!67 U+0067 g +!68 U+0068 h +!69 U+0069 i +!6A U+006A j +!6B U+006B k +!6C U+006C l +!6D U+006D m +!6E U+006E n +!6F U+006F o +!70 U+0070 p +!71 U+0071 q +!72 U+0072 r +!73 U+0073 s +!74 U+0074 t +!75 U+0075 u +!76 U+0076 v +!77 U+0077 w +!78 U+0078 x +!79 U+0079 y +!7A U+007A z +!7B U+007B braceleft +!7C U+007C bar +!7D U+007D braceright +!7E U+007E asciitilde +!7F U+007F .notdef +!80 U+20AC Euro +!82 U+201A quotesinglbase +!83 U+0192 florin +!84 U+201E quotedblbase +!85 U+2026 ellipsis +!86 U+2020 dagger +!87 U+2021 daggerdbl +!88 U+02C6 circumflex +!89 U+2030 perthousand +!8A U+0160 Scaron +!8B U+2039 guilsinglleft +!8C U+0152 OE +!8E U+017D Zcaron +!91 U+2018 quoteleft +!92 U+2019 quoteright +!93 U+201C quotedblleft +!94 U+201D quotedblright +!95 U+2022 bullet +!96 U+2013 endash +!97 U+2014 emdash +!98 U+02DC tilde +!99 U+2122 trademark +!9A U+0161 scaron +!9B U+203A guilsinglright +!9C U+0153 oe +!9E U+017E zcaron +!9F U+0178 Ydieresis +!A0 U+00A0 space +!A1 U+00A1 exclamdown +!A2 U+00A2 cent +!A3 U+00A3 sterling +!A4 U+00A4 currency +!A5 U+00A5 yen +!A6 U+00A6 brokenbar +!A7 U+00A7 section +!A8 U+00A8 dieresis +!A9 U+00A9 copyright +!AA U+00AA ordfeminine +!AB U+00AB guillemotleft +!AC U+00AC logicalnot +!AD U+00AD hyphen +!AE U+00AE registered +!AF U+00AF macron +!B0 U+00B0 degree +!B1 U+00B1 plusminus +!B2 U+00B2 twosuperior +!B3 U+00B3 threesuperior +!B4 U+00B4 acute +!B5 U+00B5 mu +!B6 U+00B6 paragraph +!B7 U+00B7 periodcentered +!B8 U+00B8 cedilla +!B9 U+00B9 onesuperior +!BA U+00BA ordmasculine +!BB U+00BB guillemotright +!BC U+00BC onequarter +!BD U+00BD onehalf +!BE U+00BE threequarters +!BF U+00BF questiondown +!C0 U+00C0 Agrave +!C1 U+00C1 Aacute +!C2 U+00C2 Acircumflex +!C3 U+00C3 Atilde +!C4 U+00C4 Adieresis +!C5 U+00C5 Aring +!C6 U+00C6 AE +!C7 U+00C7 Ccedilla +!C8 U+00C8 Egrave +!C9 U+00C9 Eacute +!CA U+00CA Ecircumflex +!CB U+00CB Edieresis +!CC U+00CC Igrave +!CD U+00CD Iacute +!CE U+00CE Icircumflex +!CF U+00CF Idieresis +!D0 U+00D0 Eth +!D1 U+00D1 Ntilde +!D2 U+00D2 Ograve +!D3 U+00D3 Oacute +!D4 U+00D4 Ocircumflex +!D5 U+00D5 Otilde +!D6 U+00D6 Odieresis +!D7 U+00D7 multiply +!D8 U+00D8 Oslash +!D9 U+00D9 Ugrave +!DA U+00DA Uacute +!DB U+00DB Ucircumflex +!DC U+00DC Udieresis +!DD U+00DD Yacute +!DE U+00DE Thorn +!DF U+00DF germandbls +!E0 U+00E0 agrave +!E1 U+00E1 aacute +!E2 U+00E2 acircumflex +!E3 U+00E3 atilde +!E4 U+00E4 adieresis +!E5 U+00E5 aring +!E6 U+00E6 ae +!E7 U+00E7 ccedilla +!E8 U+00E8 egrave +!E9 U+00E9 eacute +!EA U+00EA ecircumflex +!EB U+00EB edieresis +!EC U+00EC igrave +!ED U+00ED iacute +!EE U+00EE icircumflex +!EF U+00EF idieresis +!F0 U+00F0 eth +!F1 U+00F1 ntilde +!F2 U+00F2 ograve +!F3 U+00F3 oacute +!F4 U+00F4 ocircumflex +!F5 U+00F5 otilde +!F6 U+00F6 odieresis +!F7 U+00F7 divide +!F8 U+00F8 oslash +!F9 U+00F9 ugrave +!FA U+00FA uacute +!FB U+00FB ucircumflex +!FC U+00FC udieresis +!FD U+00FD yacute +!FE U+00FE thorn +!FF U+00FF ydieresis diff --git a/internal/dossier/content.go b/internal/dossier/content.go new file mode 100644 index 0000000..8ab9cb2 --- /dev/null +++ b/internal/dossier/content.go @@ -0,0 +1,155 @@ +// Package dossier erzeugt das Nachweis-Dossier (PDF) aus Submission, +// Findings, Verantwortungsmatrix und Beweiskette. Die Aufbereitung des +// Inhalts (BuildContent) ist von der eigentlichen PDF-Zeichnung +// (Render) getrennt, damit die fachliche Logik — was steht wo im +// Dossier, in welcher Form — ohne PDF-Parsing testbar ist. +package dossier + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/netcell-it/deklarix/internal/evidence" + "github.com/netcell-it/deklarix/internal/rules" +) + +// Disclaimer steht auf jedem erzeugten Dossier. Siehe CLAUDE.md, +// Leitplanken: Deklarix ist ein Werkzeug, keine Rechtsberatung. +const Disclaimer = "Dieses Dossier dokumentiert die durchgeführte Prüfung. " + + "Es ist keine Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall." + +// Submission sind die Basisdaten des geprüften Beitrags. +type Submission struct { + Platform string + PostType string + Caption string + CreatedAt time.Time +} + +// Participant ist ein Beteiligter aus der Verantwortungsmatrix. +type Participant struct { + Role string + Name string + Vorgegeben bool + Freigegeben bool +} + +// Data ist die Eingabe für Generate/BuildContent — alles, was ein +// Dossier für einen Beitrag braucht. +type Data struct { + Submission Submission + Facts rules.Facts + Findings []rules.Finding + Participants []Participant + AssetHash []byte + MetadataHash []byte + TimestampToken []byte + GeneratedAt time.Time +} + +// FindingRow ist die dossier-taugliche Aufbereitung eines rules.Finding. +type FindingRow struct { + RuleID string + Version int + Severity string + Title string + Fix string + Sources []string +} + +// ParticipantRow ist die dossier-taugliche Aufbereitung eines Participant. +type ParticipantRow struct { + Role string + Name string + Vorgegeben bool + Freigegeben bool +} + +// Content ist die fertig aufbereitete, PDF-unabhängige Darstellung +// eines Dossiers. +type Content struct { + Title string + Platform string + PostType string + Caption string + + SubmittedAt time.Time + GeneratedAt time.Time + + DisclosurePresent bool + DisclosureWording string + DisclosureBeforeCut bool + + Findings []FindingRow + Participants []ParticipantRow + + AssetHashHex string + MetadataHashHex string + TimestampedAt time.Time + + Disclaimer string +} + +// BuildContent bereitet Data zu Content auf. Fehlt eine Pflichtangabe +// (Plattform, Zeitstempel-Token), wird ein Fehler geliefert statt ein +// Dossier mit stillschweigend leeren Beweisfeldern zu erzeugen. +func BuildContent(data Data) (Content, error) { + if data.Submission.Platform == "" { + return Content{}, fmt.Errorf("dossier: submission.platform fehlt") + } + 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") + } + if len(data.MetadataHash) == 0 { + return Content{}, fmt.Errorf("dossier: metadata hash fehlt") + } + + timestampedAt, err := evidence.TimestampTime(data.TimestampToken) + if err != nil { + return Content{}, fmt.Errorf("dossier: timestamp token: %w", err) + } + + findings := make([]FindingRow, len(data.Findings)) + for i, f := range data.Findings { + findings[i] = FindingRow{ + RuleID: f.RuleID, + Version: f.RuleVersion, + Severity: string(f.Severity), + Title: f.Title, + Fix: f.Fix, + Sources: f.Sources, + } + } + + participants := make([]ParticipantRow, len(data.Participants)) + for i, p := range data.Participants { + participants[i] = ParticipantRow{ + Role: p.Role, + Name: p.Name, + Vorgegeben: p.Vorgegeben, + Freigegeben: p.Freigegeben, + } + } + + return Content{ + Title: "Nachweis-Dossier", + Platform: data.Submission.Platform, + PostType: data.Submission.PostType, + Caption: data.Submission.Caption, + SubmittedAt: data.Submission.CreatedAt, + GeneratedAt: data.GeneratedAt, + DisclosurePresent: data.Facts.DisclosurePresent, + DisclosureWording: data.Facts.DisclosureWording, + DisclosureBeforeCut: data.Facts.DisclosureBeforeCut, + Findings: findings, + Participants: participants, + AssetHashHex: hex.EncodeToString(data.AssetHash), + MetadataHashHex: hex.EncodeToString(data.MetadataHash), + TimestampedAt: timestampedAt, + Disclaimer: Disclaimer, + }, nil +} diff --git a/internal/dossier/content_test.go b/internal/dossier/content_test.go new file mode 100644 index 0000000..e5a144f --- /dev/null +++ b/internal/dossier/content_test.go @@ -0,0 +1,158 @@ +package dossier_test + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "testing" + "time" + + "github.com/digitorus/timestamp" + + "github.com/netcell-it/deklarix/internal/dossier" + "github.com/netcell-it/deklarix/internal/evidence" + "github.com/netcell-it/deklarix/internal/rules" +) + +// fakeTimestampToken erzeugt einen strukturell gültigen, selbstsignierten +// RFC-3161-Token für hash — offline, ohne echte TSA. Damit lassen sich +// BuildContent/Render testen, ohne bei jedem Testlauf eine echte +// Time-Stamp Authority anzufragen (die Echtheit/Vertrauenswürdigkeit +// des Zertifikats spielt für diese Tests keine Rolle, nur dass der Token +// strukturell parsbar ist wie ein echter). +func fakeTimestampToken(t *testing.T, hash []byte, at time.Time) []byte { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + certTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "deklarix-test-tsa"}, + NotBefore: at.Add(-time.Hour), + NotAfter: at.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 { + t.Fatalf("create certificate: %v", err) + } + cert, err := x509.ParseCertificate(certDER) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + + ts := timestamp.Timestamp{ + HashAlgorithm: crypto.SHA256, + HashedMessage: hash, + Time: at, + SerialNumber: big.NewInt(1), + Policy: asn1.ObjectIdentifier{1, 2, 3}, + } + respDER, err := ts.CreateResponse(cert, key) + if err != nil { + t.Fatalf("create timestamp response: %v", err) + } + + parsed, err := timestamp.ParseResponse(respDER) + if err != nil { + t.Fatalf("parse fake timestamp response: %v", err) + } + return parsed.RawToken +} + +func validData(t *testing.T) dossier.Data { + t.Helper() + hash := evidence.HashBytes([]byte("caption+bild")) + at := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + + return dossier.Data{ + Submission: dossier.Submission{ + Platform: "instagram", + PostType: "reel", + Caption: "Werbung für ein Produkt äöüß", + CreatedAt: at, + }, + Facts: rules.Facts{ + DisclosurePresent: true, + DisclosureWording: "Werbung", + DisclosureBeforeCut: false, + }, + Findings: []rules.Finding{ + {RuleID: "WK-004", RuleVersion: 1, Severity: rules.SeverityHigh, Title: "t", Fix: "f", Sources: []string{"§ 5a Abs. 4 UWG"}}, + }, + Participants: []dossier.Participant{ + {Role: "creator", Name: "Max Mustermann", Vorgegeben: false, Freigegeben: true}, + }, + AssetHash: hash, + MetadataHash: evidence.HashBytes([]byte("metadata")), + TimestampToken: fakeTimestampToken(t, hash, at), + GeneratedAt: at, + } +} + +func TestBuildContentRejectsMissingPlatform(t *testing.T) { + data := validData(t) + data.Submission.Platform = "" + if _, err := dossier.BuildContent(data); err == nil { + t.Fatal("expected error for missing platform, got nil") + } +} + +func TestBuildContentRejectsMissingTimestampToken(t *testing.T) { + data := validData(t) + data.TimestampToken = nil + if _, err := dossier.BuildContent(data); err == nil { + t.Fatal("expected error for missing timestamp token, got nil") + } +} + +func TestBuildContentRejectsMissingHashes(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 TestBuildContentSuccess(t *testing.T) { + data := validData(t) + + content, err := dossier.BuildContent(data) + if err != nil { + t.Fatalf("BuildContent: %v", err) + } + + if content.Platform != "instagram" { + t.Errorf("Platform = %q, want instagram", content.Platform) + } + if len(content.Findings) != 1 || content.Findings[0].RuleID != "WK-004" { + t.Errorf("Findings = %+v, want one WK-004 row", content.Findings) + } + if len(content.Participants) != 1 || content.Participants[0].Name != "Max Mustermann" { + t.Errorf("Participants = %+v, want one Max-Mustermann row", content.Participants) + } + if len(content.AssetHashHex) != 64 { + t.Errorf("AssetHashHex length = %d, want 64 (hex of 32-byte SHA-256)", len(content.AssetHashHex)) + } + if !content.TimestampedAt.Equal(data.Submission.CreatedAt) { + t.Errorf("TimestampedAt = %v, want %v", content.TimestampedAt, data.Submission.CreatedAt) + } + if content.Disclaimer == "" { + t.Error("expected a non-empty disclaimer") + } +} diff --git a/internal/dossier/render.go b/internal/dossier/render.go new file mode 100644 index 0000000..cc9fc5d --- /dev/null +++ b/internal/dossier/render.go @@ -0,0 +1,125 @@ +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") + line("Asset-Hash (SHA-256): %s", c.AssetHashHex) + 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)) +} diff --git a/internal/dossier/render_test.go b/internal/dossier/render_test.go new file mode 100644 index 0000000..8f16c3d --- /dev/null +++ b/internal/dossier/render_test.go @@ -0,0 +1,51 @@ +package dossier_test + +import ( + "bytes" + "testing" + + "github.com/netcell-it/deklarix/internal/dossier" +) + +func TestRenderProducesValidPDF(t *testing.T) { + content, err := dossier.BuildContent(validData(t)) + if err != nil { + t.Fatalf("BuildContent: %v", err) + } + + var buf bytes.Buffer + if err := dossier.Render(&buf, content); err != nil { + t.Fatalf("Render: %v", err) + } + + out := buf.Bytes() + if !bytes.HasPrefix(out, []byte("%PDF-")) { + t.Fatalf("output does not start with %%PDF- header: %q", out[:min(20, len(out))]) + } + if !bytes.Contains(out, []byte("%%EOF")) { + t.Fatal("output does not contain the expected PDF EOF trailer") + } + if len(out) < 500 { + t.Fatalf("output suspiciously small (%d bytes) for a multi-section dossier", len(out)) + } +} + +func TestGenerateProducesValidPDF(t *testing.T) { + var buf bytes.Buffer + if err := dossier.Generate(&buf, validData(t)); err != nil { + t.Fatalf("Generate: %v", err) + } + if !bytes.HasPrefix(buf.Bytes(), []byte("%PDF-")) { + t.Fatal("Generate output does not start with the expected PDF header") + } +} + +func TestGeneratePropagatesBuildContentErrors(t *testing.T) { + data := validData(t) + data.Submission.Platform = "" + + var buf bytes.Buffer + if err := dossier.Generate(&buf, data); err == nil { + t.Fatal("expected Generate to propagate a BuildContent error, got nil") + } +} diff --git a/internal/evidence/parse.go b/internal/evidence/parse.go new file mode 100644 index 0000000..10fe542 --- /dev/null +++ b/internal/evidence/parse.go @@ -0,0 +1,18 @@ +package evidence + +import ( + "fmt" + "time" + + "github.com/digitorus/timestamp" +) + +// TimestampTime liefert den im RFC-3161-Token bescheinigten Zeitpunkt, +// z. B. für die Anzeige im Nachweis-Dossier. +func TimestampTime(token []byte) (time.Time, error) { + ts, err := timestamp.Parse(token) + if err != nil { + return time.Time{}, fmt.Errorf("evidence: timestamp token parse: %w", err) + } + return ts.Time, nil +} diff --git a/internal/evidence/parse_test.go b/internal/evidence/parse_test.go new file mode 100644 index 0000000..8d66c4d --- /dev/null +++ b/internal/evidence/parse_test.go @@ -0,0 +1,13 @@ +package evidence_test + +import ( + "testing" + + "github.com/netcell-it/deklarix/internal/evidence" +) + +func TestTimestampTimeRejectsGarbage(t *testing.T) { + if _, err := evidence.TimestampTime([]byte("not a timestamp token")); err == nil { + t.Fatal("expected error for garbage token bytes, got nil") + } +} diff --git a/internal/evidence/tsa_test.go b/internal/evidence/tsa_test.go index 2b2433c..435d656 100644 --- a/internal/evidence/tsa_test.go +++ b/internal/evidence/tsa_test.go @@ -131,4 +131,12 @@ func TestTimestampIntegration(t *testing.T) { if parsed.Time.IsZero() { t.Fatal("expected a non-zero timestamp time") } + + tm, err := evidence.TimestampTime(token) + if err != nil { + t.Fatalf("TimestampTime: %v", err) + } + if !tm.Equal(parsed.Time) { + t.Fatalf("TimestampTime() = %v, want %v", tm, parsed.Time) + } }