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 (
|
require (
|
||||||
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea
|
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/golang-migrate/migrate/v4 v4.19.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
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() {
|
if parsed.Time.IsZero() {
|
||||||
t.Fatal("expected a non-zero timestamp time")
|
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