feat: Standbild-Upload bei der Pre-Publish-Prüfung

CLAUDE.md beschreibt die Prüfung seit dem ersten Commit als "Caption,
Standbild und Vertragslage rein" — bisher wurde nur die Caption
verarbeitet, das asset-Schema aus Migration 0001 blieb ungenutzt.

- internal/store/asset.go: CreateAsset/GetLatestAssetForSubmission.
  Migration 0005 macht asset append-only (Trigger fehlte seit 0001,
  weil bis jetzt nichts hineinschrieb) — ein hochgeladenes Beweisstück
  wird nicht nachträglich ausgetauscht, aus demselben Grund wie bei
  extraction/finding/evidence_package.
- handleCheck liest ein optionales "standbild"-Formularfeld (Bild-
  Upload, max. 8 MiB, Content-Type muss image/* sein), validiert es
  VOR dem Anlegen der Submission (ein ungültiger Upload hinterlässt so
  keine leere Beitrags-Zeile), speichert es danach unter ASSET_DIR und
  legt die Asset-Zeile an.
- handleArchive bindet den Asset-Hash (falls vorhanden) in den
  Metadaten-Hash und ins PDF-Dossier ein (dossier.Data.AssetHash war
  bereits vorbereitet, wurde aber nie befüllt).
- index.html: Formular auf multipart/form-data umgestellt
  (hx-encoding + enctype), neues optionales Dateifeld. handleCheck
  bleibt abwärtskompatibel zu urlencoded-Requests (ParseMultipartForm
  liefert ErrNotMultipart, das wird wie "kein Bild hochgeladen"
  behandelt, nicht wie ein Fehler).
- ASSET_DIR neue Konfigurationsvariable (Default "assets", wie
  DOSSIER_DIR relativ zu WorkingDirectory=/var/lib/deklarix — kein
  postinst-Healing nötig, anders als bei RULES_DIR, dessen Default
  nicht zum installierten Pfad passt).

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Upload, Hash in DB, Hash im
erzeugten PDF via pdftotext, Ablehnung bei falschem Dateityp).
This commit is contained in:
noroot
2026-08-27 21:45:03 +02:00
parent ae59e745c0
commit 790ab20651
12 changed files with 538 additions and 17 deletions

View File

@@ -167,7 +167,10 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
Account wie verändert); append-only aus demselben Grund wie Account wie verändert); append-only aus demselben Grund wie
`finding`/`extraction`/`evidence_package` `finding`/`extraction`/`evidence_package`
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte - `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
- `asset` — Bild oder Datei, Pfad, SHA-256 - `asset` — hochgeladenes Standbild (optional bei der Prüfung), Pfad,
SHA-256; append-only aus demselben Grund wie `finding`/`extraction`/
`evidence_package` — ein Beweisstück wird nicht nachträglich
ausgetauscht
- `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, - `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert, Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
@@ -178,7 +181,7 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur (`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben) Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
**Append-only.** Kein UPDATE auf `finding`, `extraction`, **Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`,
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit `evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man
Zeilen ändern kann, ist keines mehr. Zeilen ändern kann, ist keines mehr.
@@ -393,7 +396,7 @@ sudo systemctl start deklarix
sudo systemctl status deklarix sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR, # Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, TSA_URL) # DOSSIER_DIR, ASSET_DIR, TSA_URL)
# Logs prüfen # Logs prüfen
journalctl -u deklarix -f journalctl -u deklarix -f

View File

@@ -48,7 +48,12 @@ func main() {
dossierDir = "dossiers" dossierDir = "dossiers"
} }
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir) assetDir := os.Getenv("ASSET_DIR")
if assetDir == "" {
assetDir = "assets"
}
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir)
if err != nil { if err != nil {
log.Fatalf("web server: %v", err) log.Fatalf("web server: %v", err)
} }

65
internal/store/asset.go Normal file
View File

@@ -0,0 +1,65 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Asset ist eine zu einem Beitrag hochgeladene Datei (aktuell: das
// Standbild/der Screenshot). Append-only wie extraction/finding/
// evidence_package — ein hochgeladenes Beweisstück wird nicht
// nachträglich ausgetauscht, siehe Migration.
type Asset struct {
ID string
SubmissionID string
Kind string
Path string
SHA256 string
CreatedAt time.Time
}
// CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte
// SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form,
// in der evidence_package.SHA256 seinen Hash speichert.
func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (Asset, error) {
var a Asset
err := s.Pool.QueryRow(ctx, `
INSERT INTO asset (submission_id, kind, path, sha256)
VALUES ($1, $2, $3, $4)
RETURNING id, submission_id, kind, path, sha256, created_at
`, submissionID, kind, path, sha256Hex).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
)
if err != nil {
return Asset{}, fmt.Errorf("store: create asset: %w", err)
}
return a, nil
}
// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset
// eines Beitrags. Liefert ErrNotFound, wenn kein Asset hochgeladen wurde
// — das ist der Normalfall (ein Standbild ist optional), kein Fehler,
// den Aufrufer wie einen echten Datenbankfehler behandeln sollten.
func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) {
var a Asset
err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, kind, path, sha256, created_at
FROM asset
WHERE submission_id = $1
ORDER BY created_at DESC
LIMIT 1
`, submissionID).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Asset{}, ErrNotFound
}
if err != nil {
return Asset{}, fmt.Errorf("store: get latest asset: %w", err)
}
return a, nil
}

View File

@@ -0,0 +1,99 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAssetCreateAndGetLatest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
if a.SubmissionID != sub.ID || a.Kind != "image" {
t.Fatalf("CreateAsset = %+v, unerwartete Werte", a)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != a.ID || got.SHA256 != "deadbeef" {
t.Fatalf("GetLatestAssetForSubmission = %+v, want %+v", got, a)
}
}
func TestGetLatestAssetForSubmissionNotFoundWhenNoneUploaded(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
_, err = s.GetLatestAssetForSubmission(ctx, sub.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/erstes.jpg", "erstehash"); err != nil {
t.Fatalf("CreateAsset (1): %v", err)
}
second, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/zweites.jpg", "zweitehash")
if err != nil {
t.Fatalf("CreateAsset (2): %v", err)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != second.ID {
t.Fatalf("expected the newest asset, got %+v", got)
}
}
func TestAssetIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/x.jpg", "hash")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE asset SET sha256 = 'geaendert' WHERE id = $1`, a.ID)
if err == nil {
t.Fatal("expected UPDATE on asset to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM asset WHERE id = $1`, a.ID)
if err == nil {
t.Fatal("expected DELETE on asset to be rejected by the append-only trigger")
}
}

View File

@@ -0,0 +1 @@
DROP TRIGGER asset_append_only ON asset;

View File

@@ -0,0 +1,9 @@
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
-- finding/evidence_package — es nachträglich austauschen zu können,
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
-- sind.
CREATE TRIGGER asset_append_only
BEFORE UPDATE OR DELETE ON asset
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -0,0 +1,191 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
// tinyPNG ist das kleinstmögliche gültige PNG (1x1 transparent) — genug,
// um einen echten Datei-Upload zu simulieren, ohne eine Bilddatei aus
// dem Repo laden zu müssen.
var tinyPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
// postCheckWithImage stellt eine echte multipart/form-data-Anfrage wie
// der Browser sie schickt (im Gegensatz zu postForm, das urlencoded
// postet) — checkForm()-Felder plus ein optionales "standbild".
func postCheckWithImage(t *testing.T, s *web.Server, cookie *http.Cookie, imageBytes []byte, contentType string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for key, val := range checkForm() {
if err := mw.WriteField(key, val[0]); err != nil {
t.Fatalf("WriteField(%s): %v", key, err)
}
}
if imageBytes != nil {
part, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="standbild"; filename="screenshot.png"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(imageBytes); err != nil {
t.Fatalf("Write image bytes: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("multipart Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/pruefen", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestCheckWithImageUploadStoresAsset(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var subID string
for id := range fs.submissions {
subID = id
}
if subID == "" {
t.Fatal("expected a submission to have been created")
}
asset, err := fs.GetLatestAssetForSubmission(context.Background(), subID)
if err != nil {
t.Fatalf("expected an asset to be stored, got err: %v", err)
}
if asset.Kind != "image" || asset.SHA256 == "" {
t.Errorf("unexpected asset: %+v", asset)
}
}
func TestCheckWithoutImageStoresNoAsset(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postForm(t, s, cookie, "/pruefen", checkForm())
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var subID string
for id := range fs.submissions {
subID = id
}
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
t.Fatal("expected no asset when none was uploaded")
}
}
func TestCheckRejectsNonImageUpload(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postCheckWithImage(t, s, cookie, []byte("kein bild, nur text"), "text/plain")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a non-image upload, body: %s", resp.Code, resp.Body.String())
}
if len(fs.submissions) != 0 {
t.Error("expected no submission to be created when the upload is rejected")
}
}
func TestCheckRejectsOversizedUpload(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
tooLarge := bytes.Repeat([]byte{0xff}, 9<<20) // 9 MiB > 8 MiB Limit
resp := postCheckWithImage(t, s, cookie, tooLarge, "image/png")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for an oversized upload, body: %s", resp.Code, resp.Body.String())
}
}
// TestArchiveMetadataHashDiffersWhenAssetPresent prüft schwarz-verpackt
// (ohne PDF-Interna zu kennen — der Dossier-Content wird komprimiert,
// ein hex-Hash taucht daher nicht als durchsuchbarer String in den
// PDF-Rohbytes auf, siehe internal/dossier/content_test.go für die
// Prüfung auf Ebene der PDF-Inhaltsstruktur), dass ein hochgeladenes
// Standbild tatsächlich in den archivierten Metadaten-Hash einfließt:
// zwei sonst identische Beiträge, einer mit, einer ohne Bild, müssen
// unterschiedliche evidence_package.SHA256 ergeben.
func TestArchiveMetadataHashDiffersWhenAssetPresent(t *testing.T) {
fakeEx := fakeExtractor{
facts: rules.Facts{Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone},
raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`),
}
s, fs, cookie := newAuthedTestServer(t, fakeEx)
withImageResp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
if withImageResp.Code != http.StatusOK {
t.Fatalf("check (mit Bild) status = %d, body: %s", withImageResp.Code, withImageResp.Body.String())
}
var withImageSubID string
for id := range fs.submissions {
withImageSubID = id
}
archiveWithImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withImageSubID}})
if archiveWithImage.Code != http.StatusOK {
t.Fatalf("archive (mit Bild) status = %d, body: %s", archiveWithImage.Code, archiveWithImage.Body.String())
}
pkgWithImage, err := fs.GetLatestEvidencePackage(context.Background(), withImageSubID)
if err != nil {
t.Fatalf("GetLatestEvidencePackage (mit Bild): %v", err)
}
withoutImageResp := postForm(t, s, cookie, "/pruefen", checkForm())
if withoutImageResp.Code != http.StatusOK {
t.Fatalf("check (ohne Bild) status = %d, body: %s", withoutImageResp.Code, withoutImageResp.Body.String())
}
var withoutImageSubID string
for id := range fs.submissions {
if id != withImageSubID {
withoutImageSubID = id
}
}
archiveWithoutImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withoutImageSubID}})
if archiveWithoutImage.Code != http.StatusOK {
t.Fatalf("archive (ohne Bild) status = %d, body: %s", archiveWithoutImage.Code, archiveWithoutImage.Body.String())
}
pkgWithoutImage, err := fs.GetLatestEvidencePackage(context.Background(), withoutImageSubID)
if err != nil {
t.Fatalf("GetLatestEvidencePackage (ohne Bild): %v", err)
}
if pkgWithImage.SHA256 == pkgWithoutImage.SHA256 {
t.Fatal("expected different metadata hashes for an archived submission with vs. without an uploaded asset")
}
}

View File

@@ -1,19 +1,90 @@
package web package web
import ( import (
"context"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/netcell-it/deklarix/internal/dossier" "github.com/netcell-it/deklarix/internal/dossier"
"github.com/netcell-it/deklarix/internal/evidence" "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"
) )
// maxAssetSize begrenzt ein hochgeladenes Standbild auf 8 MiB — genug für
// einen Screenshot, nicht genug, um den Server mit Uploads zu fluten.
const maxAssetSize = 8 << 20
// uploadedAsset ist ein bereits gelesenes und geprüftes Standbild, das
// nach dem Anlegen der Submission (die submission_id als Fremdschlüssel
// braucht) tatsächlich gespeichert wird. Getrennt von storeAsset, damit
// ein ungültiger Upload (falscher Typ, zu groß) *vor* dem Anlegen der
// Submission scheitert, statt eine Beitrags-Zeile ohne Asset zu hinterlassen.
type uploadedAsset struct {
data []byte
extension string
sha256Hex string
}
// readUploadedAsset liest das optionale "standbild"-Feld. Liefert
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
file, header, err := r.FormFile("standbild")
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile.
if errors.Is(err, http.ErrMissingFile) || errors.Is(err, http.ErrNotMultipart) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err)
}
defer file.Close()
if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") {
return nil, fmt.Errorf("nur Bilddateien sind als Standbild erlaubt")
}
data, err := io.ReadAll(io.LimitReader(file, maxAssetSize+1))
if err != nil {
return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err)
}
if len(data) > maxAssetSize {
return nil, fmt.Errorf("Standbild ist zu groß (max. %d MB)", maxAssetSize/(1<<20))
}
ext := filepath.Ext(header.Filename)
if ext == "" {
ext = ".bin"
}
return &uploadedAsset{data: data, extension: ext, sha256Hex: hex.EncodeToString(evidence.HashBytes(data))}, nil
}
// storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und
// speichert die Asset-Zeile.
func (s *Server) storeAsset(ctx context.Context, submissionID string, ua *uploadedAsset) error {
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
}
path := filepath.Join(s.assetDir, submissionID+ua.extension)
if err := os.WriteFile(path, ua.data, 0o640); err != nil {
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err)
}
if _, err := s.store.CreateAsset(ctx, submissionID, "image", path, ua.sha256Hex); err != nil {
return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
}
return nil
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`) fmt.Fprint(w, `{"ok":true}`)
@@ -54,8 +125,14 @@ type resultData struct {
// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im // veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im
// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2). // 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 { r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
http.Error(w, "ungültiges Formular", http.StatusBadRequest) // ErrNotMultipart ist kein Fehlerfall: ParseMultipartForm ruft intern
// zuerst ParseForm auf, das Formularfelder auch aus einem klassischen
// urlencoded-Body liest (kein Standbild dabei, aber alle anderen
// Felder sind trotzdem gültig) — nur ein wirklich kaputter oder zu
// großer Body soll hier abbrechen.
if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
return return
} }
@@ -68,6 +145,12 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
asset, err := s.readUploadedAsset(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context() ctx := r.Context()
accountID := currentUser(r).AccountID accountID := currentUser(r).AccountID
@@ -88,6 +171,13 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
if asset != nil {
if err := s.storeAsset(ctx, sub.ID, asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion) ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion)
if err != nil { if err != nil {
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
@@ -182,14 +272,33 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
} }
} }
var assetHash []byte
var assetSHA256Hex string
switch asset, assetErr := s.store.GetLatestAssetForSubmission(ctx, submissionID); {
case assetErr == nil:
assetSHA256Hex = asset.SHA256
assetHash, err = hex.DecodeString(asset.SHA256)
if err != nil {
http.Error(w, "gespeicherter Asset-Hash konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
case errors.Is(assetErr, store.ErrNotFound):
// Kein Standbild hochgeladen — das ist erlaubt, siehe CLAUDE.md
// (Standbild ist kein Pflichtfeld der Prüfung).
default:
http.Error(w, "Asset konnte nicht geladen werden: "+assetErr.Error(), http.StatusInternalServerError)
return
}
metadataHash, err := evidence.HashMetadata(struct { metadataHash, err := evidence.HashMetadata(struct {
SubmissionID string SubmissionID string
Platform string Platform string
PostType string PostType string
Caption string Caption string
Facts rules.Facts Facts rules.Facts
Findings []rules.Finding Findings []rules.Finding
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings}) AssetSHA256Hex string
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings, assetSHA256Hex})
if err != nil { if err != nil {
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
@@ -219,6 +328,7 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
}, },
Facts: facts, Facts: facts,
Findings: dossierFindings, Findings: dossierFindings,
AssetHash: assetHash,
MetadataHash: metadataHash, MetadataHash: metadataHash,
TimestampToken: timestampToken, TimestampToken: timestampToken,
GeneratedAt: time.Now(), GeneratedAt: time.Now(),

View File

@@ -70,6 +70,8 @@ type Store interface {
DeleteSession(ctx context.Context, token string) error DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
} }
// Server bündelt Routing und Abhängigkeiten der Web-Schicht. // Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -80,13 +82,15 @@ type Server struct {
store Store store Store
timestamper evidence.Timestamper timestamper evidence.Timestamper
dossierDir string dossierDir string
assetDir 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. dossierDir ist das // einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden. // Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) { // assetDir das Verzeichnis für hochgeladene Standbilder.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir 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)
@@ -98,6 +102,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
store: st, store: st,
timestamper: timestamper, timestamper: timestamper,
dossierDir: dossierDir, dossierDir: dossierDir,
assetDir: assetDir,
templates: tmpl, templates: tmpl,
} }

View File

@@ -68,6 +68,7 @@ type fakeStore struct {
evidencePkgs map[string]store.EvidencePackage evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant participants map[string]store.Participant
auditLog []store.AuditEntry auditLog []store.AuditEntry
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset
} }
func newFakeStore() *fakeStore { func newFakeStore() *fakeStore {
@@ -81,6 +82,7 @@ func newFakeStore() *fakeStore {
findings: map[string][]store.Finding{}, findings: map[string][]store.Finding{},
evidencePkgs: map[string]store.EvidencePackage{}, evidencePkgs: map[string]store.EvidencePackage{},
participants: map[string]store.Participant{}, participants: map[string]store.Participant{},
assets: map[string]store.Asset{},
} }
} }
@@ -342,6 +344,26 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
return pkg, nil return pkg, nil
} }
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a := store.Asset{
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
}
f.assets[submissionID] = a
return a, nil
}
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.assets[submissionID]
if !ok {
return store.Asset{}, store.ErrNotFound
}
return a, nil
}
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) { func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -500,7 +522,7 @@ func loadRealRules(t *testing.T) []rules.Rule {
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server { func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
t.Helper() t.Helper()
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir())
if err != nil { if err != nil {
t.Fatalf("NewServer: %v", err) t.Fatalf("NewServer: %v", err)
} }

View File

@@ -7,7 +7,7 @@
<h1>Pre-Publish-Prüfung</h1> <h1>Pre-Publish-Prüfung</h1>
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p> <p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
<form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML"> <form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML" hx-encoding="multipart/form-data" enctype="multipart/form-data">
<label for="platform">Plattform</label> <label for="platform">Plattform</label>
<select id="platform" name="platform" required> <select id="platform" name="platform" required>
<option value="instagram">Instagram</option> <option value="instagram">Instagram</option>
@@ -38,6 +38,13 @@
<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>
<label for="standbild">Standbild (optional)</label>
<input type="file" id="standbild" name="standbild" accept="image/*">
<p class="hinweis">
Screenshot des veröffentlichten Beitrags — wird Teil des
Nachweis-Dossiers, sobald der Beitrag archiviert wird.
</p>
<button type="submit">Prüfen</button> <button type="submit">Prüfen</button>
</form> </form>

View File

@@ -13,6 +13,10 @@ RULES_DIR=/usr/share/deklarix/rules
# Archivieren eines Beitrags automatisch angelegt. # Archivieren eines Beitrags automatisch angelegt.
DOSSIER_DIR=/var/lib/deklarix/dossiers DOSSIER_DIR=/var/lib/deklarix/dossiers
# Wo hochgeladene Standbilder abgelegt werden. Wird bei der
# Pre-Publish-Prüfung automatisch angelegt.
ASSET_DIR=/var/lib/deklarix/assets
# RFC-3161-Zeitstempeldienst. Leer = FreeTSA.org (frei, aber NICHT # RFC-3161-Zeitstempeldienst. Leer = FreeTSA.org (frei, aber NICHT
# eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem # eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen. # Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.