feat: add PDF dossier generation (internal/dossier)
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 <noreply@anthropic.com>
This commit is contained in:
1
go.mod
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
251
internal/dossier/assets/cp1252.map
Normal file
251
internal/dossier/assets/cp1252.map
Normal file
@@ -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
|
||||
155
internal/dossier/content.go
Normal file
155
internal/dossier/content.go
Normal file
@@ -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
|
||||
}
|
||||
158
internal/dossier/content_test.go
Normal file
158
internal/dossier/content_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
125
internal/dossier/render.go
Normal file
125
internal/dossier/render.go
Normal file
@@ -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))
|
||||
}
|
||||
51
internal/dossier/render_test.go
Normal file
51
internal/dossier/render_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
18
internal/evidence/parse.go
Normal file
18
internal/evidence/parse.go
Normal file
@@ -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
|
||||
}
|
||||
13
internal/evidence/parse_test.go
Normal file
13
internal/evidence/parse_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user