feat: add web layer (internal/web) and wire it into main.go

Routing, html/template layout/content pattern, and the Pre-Publish
check flow: POST /pruefen runs extraction (Stufe 1) then rules.Evaluate
(Stufe 2) and renders the result as an htmx fragment. Nothing is
persisted yet — that's the next step (wiring internal/store in).

The needsClarification case is rendered explicitly as a request for
more information rather than "no findings", matching the core
principle. Every result carries the legal-advice disclaimer required by
CLAUDE.md's guardrails.

Server depends on a narrow Extractor interface rather than *extract.
Client directly, so tests inject a fake instead of calling the real API
— internal/web's test suite never touches the network. htmx is vendored
locally (internal/web/static/htmx.min.js) instead of loaded from a CDN,
keeping the UI usable without runtime internet access.

cmd/deklarix/main.go now wires all of this together: reads
ANTHROPIC_API_KEY (required) and RULES_DIR (default "rules"), builds
the extract client and loads the rule set, and serves web.Server instead
of the old inline health-only mux.

This exposed the same crash-loop risk fixed earlier for DATABASE_URL:
postinst's start guard only checked DATABASE_URL, so a fresh install
would now crash-loop on a missing ANTHROPIC_API_KEY instead. The guard
checks both. scripts/build.sh also now ships rules/*.yaml into the .deb
under /usr/share/deklarix/rules (not a conffile — rules are updated via
the release pipeline, never hand-edited on a server), and
deklarix.env.example points RULES_DIR there by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-08-27 14:23:45 +02:00
parent db373e51ce
commit 31caa8f66a
12 changed files with 440 additions and 22 deletions

83
internal/web/handlers.go Normal file
View File

@@ -0,0 +1,83 @@
package web
import (
"fmt"
"net/http"
"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, "layout", 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 {
NeedsClarification bool
Findings []findingView
}
// handleCheck führt die Pre-Publish-Prüfung aus: Extraktion (Stufe 1)
// gefolgt von der Regelauswertung (Stufe 2). Speichert noch nichts —
// das ist der nächste Schritt (Verdrahtung über internal/store).
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")
caption := r.FormValue("caption")
if platform == "" || caption == "" {
http.Error(w, "Plattform und Caption sind Pflichtfelder", http.StatusBadRequest)
return
}
facts, err := s.extractor.Extract(r.Context(), extract.Input{
Platform: platform,
Jurisdiction: "DE",
Caption: caption,
})
if err != nil {
http.Error(w, "Extraktion fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
findings, needsClarification := rules.Evaluate(s.ruleSet, facts)
data := resultData{NeedsClarification: needsClarification}
for _, f := range findings {
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.templates.ExecuteTemplate(w, "result", data); err != nil {
http.Error(w, "Ergebnis konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

65
internal/web/server.go Normal file
View File

@@ -0,0 +1,65 @@
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// Stack). Persistenz (Submission/Finding/Evidence speichern) ist noch
// nicht angebunden — das ist der nächste Schritt (Verdrahtung über
// internal/store). Dieser Server führt Pre-Publish-Prüfungen aus
// (extrahieren + bewerten) und zeigt das Ergebnis an, ohne es
// aufzubewahren.
package web
import (
"context"
"embed"
"fmt"
"html/template"
"net/http"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *extract.Client erfüllt sie; Tests injizieren einen Fake statt echte
// Claude-API-Aufrufe zu machen.
type Extractor interface {
Extract(ctx context.Context, in extract.Input) (rules.Facts, error)
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
extractor Extractor
ruleSet []rules.Rule
templates *template.Template
}
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request.
func NewServer(extractor Extractor, ruleSet []rules.Rule) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err)
}
s := &Server{extractor: extractor, ruleSet: ruleSet, templates: tmpl}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /pruefen", s.handleCheck)
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux
return s, nil
}
// ServeHTTP macht Server zu einem http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}

183
internal/web/server_test.go Normal file
View File

@@ -0,0 +1,183 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
type fakeExtractor struct {
facts rules.Facts
err error
}
func (f fakeExtractor) Extract(ctx context.Context, in extract.Input) (rules.Facts, error) {
return f.facts, f.err
}
func loadRealRules(t *testing.T) []rules.Rule {
t.Helper()
rs, err := rules.Load(os.DirFS("../../rules"))
if err != nil {
t.Fatalf("rules.Load: %v", err)
}
return rs
}
func newTestServer(t *testing.T, ex web.Extractor) *web.Server {
t.Helper()
s, err := web.NewServer(ex, loadRealRules(t))
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return s
}
func TestHandleHealth(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.String() != `{"ok":true}` {
t.Fatalf("body = %q, want {\"ok\":true}", w.Body.String())
}
}
func TestHandleIndexRendersForm(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{`name="platform"`, `name="caption"`, `hx-post="/pruefen"`} {
if !strings.Contains(body, want) {
t.Errorf("index body missing %q\nbody: %s", want, body)
}
}
}
func TestHandleStaticServesHTMX(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/static/htmx.min.js", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if w.Body.Len() < 1000 {
t.Fatalf("htmx.min.js suspiciously small: %d bytes", w.Body.Len())
}
}
func postCheck(t *testing.T, s *web.Server, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestHandleCheckRejectsMissingFields(t *testing.T) {
s := newTestServer(t, fakeExtractor{})
w := postCheck(t, s, url.Values{"platform": {"instagram"}})
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for missing caption", w.Code)
}
}
func TestHandleCheckPropagatesExtractionError(t *testing.T) {
s := newTestServer(t, fakeExtractor{err: errTest})
w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"x"}})
if w.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want 502 when extraction fails", w.Code)
}
}
func TestHandleCheckRendersFinding(t *testing.T) {
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram",
Jurisdiction: "DE",
Consideration: rules.ConsiderationPaid,
DisclosurePresent: true,
DisclosureWording: "Werbung",
DisclosureBeforeCut: false,
}})
w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"Werbung, schaut mal..."}})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "WK-004") {
t.Errorf("expected WK-004 finding in result, got: %s", body)
}
}
func TestHandleCheckRendersNeedsClarification(t *testing.T) {
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram",
Jurisdiction: "DE",
Consideration: rules.ConsiderationUnclear,
}})
w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"..."}})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "WK-") {
t.Errorf("expected no rule findings for an unclear extraction, got: %s", body)
}
if !strings.Contains(body, "nicht sicher bestimmt") {
t.Errorf("expected a clarification message, got: %s", body)
}
}
func TestHandleCheckCleanCaseHasNoFindings(t *testing.T) {
s := newTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram",
Jurisdiction: "DE",
Consideration: rules.ConsiderationNone,
}})
w := postCheck(t, s, url.Values{"platform": {"instagram"}, "caption": {"ein ganz normaler Post"}})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "WK-") {
t.Errorf("expected no findings for an organic post, got: %s", body)
}
if !strings.Contains(body, "Keine Kennzeichnungsrisiken") {
t.Errorf("expected the no-findings message, got: %s", body)
}
}
var errTest = extractError("simulierter Extraktionsfehler")
type extractError string
func (e extractError) Error() string { return string(e) }

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
{{define "content"}}
<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">
<label for="platform">Plattform</label>
<select id="platform" name="platform" required>
<option value="instagram">Instagram</option>
<option value="tiktok">TikTok</option>
</select>
<label for="caption">Caption</label>
<textarea id="caption" name="caption" rows="6" required></textarea>
<button type="submit">Prüfen</button>
</form>
<div id="ergebnis"></div>
{{end}}

View File

@@ -0,0 +1,13 @@
{{define "layout"}}<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Deklarix</title>
<script src="/static/htmx.min.js"></script>
</head>
<body>
{{template "content" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,27 @@
{{define "result"}}
{{if .NeedsClarification}}
<p class="rueckfrage">
Die Gegenleistung konnte nicht sicher bestimmt werden. Bitte Angaben
präzisieren (z. B. ob ein Produkt oder eine Zahlung im Zusammenhang mit
dem Beitrag stand) — es wurde bewusst keine Bewertung abgegeben.
</p>
{{else if .Findings}}
<ul class="findings">
{{range .Findings}}
<li class="finding finding-{{.Severity}}">
<strong>{{.RuleID}} v{{.Version}}</strong> ({{.Severity}}) — {{.Title}}
<p>Korrektur: {{.Fix}}</p>
{{if .Sources}}
<p>Fundstellen: {{range $i, $s := .Sources}}{{if $i}}; {{end}}{{$s}}{{end}}</p>
{{end}}
</li>
{{end}}
</ul>
{{else}}
<p class="keine-findings">Keine Kennzeichnungsrisiken nach aktuellem Regelwerk gefunden.</p>
{{end}}
<p class="disclaimer">
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
Prüfung im Einzelfall.
</p>
{{end}}