Registration creates a new account plus its first user; login authenticates an existing one; both set a deklarix_session cookie (HttpOnly, SameSite=Strict, Secure only when the request itself came over TLS — hardcoding Secure=true would break local http://localhost development, since browsers won't store a Secure cookie over plaintext). requirePage protects full-page GETs (redirects to /login); requireAPI protects the htmx/download endpoints (401, since those are only ever called from an already-authenticated page — an unauthenticated hit there is the exception, e.g. a session expiring mid-use). handleCheck now creates submissions under the current account. handleArchive and handleDossierDownload compare the submission's account against the caller's and return 404 on mismatch — not 403, which would confirm the ID exists to a different tenant. Login failure uses the same message for "no such email" and "wrong password" to avoid account enumeration. Restructured templates along the way: layout.html now only holds reusable fragments ("head", "nav"); each full page (index/login/register) is its own top-level named template. The previous layout+content nesting would have broken the moment a second page defined "content" — Go's html/template keys blocks by name across the whole parsed set, not per file, so two pages both defining "content" would silently overwrite each other. Verified against a real running instance (not just Go's test recorder): started the compiled binary against a fresh Postgres and drove the whole flow with curl — anonymous redirect, registration setting a real cookie, authenticated page load, logout clearing both the cookie and the server-side session row, and being locked out again afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
8.9 KiB
Go
273 lines
8.9 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.templates.ExecuteTemplate(w, "index", indexData{Title: "Pre-Publish-Prüfung"}); 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) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
platform := r.FormValue("platform")
|
|
postType := r.FormValue("post_type")
|
|
caption := r.FormValue("caption")
|
|
if platform == "" || postType == "" || caption == "" {
|
|
http.Error(w, "Plattform, Beitragstyp und Caption sind Pflichtfelder", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
accountID := currentUser(r).AccountID
|
|
|
|
result, err := s.extractor.Extract(ctx, extract.Input{
|
|
Platform: platform,
|
|
Jurisdiction: "DE",
|
|
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
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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})
|
|
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,
|
|
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)
|
|
}
|