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:
65
internal/store/asset.go
Normal file
65
internal/store/asset.go
Normal 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
|
||||
}
|
||||
99
internal/store/asset_test.go
Normal file
99
internal/store/asset_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TRIGGER asset_append_only ON asset;
|
||||
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal 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();
|
||||
191
internal/web/asset_handlers_test.go
Normal file
191
internal/web/asset_handlers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h1>Pre-Publish-Prüfung</h1>
|
||||
<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>
|
||||
<select id="platform" name="platform" required>
|
||||
<option value="instagram">Instagram</option>
|
||||
@@ -38,6 +38,13 @@
|
||||
<label for="caption">Caption</label>
|
||||
<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>
|
||||
</form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user