diff --git a/CLAUDE.md b/CLAUDE.md index f983840..ea3c3c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,10 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`, Account wie verändert); append-only aus demselben Grund wie `finding`/`extraction`/`evidence_package` - `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 - `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere, 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 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 Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man Zeilen ändern kann, ist keines mehr. @@ -393,7 +396,7 @@ sudo systemctl start deklarix sudo systemctl status deklarix # Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR, -# DOSSIER_DIR, TSA_URL) +# DOSSIER_DIR, ASSET_DIR, TSA_URL) # Logs prüfen journalctl -u deklarix -f diff --git a/cmd/deklarix/main.go b/cmd/deklarix/main.go index f5e8fd6..80f0126 100644 --- a/cmd/deklarix/main.go +++ b/cmd/deklarix/main.go @@ -48,7 +48,12 @@ func main() { 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 { log.Fatalf("web server: %v", err) } diff --git a/internal/store/asset.go b/internal/store/asset.go new file mode 100644 index 0000000..fa6477b --- /dev/null +++ b/internal/store/asset.go @@ -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 +} diff --git a/internal/store/asset_test.go b/internal/store/asset_test.go new file mode 100644 index 0000000..d1b326e --- /dev/null +++ b/internal/store/asset_test.go @@ -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") + } +} diff --git a/internal/store/migrations/0005_asset_append_only.down.sql b/internal/store/migrations/0005_asset_append_only.down.sql new file mode 100644 index 0000000..5b0ff95 --- /dev/null +++ b/internal/store/migrations/0005_asset_append_only.down.sql @@ -0,0 +1 @@ +DROP TRIGGER asset_append_only ON asset; diff --git a/internal/store/migrations/0005_asset_append_only.up.sql b/internal/store/migrations/0005_asset_append_only.up.sql new file mode 100644 index 0000000..c583363 --- /dev/null +++ b/internal/store/migrations/0005_asset_append_only.up.sql @@ -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(); diff --git a/internal/web/asset_handlers_test.go b/internal/web/asset_handlers_test.go new file mode 100644 index 0000000..e0279cb --- /dev/null +++ b/internal/web/asset_handlers_test.go @@ -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") + } +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 5da9f7b..3a010e6 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1,19 +1,90 @@ package web import ( + "context" "encoding/hex" + "errors" "fmt" + "io" "net/http" "os" "path/filepath" + "strings" "time" "github.com/netcell-it/deklarix/internal/dossier" "github.com/netcell-it/deklarix/internal/evidence" "github.com/netcell-it/deklarix/internal/extract" "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) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"ok":true}`) @@ -54,8 +125,14 @@ type resultData struct { // veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im // Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2). func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "ungültiges Formular", http.StatusBadRequest) + r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20)) + // 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 } @@ -68,6 +145,12 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { return } + asset, err := s.readUploadedAsset(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + ctx := r.Context() accountID := currentUser(r).AccountID @@ -88,6 +171,13 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { 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) if err != nil { 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 { - SubmissionID string - Platform string - PostType string - Caption string - Facts rules.Facts - Findings []rules.Finding - }{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings}) + SubmissionID string + Platform string + PostType string + Caption string + Facts rules.Facts + Findings []rules.Finding + AssetSHA256Hex string + }{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings, assetSHA256Hex}) if err != nil { http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return @@ -219,6 +328,7 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) { }, Facts: facts, Findings: dossierFindings, + AssetHash: assetHash, MetadataHash: metadataHash, TimestampToken: timestampToken, GeneratedAt: time.Now(), diff --git a/internal/web/server.go b/internal/web/server.go index c886754..6bd9cdc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -70,6 +70,8 @@ type Store interface { DeleteSession(ctx context.Context, token string) error CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (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. @@ -80,13 +82,15 @@ type Server struct { store Store timestamper evidence.Timestamper dossierDir string + assetDir string templates *template.Template } // NewServer erstellt den Server. ruleSet kommt von rules.Load und wird // einmal beim Start geladen, nicht pro Request. dossierDir ist das -// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden. -func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) { +// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden; +// 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") if err != nil { 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, timestamper: timestamper, dossierDir: dossierDir, + assetDir: assetDir, templates: tmpl, } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index ff97dc4..dad17ac 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -68,6 +68,7 @@ type fakeStore struct { evidencePkgs map[string]store.EvidencePackage participants map[string]store.Participant auditLog []store.AuditEntry + assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset } func newFakeStore() *fakeStore { @@ -81,6 +82,7 @@ func newFakeStore() *fakeStore { findings: map[string][]store.Finding{}, evidencePkgs: map[string]store.EvidencePackage{}, 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 } +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) { f.mu.Lock() 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 { 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 { t.Fatalf("NewServer: %v", err) } diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index d5d4ca7..6e154d1 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -7,7 +7,7 @@
Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.
- diff --git a/packaging/etc/deklarix/deklarix.env.example b/packaging/etc/deklarix/deklarix.env.example index 793109b..e8a9e13 100644 --- a/packaging/etc/deklarix/deklarix.env.example +++ b/packaging/etc/deklarix/deklarix.env.example @@ -13,6 +13,10 @@ RULES_DIR=/usr/share/deklarix/rules # Archivieren eines Beitrags automatisch angelegt. 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 # eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem # Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.