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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user