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) }