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).
387 lines
13 KiB
Go
387 lines
13 KiB
Go
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}`)
|
|
}
|
|
|
|
type indexData struct {
|
|
Title string
|
|
Nav navData
|
|
}
|
|
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
data := indexData{Title: "Pre-Publish-Prüfung", Nav: navFor(r)}
|
|
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
|
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type findingView struct {
|
|
RuleID string
|
|
Version int
|
|
Severity string
|
|
Title string
|
|
Fix string
|
|
Sources []string
|
|
}
|
|
|
|
type resultData struct {
|
|
SubmissionID string
|
|
NeedsClarification bool
|
|
CanArchive bool
|
|
Findings []findingView
|
|
}
|
|
|
|
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1),
|
|
// Regelauswertung (Stufe 2), und speichert Submission + Extraction +
|
|
// Findings. Die eigentliche Archivierung (Hash, Zeitstempel, Dossier)
|
|
// passiert erst in handleArchive, wenn der Beitrag tatsächlich
|
|
// 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) {
|
|
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
|
|
}
|
|
|
|
platform := r.FormValue("platform")
|
|
postType := r.FormValue("post_type")
|
|
caption := r.FormValue("caption")
|
|
consideration := r.FormValue("consideration")
|
|
if platform == "" || postType == "" || caption == "" || consideration == "" {
|
|
http.Error(w, "Plattform, Beitragstyp, Gegenleistung und Caption sind Pflichtfelder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
asset, err := s.readUploadedAsset(r)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
accountID := currentUser(r).AccountID
|
|
|
|
result, err := s.extractor.Extract(ctx, extract.Input{
|
|
Platform: platform,
|
|
Jurisdiction: "DE",
|
|
Consideration: consideration,
|
|
Caption: caption,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
sub, err := s.store.CreateSubmission(ctx, accountID, platform, postType, caption)
|
|
if err != nil {
|
|
http.Error(w, "Beitrag konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
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)
|
|
return
|
|
}
|
|
|
|
findings, needsClarification := rules.Evaluate(s.ruleSet, result.Facts)
|
|
|
|
data := resultData{SubmissionID: sub.ID, NeedsClarification: needsClarification}
|
|
if !needsClarification {
|
|
extractionID := ext.ID
|
|
for _, f := range findings {
|
|
if _, err := s.store.CreateFinding(ctx, sub.ID, &extractionID, f.RuleID, f.RuleVersion, string(f.Severity), f.Title, f.Fix, f.Sources); err != nil {
|
|
http.Error(w, "Finding konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data.Findings = append(data.Findings, findingView{
|
|
RuleID: f.RuleID, Version: f.RuleVersion, Severity: string(f.Severity),
|
|
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
|
})
|
|
}
|
|
if err := s.store.SetSubmissionStatus(ctx, sub.ID, "checked"); err != nil {
|
|
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data.CanArchive = true
|
|
}
|
|
|
|
if err := s.templates.ExecuteTemplate(w, "result", data); err != nil {
|
|
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
type archivedData struct {
|
|
SubmissionID string
|
|
DossierURL string
|
|
TimestampedAt string
|
|
}
|
|
|
|
// handleArchive markiert einen geprüften Beitrag als veröffentlicht und
|
|
// archiviert ihn: Hash der Metadaten, RFC-3161-Zeitstempel, PDF-Dossier.
|
|
// Setzt eine vorherige Prüfung voraus (Submission + Extraction +
|
|
// Findings müssen bereits existieren).
|
|
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
submissionID := r.FormValue("submission_id")
|
|
if submissionID == "" {
|
|
http.Error(w, "submission_id ist Pflicht", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
sub, err := s.store.GetSubmission(ctx, submissionID)
|
|
if err != nil {
|
|
http.Error(w, "Beitrag nicht gefunden: "+err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
// Mandantentrennung: ein Beitrag eines anderen Accounts wird wie ein
|
|
// nicht existierender behandelt, nicht mit einer 403 bestätigt —
|
|
// eine 403 würde einem anderen Mandanten verraten, dass die ID
|
|
// existiert.
|
|
if sub.AccountID != currentUser(r).AccountID {
|
|
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
ext, err := s.store.GetLatestExtraction(ctx, submissionID)
|
|
if err != nil {
|
|
http.Error(w, "Extraktion nicht gefunden: "+err.Error(), http.StatusNotFound)
|
|
return
|
|
}
|
|
facts, err := extract.ParsePayload(ext.Payload, sub.Platform, "DE")
|
|
if err != nil {
|
|
http.Error(w, "gespeicherte Extraktion konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
storeFindings, err := s.store.ListCurrentFindings(ctx, submissionID)
|
|
if err != nil {
|
|
http.Error(w, "Findings konnten nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
dossierFindings := make([]rules.Finding, len(storeFindings))
|
|
for i, f := range storeFindings {
|
|
dossierFindings[i] = rules.Finding{
|
|
RuleID: f.RuleID, RuleVersion: f.RuleVersion, Severity: rules.Severity(f.Severity),
|
|
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
|
|
}
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
timestampToken, err := s.timestamper.Timestamp(ctx, metadataHash)
|
|
if err != nil {
|
|
http.Error(w, "Zeitstempel fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
if err := os.MkdirAll(s.dossierDir, 0o750); err != nil {
|
|
http.Error(w, "Dossier-Verzeichnis konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
dossierPath := filepath.Join(s.dossierDir, submissionID+".pdf")
|
|
dossierFile, err := os.Create(dossierPath)
|
|
if err != nil {
|
|
http.Error(w, "Dossier-Datei konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer dossierFile.Close()
|
|
|
|
genErr := dossier.Generate(dossierFile, dossier.Data{
|
|
Submission: dossier.Submission{
|
|
Platform: sub.Platform, PostType: sub.PostType, Caption: sub.Caption, CreatedAt: sub.CreatedAt,
|
|
},
|
|
Facts: facts,
|
|
Findings: dossierFindings,
|
|
AssetHash: assetHash,
|
|
MetadataHash: metadataHash,
|
|
TimestampToken: timestampToken,
|
|
GeneratedAt: time.Now(),
|
|
})
|
|
if genErr != nil {
|
|
http.Error(w, "Dossier konnte nicht erzeugt werden: "+genErr.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if _, err := s.store.CreateEvidencePackage(ctx, submissionID, dossierPath, hex.EncodeToString(metadataHash), timestampToken); err != nil {
|
|
http.Error(w, "Evidence-Package konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.store.SetSubmissionStatus(ctx, submissionID, "published"); err != nil {
|
|
http.Error(w, "Status konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
timestampedAt, err := evidence.TimestampTime(timestampToken)
|
|
if err != nil {
|
|
http.Error(w, "Zeitstempel konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
data := archivedData{
|
|
SubmissionID: submissionID,
|
|
DossierURL: "/dossier/" + submissionID,
|
|
TimestampedAt: timestampedAt.Format("02.01.2006 15:04:05 MST"),
|
|
}
|
|
if err := s.templates.ExecuteTemplate(w, "archived", data); err != nil {
|
|
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// handleDossierDownload liefert das erzeugte PDF-Dossier eines
|
|
// archivierten Beitrags aus.
|
|
func (s *Server) handleDossierDownload(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
ctx := r.Context()
|
|
|
|
sub, err := s.store.GetSubmission(ctx, id)
|
|
if err != nil || sub.AccountID != currentUser(r).AccountID {
|
|
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
pkg, err := s.store.GetLatestEvidencePackage(ctx, id)
|
|
if err != nil {
|
|
http.Error(w, "Kein Dossier für diesen Beitrag gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/pdf")
|
|
http.ServeFile(w, r, pkg.DossierPath)
|
|
}
|