feat!: Produktwechsel zu KI-Antragsprüfung — Phase 1 (Datenmodell, Regelwerk, Katalog)

Deklarix war eine Pre-Publish-Kennzeichnungsprüfung für Werbe-Content
(UWG/MStV). Dieser Scope wird komplett verworfen und durch eine
KI-Antragsprüfung ersetzt: Mitarbeitende beschreiben ein KI-Vorhaben,
das System leitet Datenklasse und KI-VO-Einstufung ab, gleicht sie
gegen einen Werkzeugkatalog ab und erzeugt einen Entscheidungsvorschlag
mit Herleitung — ein Mensch entscheidet, das System bereitet nur vor.

BREAKING CHANGE: Migration 0008 droppt alle werberechtsspezifischen
Tabellen (submission, finding, extraction, evidence_package,
participant, platform_connection, asset). account/app_user/session/
audit_log bleiben (Mandantentrennung, Login, Protokollierung sind
produktunabhängig) — app_user.role wechselt von
creator/agentur/marke/kanzlei/admin zu den fünf neuen Rollen
mitarbeiter/verantwortlicher/pruefer/admin/betreiber (vier
Mandanten-Rollen + eine plattformweite, siehe CLAUDE.md).

Entfernt: internal/extract, internal/dossier, internal/evidence,
internal/socialconnect, alte rules/*.yaml (UWG-Regeln), testdata/golden
— alles ausschließlich für das alte Produkt.

Neu, Phase 1 der Baureihenfolge ("Datenmodell, Regelwerk als YAML,
Katalogstruktur"):
- Store: abteilung (Stammdaten), werkzeug + werkzeug_sperre (der
  eigentliche Wert des Produkts — zentral gepflegter Katalog mit
  mandantenspezifischen Ergänzungen/Sperrungen, Pflichtfelder
  letzte_pruefung/quelle für jede Zusicherung), antrag (Fragebogen-
  Grundgerüst, Antworten als JSONB für den adaptiven Fragebogen aus
  Phase 2).
- internal/rules komplett neu: lädt und validiert drei YAML-
  Regelwerke (Datenklasse-Ableitung, KI-VO-Einstufung, Anforderungs-
  profil) aus rules/*.yaml — noch ohne Auswertungslogik gegen echte
  Fragebogen-Antworten (das ist Phase 3, bewusst erst nach dem
  Fragebogen aus Phase 2, der die exakten Fakten-Feldnamen festlegt).
  Offene fachliche Annahmen (Rangfolge der Datenklassen, Fragebogen-
  Lücke für die "verboten"-Varianten) explizit in rules/OPEN.md
  dokumentiert statt geraten.
- Web-Layer auf Minimalgerüst reduziert, das kompiliert und die neue
  Rollenwelt trägt: Firma-Registrierung (Ebene 1, erster Nutzer wird
  admin), Login/Logout, Plattform-Bereich (Ebene 5, nur betreiber:
  Dashboard, Accounts-Übersicht, Audit-Log) — Fragebogen (Ebene 2) und
  Fachebene (Ebene 3) folgen in den nächsten Phasen.
- CLAUDE.md komplett neu geschrieben: Produktbeschreibung, Fünf-Ebenen-
  Rollenmodell, Fragebogen-Spezifikation, Ableitungstabellen,
  Werkzeugkatalog, Bewertungslogik (geplant), Onboarding, offene
  Punkte (u. a. Postgres-RLS-Frage aus der Frontend-Spezifikation
  noch nicht entschieden, "Admin und Verantwortlicher gleichzeitig"
  beim Onboarding noch nicht datenmodelliert).

Volle Testsuite inkl. echter Postgres-Tests grün. End-to-End gegen
einen laufenden Server verifiziert: Firma-Registrierung legt Account +
admin-Nutzer an, Betreiber-Login leitet zu /betreiber, mandanten-
übergreifende Accounts-Liste sichtbar für betreiber, 404 für
mitarbeiter auf /betreiber, 303 zu /login ohne Sitzung.
This commit is contained in:
noroot
2026-08-28 21:39:04 +02:00
parent 6fd7831784
commit b4d4ee8d3c
96 changed files with 2348 additions and 7612 deletions

View File

@@ -1,217 +0,0 @@
package web
import (
"net/http"
)
type kanzleiListItem struct {
Name string
}
type kanzleiListData struct {
Title string
Kanzleien []kanzleiListItem
}
// handlePublicKanzleiList zeigt das öffentliche, kostenlose Kanzlei-
// Verzeichnis — keine Anmeldung nötig. Bewusst nur Name, kein Kontakt-
// Button/Routing (siehe CLAUDE.md, § 49b Abs. 3 BRAO): Nutzer wählen
// selbst, es gibt keine Vermittlung.
func (s *Server) handlePublicKanzleiList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListVerifiedKanzleien(r.Context())
if err != nil {
http.Error(w, "Verzeichnis konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := kanzleiListData{Title: "Kanzlei-Verzeichnis"}
for _, a := range accounts {
data.Kanzleien = append(data.Kanzleien, kanzleiListItem{Name: a.Name})
}
if err := s.templates.ExecuteTemplate(w, "kanzleien", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminDashboardData struct {
Title string
Nav navData
AccountCount int
UnverifiedCount int
RecentAuditCount int
}
// handleAdminDashboard zeigt eine kurze Übersicht als Einstieg in den
// Admin-Bereich.
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
accounts, err := s.store.ListAccounts(ctx)
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
unverified := 0
for _, a := range accounts {
if !a.Verified {
unverified++
}
}
auditLog, err := s.store.ListAuditLog(ctx, 5)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminDashboardData{
Title: "Admin", Nav: navFor(r), AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog),
}
if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminAccountListItem struct {
ID string
Name string
Verified bool
CreatedAt string
}
type adminAccountListData struct {
Title string
Nav navData
Accounts []adminAccountListItem
}
// handleAdminAccountList listet alle Mandanten der Plattform.
func (s *Server) handleAdminAccountList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListAccounts(r.Context())
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAccountListData{Title: "Accounts", Nav: navFor(r)}
for _, a := range accounts {
data.Accounts = append(data.Accounts, adminAccountListItem{
ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "admin-accounts", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type adminUserView struct {
Email string
Role string
}
type adminAccountDetailData struct {
Title string
Nav navData
AccountID string
Name string
Verified bool
HasKanzlei bool
Users []adminUserView
CreatedAt string
}
// handleAdminAccountDetail zeigt einen Mandanten mit seinen Logins und —
// falls mindestens ein Login die Rolle "kanzlei" hat — der Möglichkeit,
// die Freigabe fürs öffentliche Kanzlei-Verzeichnis zu setzen.
func (s *Server) handleAdminAccountDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAccountDetailData{
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
}
for _, u := range users {
data.Users = append(data.Users, adminUserView{Email: u.Email, Role: u.Role})
if u.Role == "kanzlei" {
data.HasKanzlei = true
}
}
if err := s.templates.ExecuteTemplate(w, "admin-account-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAdminSetVerified schaltet die Freigabe fürs Kanzlei-Verzeichnis
// um und protokolliert die Aktion im Audit-Log.
func (s *Server) handleAdminSetVerified(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
accountID := r.PathValue("id")
ctx := r.Context()
if _, err := s.store.GetAccount(ctx, accountID); err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
verified := r.FormValue("verified") == "true"
updated, err := s.store.SetAccountVerified(ctx, accountID, verified)
if err != nil {
http.Error(w, "Freigabe konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError)
return
}
action := "account.unverified"
details := "Kanzlei-Verzeichnis: Freigabe entzogen"
if updated.Verified {
action = "account.verified"
details = "Kanzlei-Verzeichnis: Freigabe erteilt"
}
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, action, "account", accountID, details); err != nil {
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/accounts/"+accountID, http.StatusSeeOther)
}
type adminAuditEntryView struct {
CreatedAt string
Action string
TargetType string
TargetID string
Details string
}
type adminAuditLogData struct {
Title string
Nav navData
Entries []adminAuditEntryView
}
// handleAdminAuditLog zeigt das Protokoll der Admin-Aktionen.
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.ListAuditLog(r.Context(), 200)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := adminAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
for _, e := range entries {
data.Entries = append(data.Entries, adminAuditEntryView{
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
})
}
if err := s.templates.ExecuteTemplate(w, "admin-audit-log", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -1,198 +0,0 @@
package web_test
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
)
func TestAdminRoutesRejectNonAdminWith404(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
for _, path := range []string{"/admin", "/admin/accounts", "/admin/audit-log"} {
resp := getWithCookie(t, s, cookie, path)
if resp.Code != http.StatusNotFound {
t.Errorf("GET %s status = %d, want 404 for a non-admin user", path, resp.Code)
}
}
}
func TestAdminRoutesRedirectToLoginWithoutSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, nil, "/admin")
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
}
}
func TestAdminDashboardAccessibleForAdmin(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
resp := getWithCookie(t, s, adminCookie, "/admin")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
}
// TestNavShowsAdminLinkOnlyForAdmins deckt genau den gemeldeten Fall ab:
// nach der Anmeldung als Admin landet man auf der normalen Startseite
// (jeder Nutzer hat einen Account+Login, auch ein Admin) — ohne einen
// sichtbaren Weg zu /admin wäre der Admin-Bereich für einen Admin, der
// die URL nicht auswendig kennt, praktisch unerreichbar.
func TestNavShowsAdminLinkOnlyForAdmins(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
tenantCookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
adminResp := getWithCookie(t, s, adminCookie, "/")
if !strings.Contains(adminResp.Body.String(), `href="/admin"`) {
t.Errorf("expected an /admin nav link for an admin user, got: %s", adminResp.Body.String())
}
tenantResp := getWithCookie(t, s, tenantCookie, "/")
if strings.Contains(tenantResp.Body.String(), `href="/admin"`) {
t.Errorf("expected no /admin nav link for a non-admin user, got: %s", tenantResp.Body.String())
}
}
func TestAdminAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
seedAccount(t, fs, "Mandant A", "a@example.com")
seedAccount(t, fs, "Mandant B", "b@example.com")
resp := getWithCookie(t, s, adminCookie, "/admin/accounts")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Admin"} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in the admin account list, got: %s", want, body)
}
}
}
func TestAdminAccountDetailShowsUsersAndVerifyToggleOnlyForKanzlei(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
creatorCookie := seedAccount(t, fs, "Nur Creator", "creator@example.com")
_ = creatorCookie
var creatorAccID string
for id, acc := range fs.accounts {
if acc.Name == "Nur Creator" {
creatorAccID = id
}
}
kanzleiCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
_ = kanzleiCookie
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
creatorResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+creatorAccID)
if creatorResp.Code != http.StatusOK {
t.Fatalf("status = %d", creatorResp.Code)
}
if strings.Contains(creatorResp.Body.String(), "verifizieren") {
t.Errorf("expected no verify action for a non-kanzlei account, got: %s", creatorResp.Body.String())
}
kanzleiResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID)
if kanzleiResp.Code != http.StatusOK {
t.Fatalf("status = %d", kanzleiResp.Code)
}
if !strings.Contains(kanzleiResp.Body.String(), "kanzlei@example.com") {
t.Errorf("expected the kanzlei user's email on the account detail page, got: %s", kanzleiResp.Body.String())
}
if !strings.Contains(kanzleiResp.Body.String(), "/admin/accounts/"+kanzleiAccID+"/verifizieren") {
t.Errorf("expected a verify action for a kanzlei account, got: %s", kanzleiResp.Body.String())
}
}
func TestAdminVerifyAddsAccountToPublicDirectoryAndAuditLog(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
// Vor der Freigabe taucht die Kanzlei nicht im oeffentlichen
// Verzeichnis auf.
before := getWithCookie(t, s, nil, "/kanzleien")
if strings.Contains(before.Body.String(), "Kanzlei Musterfrau") {
t.Fatalf("kanzlei should not be public before verification, got: %s", before.Body.String())
}
verifyResp := postForm(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
if verifyResp.Code != http.StatusSeeOther {
t.Fatalf("verify status = %d, want 303, body: %s", verifyResp.Code, verifyResp.Body.String())
}
after := getWithCookie(t, s, nil, "/kanzleien")
if !strings.Contains(after.Body.String(), "Kanzlei Musterfrau") {
t.Fatalf("expected the kanzlei to be listed publicly after verification, got: %s", after.Body.String())
}
if len(fs.auditLog) != 1 {
t.Fatalf("expected exactly one audit entry, got %d", len(fs.auditLog))
}
if fs.auditLog[0].Action != "account.verified" || fs.auditLog[0].TargetID != kanzleiAccID {
t.Errorf("unexpected audit entry: %+v", fs.auditLog[0])
}
auditPageResp := getWithCookie(t, s, adminCookie, "/admin/audit-log")
if !strings.Contains(auditPageResp.Body.String(), "account.verified") {
t.Errorf("expected the audit entry on the audit log page, got: %s", auditPageResp.Body.String())
}
}
func TestAdminVerifyRejectsNonAdmin(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
tenantCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
var kanzleiAccID string
for id, acc := range fs.accounts {
if acc.Name == "Kanzlei Musterfrau" {
kanzleiAccID = id
}
}
resp := postForm(t, s, tenantCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a non-admin actor", resp.Code)
}
if fs.accounts[kanzleiAccID].Verified {
t.Fatal("account should not have been verified by a non-admin request")
}
}
func TestPublicKanzleiDirectoryRequiresNoLogin(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{Platform: "instagram"}})
req := getWithCookie(t, s, nil, "/kanzleien")
if req.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 without any session", req.Code)
}
}

View File

@@ -1,306 +0,0 @@
package web
import (
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
type submissionListItem struct {
ID string
Platform string
PostType string
Status string
CreatedAt string
FindingCount int
HighestSeverity string
}
type submissionListData struct {
Title string
Nav navData
Submissions []submissionListItem
}
// handleSubmissionList zeigt die Archiv-Übersicht: alle Beiträge des
// angemeldeten Mandanten mit Kurzfassung der Findings.
func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
summaries, err := s.store.ListSubmissionsForAccount(ctx, currentUser(r).AccountID)
if err != nil {
http.Error(w, "Beiträge konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := submissionListData{Title: "Beiträge", Nav: navFor(r)}
for _, sum := range summaries {
data.Submissions = append(data.Submissions, submissionListItem{
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
CreatedAt: sum.CreatedAt.Format("02.01.2006 15:04"),
FindingCount: sum.FindingCount, HighestSeverity: sum.HighestSeverity,
})
}
if err := s.templates.ExecuteTemplate(w, "beitraege", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type participantView struct {
ID string
Role string
Name string
Vorgegeben bool
Freigegeben bool
ApprovedAt string // leer, wenn noch nicht freigegeben
}
type insightsAssetView struct {
CreatedAt string
SHA256 string
}
type submissionDetailData struct {
Title string
Nav navData
SubmissionID string
Platform string
PostType string
Caption string
Status string
CreatedAt string
CanArchive bool
IsPublished bool
DossierURL string
Findings []findingView
Participants []participantView
InsightsReminder insightsReminder
InsightsAssets []insightsAssetView
}
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
// Wie in handleArchive/handleDossierDownload (siehe handlers.go): ein Beitrag
// eines anderen Accounts wird wie ein nicht existierender behandelt.
func (s *Server) loadOwnSubmission(r *http.Request, id string) (store.Submission, error) {
sub, err := s.store.GetSubmission(r.Context(), id)
if err != nil {
return store.Submission{}, err
}
if sub.AccountID != currentUser(r).AccountID {
return store.Submission{}, store.ErrNotFound
}
return sub, nil
}
func toParticipantViews(participants []store.Participant) []participantView {
views := make([]participantView, len(participants))
for i, p := range participants {
v := participantView{ID: p.ID, Role: p.Role, Name: p.Name, Vorgegeben: p.Vorgegeben, Freigegeben: p.Freigegeben}
if p.ApprovedAt != nil {
v.ApprovedAt = p.ApprovedAt.Format("02.01.2006 15:04")
}
views[i] = v
}
return views
}
// handleSubmissionDetail zeigt einen einzelnen Beitrag: Fakten, Findings,
// Verantwortungsmatrix (Beteiligte) inklusive Verwaltung.
func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
storeFindings, err := s.store.ListCurrentFindings(ctx, sub.ID)
if err != nil {
http.Error(w, "Findings konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
findings := make([]findingView, len(storeFindings))
for i, f := range storeFindings {
findings[i] = findingView{
RuleID: f.RuleID, Version: f.RuleVersion, Severity: f.Severity,
Title: f.Title, Fix: f.Fix, Sources: f.Sources,
}
}
participants, err := s.store.ListParticipants(ctx, sub.ID)
if err != nil {
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
assets, err := s.store.ListAssetsForSubmission(ctx, sub.ID)
if err != nil {
http.Error(w, "Assets konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
var insightsAssets []insightsAssetView
hasInsightsAsset := false
for _, a := range assets {
if a.Purpose == "insights" {
hasInsightsAsset = true
insightsAssets = append(insightsAssets, insightsAssetView{
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), SHA256: a.SHA256,
})
}
}
var reminder insightsReminder
if sub.Status == "published" {
if pkg, err := s.store.GetLatestEvidencePackage(ctx, sub.ID); err == nil {
reminder = computeInsightsReminder(sub.PostType, pkg.CreatedAt, time.Now(), hasInsightsAsset)
}
}
data := submissionDetailData{
Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
InsightsReminder: reminder, InsightsAssets: insightsAssets,
}
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAddInsightsAsset speichert einen zusätzlichen Screenshot der
// Kennzahlen (Insights) eines bereits veröffentlichten Beitrags — siehe
// insightsReminder. Nutzt dieselbe Validierung wie das initiale
// Standbild beim Prüfen (readUploadedAsset/storeAsset), nur mit
// purpose="insights" statt "initial" und als eigener, jederzeit
// wiederholbarer Upload statt einmalig beim Anlegen der Submission.
func (s *Server) handleAddInsightsAsset(w http.ResponseWriter, r *http.Request) {
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
return
}
asset, err := s.readUploadedAsset(r, "insights_standbild")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if asset == nil {
http.Error(w, "kein Standbild hochgeladen", http.StatusBadRequest)
return
}
if err := s.storeAsset(r.Context(), sub.ID, "insights", asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/beitraege/"+sub.ID, http.StatusSeeOther)
}
type participantListData struct {
SubmissionID string
Participants []participantView
}
// renderParticipantList rendert das Beteiligten-Fragment neu — Ziel für
// htmx-Swaps nach Hinzufügen/Ändern/Löschen, damit die Seite nicht neu
// geladen werden muss.
func (s *Server) renderParticipantList(w http.ResponseWriter, r *http.Request, submissionID string) {
participants, err := s.store.ListParticipants(r.Context(), submissionID)
if err != nil {
http.Error(w, "Beteiligte konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := participantListData{SubmissionID: submissionID, Participants: toParticipantViews(participants)}
if err := s.templates.ExecuteTemplate(w, "beteiligte-liste", data); err != nil {
http.Error(w, "Liste konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAddParticipant fügt einen Beteiligten zu einem Beitrag hinzu.
func (s *Server) handleAddParticipant(w http.ResponseWriter, r *http.Request) {
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
role := r.FormValue("role")
name := r.FormValue("name")
if role == "" || name == "" {
http.Error(w, "Rolle und Name sind Pflichtfelder", http.StatusBadRequest)
return
}
if _, err := s.store.CreateParticipant(r.Context(), sub.ID, role, name, false, false); err != nil {
http.Error(w, "Beteiligter konnte nicht angelegt werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, sub.ID)
}
// participantBelongsToOwnSubmission prüft, dass der Beteiligte tatsächlich
// zu einem Beitrag des angemeldeten Mandanten gehört — sonst könnte ein
// fremder Mandant über eine erratene participant_id einen Beteiligten
// eines anderen Accounts ändern oder löschen.
func (s *Server) participantBelongsToOwnSubmission(r *http.Request, submissionIDFromURL, participantID string) (store.Participant, error) {
p, err := s.store.GetParticipant(r.Context(), participantID)
if err != nil {
return store.Participant{}, err
}
if p.SubmissionID != submissionIDFromURL {
return store.Participant{}, store.ErrNotFound
}
if _, err := s.loadOwnSubmission(r, p.SubmissionID); err != nil {
return store.Participant{}, err
}
return p, nil
}
// handleUpdateParticipant setzt vorgegeben/freigegeben für einen Beteiligten.
func (s *Server) handleUpdateParticipant(w http.ResponseWriter, r *http.Request) {
submissionID := r.PathValue("id")
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
if err != nil {
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
return
}
vorgegeben := r.FormValue("vorgegeben") == "on"
freigegeben := r.FormValue("freigegeben") == "on"
if _, err := s.store.UpdateParticipant(r.Context(), p.ID, vorgegeben, freigegeben); err != nil {
http.Error(w, "Beteiligter konnte nicht aktualisiert werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, submissionID)
}
// handleDeleteParticipant entfernt einen Beteiligten.
func (s *Server) handleDeleteParticipant(w http.ResponseWriter, r *http.Request) {
submissionID := r.PathValue("id")
p, err := s.participantBelongsToOwnSubmission(r, submissionID, r.PathValue("pid"))
if err != nil {
http.Error(w, "Beteiligter nicht gefunden", http.StatusNotFound)
return
}
if err := s.store.DeleteParticipant(r.Context(), p.ID); err != nil {
http.Error(w, "Beteiligter konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError)
return
}
s.renderParticipantList(w, r, submissionID)
}

View File

@@ -1,202 +0,0 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
)
// checkAndReturnSubID führt eine Pre-Publish-Prüfung durch und liefert die
// dabei angelegte submission_id — Hilfsfunktion für Tests, die einen
// bereits existierenden Beitrag brauchen, ohne den ganzen Ablauf jedes Mal
// auszuschreiben.
func checkAndReturnSubID(t *testing.T, s interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}, cookie *http.Cookie) string {
t.Helper()
form := checkForm()
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
const marker = `name="submission_id" value="`
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a submission_id field in the result, got: %s", body)
}
rest := body[idx+len(marker):]
return rest[:strings.Index(rest, `"`)]
}
func TestSubmissionListShowsOwnSubmissionsOnly(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
_ = checkAndReturnSubID(t, s, cookieB)
resp := getWithCookie(t, s, cookieA, "/beitraege")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "/beitraege/"+subA) {
t.Errorf("expected Mandant A's own submission link, got: %s", body)
}
// Mandant B hat genau einen eigenen Beitrag, keinen von A.
respB := getWithCookie(t, s, cookieB, "/beitraege")
if strings.Contains(respB.Body.String(), "/beitraege/"+subA) {
t.Errorf("Mandant B should not see Mandant A's submission, got: %s", respB.Body.String())
}
}
func TestSubmissionListRequiresSession(t *testing.T) {
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
req := httptest.NewRequest(http.MethodGet, "/beitraege", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 redirect to /login", w.Code)
}
}
func TestSubmissionDetailShowsFactsAndFindings(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationPaid,
DisclosurePresent: true, DisclosureWording: "Werbung", DisclosureBeforeCut: false,
}})
subID := checkAndReturnSubID(t, s, cookie)
_ = fs
resp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if !strings.Contains(body, "WK-004") {
t.Errorf("expected the finding to be shown, got: %s", body)
}
if !strings.Contains(body, "/veroeffentlichen") {
t.Errorf("expected an archive option for a checked submission, got: %s", body)
}
}
func TestSubmissionDetailTenantIsolation(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
resp := getWithCookie(t, s, cookieB, "/beitraege/"+subA)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant detail status = %d, want 404", resp.Code)
}
}
func TestParticipantAddUpdateDeleteFlow(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
subID := checkAndReturnSubID(t, s, cookie)
addResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte", url.Values{
"role": {"creator"}, "name": {"Max Mustermann"},
})
if addResp.Code != http.StatusOK {
t.Fatalf("add participant status = %d, body: %s", addResp.Code, addResp.Body.String())
}
if !strings.Contains(addResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the new participant in the response, got: %s", addResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if !strings.Contains(detailResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the participant on the detail page, got: %s", detailResp.Body.String())
}
// Beteiligten-ID aus dem Update-Formular extrahieren.
body := addResp.Body.String()
const marker = "/beteiligte/"
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a participant action URL, got: %s", body)
}
rest := body[idx+len(marker):]
pID := rest[:strings.Index(rest, "/")]
updateResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/aktualisieren", url.Values{
"vorgegeben": {"on"}, "freigegeben": {"on"},
})
if updateResp.Code != http.StatusOK {
t.Fatalf("update participant status = %d, body: %s", updateResp.Code, updateResp.Body.String())
}
if !strings.Contains(updateResp.Body.String(), "freigegeben am") {
t.Fatalf("expected an approval timestamp after freigegeben=true, got: %s", updateResp.Body.String())
}
deleteResp := postForm(t, s, cookie, "/beitraege/"+subID+"/beteiligte/"+pID+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusOK {
t.Fatalf("delete participant status = %d, body: %s", deleteResp.Code, deleteResp.Body.String())
}
if strings.Contains(deleteResp.Body.String(), "Max Mustermann") {
t.Fatalf("expected the participant to be gone after delete, got: %s", deleteResp.Body.String())
}
}
func TestParticipantActionsRejectCrossTenantAccess(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subA := checkAndReturnSubID(t, s, cookieA)
// Mandant B darf für As Beitrag gar keinen Beteiligten anlegen.
addResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte", url.Values{
"role": {"creator"}, "name": {"Fremd"},
})
if addResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant add status = %d, want 404", addResp.Code)
}
p, err := fs.CreateParticipant(context.Background(), subA, "creator", "Eigener Beteiligter", false, false)
if err != nil {
t.Fatalf("CreateParticipant: %v", err)
}
updateResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/aktualisieren", url.Values{
"vorgegeben": {"on"},
})
if updateResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant update status = %d, want 404", updateResp.Code)
}
deleteResp := postForm(t, s, cookieB, "/beitraege/"+subA+"/beteiligte/"+p.ID+"/loeschen", url.Values{})
if deleteResp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant delete status = %d, want 404", deleteResp.Code)
}
if _, err := fs.GetParticipant(context.Background(), p.ID); err != nil {
t.Fatalf("participant should still exist after rejected cross-tenant delete: %v", err)
}
}

View File

@@ -1,191 +0,0 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
// tinyPNG ist das kleinstmögliche gültige PNG (1x1 transparent) — genug,
// um einen echten Datei-Upload zu simulieren, ohne eine Bilddatei aus
// dem Repo laden zu müssen.
var tinyPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
// postCheckWithImage stellt eine echte multipart/form-data-Anfrage wie
// der Browser sie schickt (im Gegensatz zu postForm, das urlencoded
// postet) — checkForm()-Felder plus ein optionales "standbild".
func postCheckWithImage(t *testing.T, s *web.Server, cookie *http.Cookie, imageBytes []byte, contentType string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for key, val := range checkForm() {
if err := mw.WriteField(key, val[0]); err != nil {
t.Fatalf("WriteField(%s): %v", key, err)
}
}
if imageBytes != nil {
part, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="standbild"; filename="screenshot.png"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(imageBytes); err != nil {
t.Fatalf("Write image bytes: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("multipart Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/pruefen", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestCheckWithImageUploadStoresAsset(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var subID string
for id := range fs.submissions {
subID = id
}
if subID == "" {
t.Fatal("expected a submission to have been created")
}
asset, err := fs.GetLatestAssetForSubmission(context.Background(), subID)
if err != nil {
t.Fatalf("expected an asset to be stored, got err: %v", err)
}
if asset.Kind != "image" || asset.SHA256 == "" {
t.Errorf("unexpected asset: %+v", asset)
}
}
func TestCheckWithoutImageStoresNoAsset(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postForm(t, s, cookie, "/pruefen", checkForm())
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var subID string
for id := range fs.submissions {
subID = id
}
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
t.Fatal("expected no asset when none was uploaded")
}
}
func TestCheckRejectsNonImageUpload(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
resp := postCheckWithImage(t, s, cookie, []byte("kein bild, nur text"), "text/plain")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a non-image upload, body: %s", resp.Code, resp.Body.String())
}
if len(fs.submissions) != 0 {
t.Error("expected no submission to be created when the upload is rejected")
}
}
func TestCheckRejectsOversizedUpload(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}})
tooLarge := bytes.Repeat([]byte{0xff}, 9<<20) // 9 MiB > 8 MiB Limit
resp := postCheckWithImage(t, s, cookie, tooLarge, "image/png")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for an oversized upload, body: %s", resp.Code, resp.Body.String())
}
}
// TestArchiveMetadataHashDiffersWhenAssetPresent prüft schwarz-verpackt
// (ohne PDF-Interna zu kennen — der Dossier-Content wird komprimiert,
// ein hex-Hash taucht daher nicht als durchsuchbarer String in den
// PDF-Rohbytes auf, siehe internal/dossier/content_test.go für die
// Prüfung auf Ebene der PDF-Inhaltsstruktur), dass ein hochgeladenes
// Standbild tatsächlich in den archivierten Metadaten-Hash einfließt:
// zwei sonst identische Beiträge, einer mit, einer ohne Bild, müssen
// unterschiedliche evidence_package.SHA256 ergeben.
func TestArchiveMetadataHashDiffersWhenAssetPresent(t *testing.T) {
fakeEx := fakeExtractor{
facts: rules.Facts{Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone},
raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`),
}
s, fs, cookie := newAuthedTestServer(t, fakeEx)
withImageResp := postCheckWithImage(t, s, cookie, tinyPNG, "image/png")
if withImageResp.Code != http.StatusOK {
t.Fatalf("check (mit Bild) status = %d, body: %s", withImageResp.Code, withImageResp.Body.String())
}
var withImageSubID string
for id := range fs.submissions {
withImageSubID = id
}
archiveWithImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withImageSubID}})
if archiveWithImage.Code != http.StatusOK {
t.Fatalf("archive (mit Bild) status = %d, body: %s", archiveWithImage.Code, archiveWithImage.Body.String())
}
pkgWithImage, err := fs.GetLatestEvidencePackage(context.Background(), withImageSubID)
if err != nil {
t.Fatalf("GetLatestEvidencePackage (mit Bild): %v", err)
}
withoutImageResp := postForm(t, s, cookie, "/pruefen", checkForm())
if withoutImageResp.Code != http.StatusOK {
t.Fatalf("check (ohne Bild) status = %d, body: %s", withoutImageResp.Code, withoutImageResp.Body.String())
}
var withoutImageSubID string
for id := range fs.submissions {
if id != withImageSubID {
withoutImageSubID = id
}
}
archiveWithoutImage := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {withoutImageSubID}})
if archiveWithoutImage.Code != http.StatusOK {
t.Fatalf("archive (ohne Bild) status = %d, body: %s", archiveWithoutImage.Code, archiveWithoutImage.Body.String())
}
pkgWithoutImage, err := fs.GetLatestEvidencePackage(context.Background(), withoutImageSubID)
if err != nil {
t.Fatalf("GetLatestEvidencePackage (ohne Bild): %v", err)
}
if pkgWithImage.SHA256 == pkgWithoutImage.SHA256 {
t.Fatal("expected different metadata hashes for an archived submission with vs. without an uploaded asset")
}
}

View File

@@ -26,10 +26,13 @@ func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden"})
}
// handleRegister legt einen neuen Mandanten (Account) und den ersten
// Nutzer darin an. Es gibt bewusst keinen separaten "Account beitreten"-
// Flow — das wäre Schritt 4 (Agentur-/Markensicht mit mehreren Nutzern
// pro Account), hier reicht ein Nutzer pro neuem Account.
// handleRegister ist die "Firma"-Registrierung (Ebene 1 der Frontend-
// Spezifikation): legt einen neuen Mandanten (Account) und den ersten
// Nutzer als admin an. Testzugang ist sofort aktiv, der Bezahlbetrieb
// wird separat vom Betreiber freigeschaltet (nicht Teil dieses Flows).
// Es gibt bewusst KEINE offene Selbstregistrierung für einzelne
// Mitarbeiter — die kommen über Einladungslink/CSV-Import/Mandantenlink
// (spätere Ausbaustufe), nicht über dieses Formular.
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "ungültiges Formular"})
@@ -39,8 +42,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
accountName := r.FormValue("account_name")
email := r.FormValue("email")
password := r.FormValue("password")
role := r.FormValue("role")
if accountName == "" || email == "" || password == "" || role == "" {
if accountName == "" || email == "" || password == "" {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"})
return
}
@@ -57,7 +59,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Konto konnte nicht angelegt werden"})
return
}
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, role)
user, err := s.store.CreateUser(ctx, acc.ID, email, passwordHash, "admin")
if err != nil {
s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Nutzer konnte nicht angelegt werden — E-Mail evtl. schon vergeben"})
return
@@ -96,11 +98,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
return
}
// Ein Admin-Login hat keinen eigenen Beitrag zu prüfen — die
// Pre-Publish-Prüfung ("/") ist die Startseite für Mandanten, für
// einen Admin ist der Admin-Bereich der sinnvolle Einstieg.
if user.Role == "admin" {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
// Ein Betreiber-Login hat keinen eigenen Antrag zu stellen — der
// Plattform-Bereich ist der sinnvolle Einstieg (Ebene 5, technisch
// getrennt vom Mandantenbereich).
if user.Role == "betreiber" {
http.Redirect(w, r, "/betreiber", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)

View File

@@ -0,0 +1,139 @@
package web
import (
"net/http"
)
type betreiberDashboardData struct {
Title string
Nav navData
AccountCount int
RecentAuditCount int
}
// handleBetreiberDashboard zeigt eine kurze Übersicht als Einstieg in
// den Plattform-Bereich (Ebene 5 — nur Betreiber, technisch getrennt
// vom Mandantenbereich).
func (s *Server) handleBetreiberDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
accounts, err := s.store.ListAccounts(ctx)
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
auditLog, err := s.store.ListAuditLog(ctx, 5)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberDashboardData{
Title: "Plattform", Nav: navFor(r), AccountCount: len(accounts), RecentAuditCount: len(auditLog),
}
if err := s.templates.ExecuteTemplate(w, "betreiber-dashboard", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type betreiberAccountListItem struct {
ID string
Name string
CreatedAt string
}
type betreiberAccountListData struct {
Title string
Nav navData
Accounts []betreiberAccountListItem
}
// handleBetreiberAccountList listet alle Mandanten der Plattform.
func (s *Server) handleBetreiberAccountList(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.ListAccounts(r.Context())
if err != nil {
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberAccountListData{Title: "Accounts", Nav: navFor(r)}
for _, a := range accounts {
data.Accounts = append(data.Accounts, betreiberAccountListItem{
ID: a.ID, Name: a.Name, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-accounts", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type betreiberUserView struct {
Email string
Role string
}
type betreiberAccountDetailData struct {
Title string
Nav navData
AccountID string
Name string
Users []betreiberUserView
CreatedAt string
}
// handleBetreiberAccountDetail zeigt einen Mandanten mit seinen Logins.
func (s *Server) handleBetreiberAccountDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
return
}
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
if err != nil {
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberAccountDetailData{
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
}
for _, u := range users {
data.Users = append(data.Users, betreiberUserView{Email: u.Email, Role: u.Role})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-account-detail", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
type betreiberAuditEntryView struct {
CreatedAt string
Action string
TargetType string
TargetID string
Details string
}
type betreiberAuditLogData struct {
Title string
Nav navData
Entries []betreiberAuditEntryView
}
// handleBetreiberAuditLog zeigt das plattformweite Audit-Log.
func (s *Server) handleBetreiberAuditLog(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.ListAuditLog(r.Context(), 200)
if err != nil {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
data := betreiberAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
for _, e := range entries {
data.Entries = append(data.Entries, betreiberAuditEntryView{
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
})
}
if err := s.templates.ExecuteTemplate(w, "betreiber-audit-log", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}

View File

@@ -1,96 +1,10 @@
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 Datei-Feld fieldName. Liefert
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request, fieldName string) (*uploadedAsset, error) {
file, header, err := r.FormFile(fieldName)
// 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. purpose ist "initial" (das Beweisfoto beim
// Prüfen, ein Dateiname pro Submission reicht) oder "insights" (kann
// mehrfach vorkommen, braucht daher einen eindeutigen Dateinamen).
func (s *Server) storeAsset(ctx context.Context, submissionID, purpose string, ua *uploadedAsset) error {
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
}
filename := submissionID + ua.extension
if purpose != "initial" {
filename = fmt.Sprintf("%s-%s-%d%s", submissionID, purpose, time.Now().UnixNano(), ua.extension)
}
path := filepath.Join(s.assetDir, filename)
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", purpose, 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}`)
@@ -101,292 +15,12 @@ type indexData struct {
Nav navData
}
// handleIndex ist die Startseite nach der Anmeldung. Platzhalter für
// Phase 1 (Datenmodell/Regelwerk/Katalogstruktur) — der geführte
// Fragebogen (Ebene 2, "Antrag stellen") ist Phase 2 der Baureihenfolge.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
data := indexData{Title: "Pre-Publish-Prüfung", Nav: navFor(r)}
data := indexData{Title: "Start", 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, "standbild")
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, "initial", 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)
}

View File

@@ -1,132 +0,0 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
// postInsightsUpload lädt einen Insights-Screenshot für einen Beitrag hoch.
func postInsightsUpload(t *testing.T, s *web.Server, cookie *http.Cookie, submissionID string, imageBytes []byte) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="insights_standbild"; filename="insights.png"`},
"Content-Type": {"image/png"},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(imageBytes); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("multipart Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/beitraege/"+submissionID+"/insights", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestStorySubmissionShowsInsightsReminderAfterArchiving(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
if archiveResp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
}
body := detailResp.Body.String()
if !strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected an insights reminder for a freshly archived story, got: %s", body)
}
_ = fs
}
func TestInsightsUploadClearsReminder(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
if resp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}); resp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", resp.Code, resp.Body.String())
}
uploadResp := postInsightsUpload(t, s, cookie, subID, tinyPNG)
if uploadResp.Code != http.StatusSeeOther {
t.Fatalf("insights upload status = %d, want 303, body: %s", uploadResp.Code, uploadResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
body := detailResp.Body.String()
if strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected the reminder to be gone after securing insights, got: %s", body)
}
if !strings.Contains(body, "Gesicherte Insights-Nachweise") {
t.Errorf("expected the secured insights section, got: %s", body)
}
}
func TestInsightsUploadRejectsForeignSubmission(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subID := checkAndReturnSubID2(t, s, cookieA, "story")
resp := postInsightsUpload(t, s, cookieB, subID, tinyPNG)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant insights upload status = %d, want 404", resp.Code)
}
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
t.Fatal("expected no asset from a rejected cross-tenant upload")
}
}
// checkAndReturnSubID2 ist checkAndReturnSubID mit ueberschreibbarem post_type.
func checkAndReturnSubID2(t *testing.T, s interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}, cookie *http.Cookie, postType string) string {
t.Helper()
form := checkForm("post_type", postType)
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
const marker = `name="submission_id" value="`
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a submission_id field in the result, got: %s", body)
}
rest := body[idx+len(marker):]
return rest[:strings.Index(rest, `"`)]
}

View File

@@ -1,47 +0,0 @@
package web
import (
"fmt"
"time"
)
// storyInsightsWindow ist das Zeitfenster, in dem Instagram nach
// eigener Aussage Story-Insights überhaupt vorhält — danach sind sie
// auch über den offiziellen Datenexport nicht mehr zu bekommen. Reine
// Produktentscheidung (Erinnerungs-Timing), keine Rechtsnorm — bewusst
// hier als Konstante und nicht in rules/*.yaml, das ist ausschließlich
// für die Kennzeichnungsprüfung reserviert (siehe CLAUDE.md).
const storyInsightsWindow = 24 * time.Hour
// insightsReminder beschreibt, ob und wie dringend eine Erinnerung
// angezeigt werden soll, die Kennzahlen (Insights) eines veröffentlichten
// Beitrags per Screenshot zu sichern, bevor sie unwiederbringlich
// verschwinden.
type insightsReminder struct {
Show bool
Urgent bool // Fenster läuft noch
Expired bool // Fenster ist wahrscheinlich schon vorbei
Message string
}
// computeInsightsReminder berechnet den Erinnerungsstatus. Nur für
// "story"-Beiträge relevant (siehe storyInsightsWindow); alle anderen
// Beitragstypen bekommen keine Erinnerung, da ihre Kennzahlen nicht auf
// dieselbe Art flüchtig sind.
func computeInsightsReminder(postType string, publishedAt time.Time, now time.Time, hasInsightsAsset bool) insightsReminder {
if postType != "story" || hasInsightsAsset || publishedAt.IsZero() {
return insightsReminder{}
}
elapsed := now.Sub(publishedAt)
if elapsed >= storyInsightsWindow {
return insightsReminder{
Show: true, Expired: true,
Message: "Das Zeitfenster für Story-Insights ist bei Instagram nach eigener Aussage abgelaufen (24 Stunden) — die Kennzahlen sind wahrscheinlich nicht mehr abrufbar.",
}
}
remainingHours := int((storyInsightsWindow - elapsed).Hours()) + 1
return insightsReminder{
Show: true, Urgent: true,
Message: fmt.Sprintf("Noch ca. %d Stunde(n), um die Story-Insights zu sichern, bevor sie bei Instagram verschwinden.", remainingHours),
}
}

View File

@@ -1,45 +0,0 @@
package web
import (
"testing"
"time"
)
func TestComputeInsightsReminderOnlyForStory(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("feed", now.Add(-time.Hour), now, false)
if r.Show {
t.Errorf("expected no reminder for a non-story post type, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWhenAlreadySecured(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-time.Hour), now, true)
if r.Show {
t.Errorf("expected no reminder once an insights asset already exists, got %+v", r)
}
}
func TestComputeInsightsReminderUrgentWithinWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-2*time.Hour), now, false)
if !r.Show || !r.Urgent || r.Expired {
t.Fatalf("expected an urgent, non-expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderExpiredAfterWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-25*time.Hour), now, false)
if !r.Show || !r.Expired || r.Urgent {
t.Fatalf("expected an expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWithoutPublishTime(t *testing.T) {
r := computeInsightsReminder("story", time.Time{}, time.Now(), false)
if r.Show {
t.Errorf("expected no reminder without a known publish time, got %+v", r)
}
}

View File

@@ -68,19 +68,20 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
}
}
// requireAdmin schützt den Admin-Bereich. Ohne Sitzung geht es wie bei
// requirePage zu /login; mit einer Sitzung, aber ohne Admin-Rolle, gibt
// es 404 statt 403 — sonst würde eine 403 einem angemeldeten, aber
// unprivilegierten Nutzer verraten, dass unter dieser URL überhaupt
// etwas existiert.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
// requireBetreiber schützt den Plattform-Bereich (Ebene 5 — "nur
// Betreiber", technisch getrennt vom Mandantenbereich, siehe CLAUDE.md).
// Ohne Sitzung geht es wie bei requirePage zu /login; mit einer
// Sitzung, aber ohne Betreiber-Rolle, gibt es 404 statt 403 — sonst
// würde eine 403 einem angemeldeten, aber unprivilegierten Nutzer
// verraten, dass unter dieser URL überhaupt etwas existiert.
func (s *Server) requireBetreiber(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := s.authenticate(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if user.Role != "admin" {
if user.Role != "betreiber" {
http.Error(w, "nicht gefunden", http.StatusNotFound)
return
}
@@ -90,17 +91,17 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
// navData steuert die gemeinsame Navigation (layout.html, "nav"-Block).
// Eigenes, kleines Struct statt jeder Seite Zugriff auf den vollen
// currentUser zu geben — die Navigation braucht nur, ob ein Admin-Link
// gezeigt werden soll.
// currentUser zu geben — die Navigation braucht nur, ob ein
// Plattform-Link gezeigt werden soll.
type navData struct {
IsAdmin bool
IsBetreiber bool
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireAdmin aufrufbar (braucht
// Nur für Seiten hinter requirePage/requireBetreiber aufrufbar (braucht
// currentUser).
func navFor(r *http.Request) navData {
return navData{IsAdmin: currentUser(r).Role == "admin"}
return navData{IsBetreiber: currentUser(r).Role == "betreiber"}
}
// currentUser liest den Nutzer, den requirePage/requireAPI in den

View File

@@ -1,142 +0,0 @@
package web
import (
"errors"
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/auth"
"github.com/netcell-it/deklarix/internal/store"
)
const oauthStateCookieName = "deklarix_oauth_state"
// knownPlatforms sind alle Plattformen, die die Verbindungs-Übersicht
// anzeigt — unabhängig davon, ob dafür schon ein Connector konfiguriert
// ist (siehe cmd/deklarix/main.go). Eine unkonfigurierte Plattform zeigt
// "nicht konfiguriert" statt eines Verbinden-Buttons.
var knownPlatforms = []string{"instagram", "tiktok"}
type connectionView struct {
Platform string
Configured bool
Connected bool
ConnectedAt string
}
type connectionsData struct {
Title string
Nav navData
Connections []connectionView
}
// handleConnectionsList zeigt, welche Plattformen der Account verbunden
// hat — Grundlage für die spätere automatische Beweissicherung
// (Post per API statt manuellem Screenshot-Upload abrufen).
func (s *Server) handleConnectionsList(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
existing, err := s.store.ListPlatformConnectionsForAccount(r.Context(), accountID)
if err != nil {
http.Error(w, "Verbindungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
connectedAt := map[string]string{}
for _, c := range existing {
connectedAt[c.Platform] = c.ConnectedAt.Format("02.01.2006 15:04")
}
data := connectionsData{Title: "Verbindungen", Nav: navFor(r)}
for _, platform := range knownPlatforms {
_, configured := s.connectors[platform]
at, connected := connectedAt[platform]
data.Connections = append(data.Connections, connectionView{
Platform: platform, Configured: configured, Connected: connected, ConnectedAt: at,
})
}
if err := s.templates.ExecuteTemplate(w, "verbindungen", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleOAuthStart leitet zum Consent-Screen der Plattform weiter. Der
// state-Wert wird in einem kurzlebigen Cookie gehalten und beim
// Callback gegengeprüft — Schutz gegen CSRF (ein Angreifer könnte sonst
// einen fremden Autorisierungscode gegen das Konto des Opfers
// einschleusen).
func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
connector, ok := s.connectors[r.PathValue("platform")]
if !ok {
http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound)
return
}
state, err := auth.NewSessionToken()
if err != nil {
http.Error(w, "Anfrage konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: oauthStateCookieName, Value: state, Path: "/oauth",
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(10 * time.Minute),
})
http.Redirect(w, r, connector.AuthorizationURL(state), http.StatusSeeOther)
}
// handleOAuthCallback verarbeitet die Rückleitung von der Plattform:
// state prüfen, Code gegen ein Token tauschen, Verbindung speichern.
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
connector, ok := s.connectors[r.PathValue("platform")]
if !ok {
http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound)
return
}
// Der Nutzer hat die Autorisierung abgelehnt — kein Fehler unsererseits.
if errParam := r.URL.Query().Get("error"); errParam != "" {
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
return
}
stateCookie, err := r.Cookie(oauthStateCookieName)
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "ungültiger oder abgelaufener State-Parameter", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{Name: oauthStateCookieName, Value: "", Path: "/oauth", MaxAge: -1})
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "kein Autorisierungscode erhalten", http.StatusBadRequest)
return
}
token, err := connector.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
var expiresAt *time.Time
if !token.ExpiresAt.IsZero() {
expiresAt = &token.ExpiresAt
}
accountID := currentUser(r).AccountID
if _, err := s.store.UpsertPlatformConnection(r.Context(), accountID, connector.Platform(), token.PlatformUserID, token.AccessToken, token.RefreshToken, expiresAt); err != nil {
http.Error(w, "Verbindung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
}
// handleDisconnect trennt eine Plattform-Verbindung.
func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) {
accountID := currentUser(r).AccountID
platform := r.PathValue("platform")
if err := s.store.DeletePlatformConnection(r.Context(), accountID, platform); err != nil && !errors.Is(err, store.ErrNotFound) {
http.Error(w, "Verbindung konnte nicht getrennt werden: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
}

View File

@@ -1,225 +0,0 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/netcell-it/deklarix/internal/socialconnect"
)
// fakeConnector ist ein socialconnect.Connector-Fake für Tests — kein
// echter HTTP-Aufruf gegen Instagram/TikTok nötig.
type fakeConnector struct {
platform string
token socialconnect.Token
err error
}
func (f fakeConnector) Platform() string { return f.platform }
func (f fakeConnector) AuthorizationURL(state string) string {
return "https://provider.example/authorize?state=" + state + "&platform=" + f.platform
}
func (f fakeConnector) Exchange(ctx context.Context, code string) (socialconnect.Token, error) {
if f.err != nil {
return socialconnect.Token{}, f.err
}
return f.token, nil
}
func TestConnectionsListShowsUnconfiguredPlatformsWithoutConnectButton(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, cookie, "/verbindungen")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
if strings.Contains(body, "/oauth/instagram/start") || strings.Contains(body, "/oauth/tiktok/start") {
t.Errorf("expected no connect links when no connector is configured, got: %s", body)
}
if !strings.Contains(body, "instagram") || !strings.Contains(body, "tiktok") {
t.Errorf("expected both platforms to be listed regardless of configuration, got: %s", body)
}
}
func TestConnectionsListShowsConnectButtonWhenConfigured(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
resp := getWithCookie(t, s, cookie, "/verbindungen")
body := resp.Body.String()
if !strings.Contains(body, "/oauth/instagram/start") {
t.Errorf("expected an Instagram connect link, got: %s", body)
}
if strings.Contains(body, "/oauth/tiktok/start") {
t.Errorf("expected no TikTok connect link (not configured), got: %s", body)
}
}
func TestOAuthStartRedirectsToProviderAndSetsStateCookie(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/start", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", w.Code)
}
loc := w.Header().Get("Location")
if !strings.HasPrefix(loc, "https://provider.example/authorize?") {
t.Fatalf("Location = %q, want a redirect to the provider", loc)
}
var stateCookie *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == "deklarix_oauth_state" {
stateCookie = c
}
}
if stateCookie == nil || stateCookie.Value == "" {
t.Fatal("expected a non-empty deklarix_oauth_state cookie to be set")
}
}
func TestOAuthStartRejectsUnconfiguredPlatform(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
resp := getWithCookie(t, s, cookie, "/oauth/instagram/start")
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for an unconfigured platform", resp.Code)
}
}
func TestOAuthCallbackStoresConnectionOnValidState(t *testing.T) {
fs := newFakeStore()
expires := time.Now().Add(60 * 24 * time.Hour)
connectors := map[string]socialconnect.Connector{
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{
AccessToken: "ig-token", PlatformUserID: "ig-user-1", ExpiresAt: expires,
}},
}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=gueltiger-state", nil)
req.AddCookie(cookie)
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "gueltiger-state"})
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", w.Code, w.Body.String())
}
if loc := w.Header().Get("Location"); loc != "/verbindungen" {
t.Fatalf("Location = %q, want /verbindungen", loc)
}
var accID string
for id := range fs.accounts {
accID = id
}
conn, err := fs.GetPlatformConnection(context.Background(), accID, "instagram")
if err != nil {
t.Fatalf("expected a stored connection, got err: %v", err)
}
if conn.AccessToken != "ig-token" || conn.PlatformUserID != "ig-user-1" {
t.Errorf("unexpected connection: %+v", conn)
}
}
func TestOAuthCallbackRejectsMismatchedState(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{AccessToken: "x", PlatformUserID: "y"}},
}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=falscher-state", nil)
req.AddCookie(cookie)
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "anderer-state"})
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a state mismatch", w.Code)
}
}
func TestOAuthCallbackHandlesUserDenial(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{
"instagram": fakeConnector{platform: "instagram"},
}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?error=access_denied", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (redirect back, no error page)", w.Code)
}
}
func TestDisconnectRemovesConnection(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
var accID string
for id := range fs.accounts {
accID = id
}
if _, err := fs.UpsertPlatformConnection(context.Background(), accID, "instagram", "u1", "tok", "", nil); err != nil {
t.Fatalf("UpsertPlatformConnection: %v", err)
}
resp := postForm(t, s, cookie, "/verbindungen/instagram/trennen", url.Values{})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if _, err := fs.GetPlatformConnection(context.Background(), accID, "instagram"); err == nil {
t.Fatal("expected the connection to be gone after disconnect")
}
}
func TestConnectionsIsolatedPerTenant(t *testing.T) {
fs := newFakeStore()
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
var accA string
for id, acc := range fs.accounts {
if acc.Name == "Mandant A" {
accA = id
}
}
if _, err := fs.UpsertPlatformConnection(context.Background(), accA, "instagram", "u1", "tok", "", nil); err != nil {
t.Fatalf("UpsertPlatformConnection: %v", err)
}
respA := getWithCookie(t, s, cookieA, "/verbindungen")
if !strings.Contains(respA.Body.String(), "verbunden seit") {
t.Errorf("expected Mandant A to see their own connection, got: %s", respA.Body.String())
}
respB := getWithCookie(t, s, cookieB, "/verbindungen")
if strings.Contains(respB.Body.String(), "verbunden seit") {
t.Errorf("expected Mandant B to see no connection, got: %s", respB.Body.String())
}
}

View File

@@ -1,6 +1,6 @@
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
// Stack). Jeder Antrag gehört einem Account (Mandant); Auth-Cookies
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
// Account — siehe middleware.go.
package web
@@ -13,10 +13,6 @@ import (
"net/http"
"time"
"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/socialconnect"
"github.com/netcell-it/deklarix/internal/store"
)
@@ -26,42 +22,14 @@ var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
// Tests injizieren einen Fake, um Facts unabhängig von der echten
// Erkennungslogik vorzugeben.
type Extractor interface {
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
ModelVersion() string
}
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
// einen Fake statt eine echte Datenbank zu brauchen.
type Store interface {
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
GetSubmission(ctx context.Context, id string) (store.Submission, error)
SetSubmissionStatus(ctx context.Context, id, status string) error
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error)
CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error)
ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error)
GetParticipant(ctx context.Context, id string) (store.Participant, error)
UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error)
DeleteParticipant(ctx context.Context, id string) error
CreateAccount(ctx context.Context, name string) (store.Account, error)
GetAccount(ctx context.Context, id string) (store.Account, error)
ListAccounts(ctx context.Context) ([]store.Account, error)
ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error)
SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error)
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
GetUserByEmail(ctx context.Context, email string) (store.User, error)
GetUser(ctx context.Context, id string) (store.User, error)
@@ -71,51 +39,25 @@ type Store interface {
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error)
UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error)
ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error)
DeletePlatformConnection(ctx context.Context, accountID, platform string) error
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
type Server struct {
mux *http.ServeMux
extractor Extractor
ruleSet []rules.Rule
store Store
timestamper evidence.Timestamper
dossierDir string
assetDir string
connectors map[string]socialconnect.Connector
templates *template.Template
mux *http.ServeMux
store Store
templates *template.Template
}
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
// assetDir das Verzeichnis für hochgeladene Standbilder. connectors
// enthält nur die Plattformen, für die echte Client-Credentials
// konfiguriert sind (siehe cmd/deklarix/main.go) — eine leere oder nil
// Map ist gültig, dann zeigt /verbindungen "nicht konfiguriert" statt
// eines Verbinden-Buttons.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string, connectors map[string]socialconnect.Connector) (*Server, error) {
// NewServer erstellt den Server.
func NewServer(st Store) (*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,
store: st,
timestamper: timestamper,
dossierDir: dossierDir,
assetDir: assetDir,
connectors: connectors,
templates: tmpl,
store: st,
templates: tmpl,
}
mux := http.NewServeMux()
@@ -126,25 +68,10 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList))
mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail))
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
mux.HandleFunc("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset))
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList))
mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart))
mux.HandleFunc("GET /oauth/{platform}/callback", s.requirePage(s.handleOAuthCallback))
mux.HandleFunc("POST /verbindungen/{platform}/trennen", s.requireAPI(s.handleDisconnect))
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard))
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))
mux.HandleFunc("POST /admin/accounts/{id}/verifizieren", s.requireAdmin(s.handleAdminSetVerified))
mux.HandleFunc("GET /admin/audit-log", s.requireAdmin(s.handleAdminAuditLog))
mux.HandleFunc("GET /betreiber", s.requireBetreiber(s.handleBetreiberDashboard))
mux.HandleFunc("GET /betreiber/accounts", s.requireBetreiber(s.handleBetreiberAccountList))
mux.HandleFunc("GET /betreiber/accounts/{id}", s.requireBetreiber(s.handleBetreiberAccountDetail))
mux.HandleFunc("GET /betreiber/audit-log", s.requireBetreiber(s.handleBetreiberAuditLog))
mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux = mux

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
{{define "admin-account-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/admin/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
</li>
{{end}}
</ul>
{{if .HasKanzlei}}
<h2>Kanzlei-Verzeichnis</h2>
{{if .Verified}}
<p>Status: <span class="status status-published">verifiziert — im öffentlichen Verzeichnis gelistet</span></p>
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
<input type="hidden" name="verified" value="false">
<button type="submit">Freigabe entziehen</button>
</form>
{{else}}
<p>Status: <span class="status status-mittel">nicht verifiziert — noch nicht gelistet</span></p>
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
<input type="hidden" name="verified" value="true">
<button type="submit">Fürs Verzeichnis freigeben</button>
</form>
{{end}}
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -1,17 +0,0 @@
{{define "admin-dashboard"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Admin</h1>
<ul class="admin-kacheln">
<li><a href="/admin/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
<li><a href="/admin/accounts">Unverifizierte Kanzleien <span class="status status-mittel">{{.UnverifiedCount}}</span></a></li>
<li><a href="/admin/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
<li><a href="/kanzleien">Öffentliches Kanzlei-Verzeichnis ansehen</a></li>
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -1,13 +0,0 @@
{{define "archived"}}
<p class="archiviert">
Beitrag archiviert und mit RFC-3161-Zeitstempel versehen
({{.TimestampedAt}}).
</p>
<p>
<a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a>
</p>
<p class="disclaimer">
Dieses Dossier dokumentiert die durchgeführte Prüfung. Es ist keine
Rechtsberatung und ersetzt keine anwaltliche Prüfung im Einzelfall.
</p>
{{end}}

View File

@@ -1,33 +0,0 @@
{{define "beitraege"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Beiträge</h1>
{{if not .Submissions}}
<p class="hinweis">Noch keine Beiträge geprüft.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Submissions}}
<li>
<a href="/beitraege/{{.ID}}">
<strong>{{.Platform}}</strong> · {{.PostType}} · {{.CreatedAt}}
<span class="status status-{{.Status}}">{{.Status}}</span>
{{if .HighestSeverity}}
<span class="finding-{{.HighestSeverity}}">{{.FindingCount}} Finding(s), höchste: {{.HighestSeverity}}</span>
{{else}}
<span class="keine-findings">keine Findings</span>
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
<p><a href="/">Neuen Beitrag prüfen</a></p>
</div>
</body>
</html>
{{end}}

View File

@@ -1,85 +0,0 @@
{{define "beitrag"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/beitraege">&larr; Alle Beiträge</a></p>
<h1>{{.Platform}} · {{.PostType}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}} — Status: <span class="status status-{{.Status}}">{{.Status}}</span></p>
<p>{{.Caption}}</p>
<h2>Findings</h2>
{{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}}
{{if .CanArchive}}
<form hx-post="/veroeffentlichen" hx-target="#archiv-ergebnis" hx-swap="innerHTML">
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
<button type="submit">Als veröffentlicht markieren &amp; archivieren</button>
</form>
<div id="archiv-ergebnis"></div>
{{end}}
{{if .IsPublished}}
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
{{if .InsightsReminder.Show}}
<div class="{{if .InsightsReminder.Expired}}fehler{{else}}rueckfrage{{end}}">
<p>{{.InsightsReminder.Message}}</p>
<form method="post" action="/beitraege/{{.SubmissionID}}/insights" enctype="multipart/form-data">
<label for="insights_standbild">Insights-Screenshot</label>
<input type="file" id="insights_standbild" name="insights_standbild" accept="image/*" required>
<button type="submit">Insights jetzt sichern</button>
</form>
</div>
{{end}}
{{if .InsightsAssets}}
<h2>Gesicherte Insights-Nachweise</h2>
<ul class="beteiligte">
{{range .InsightsAssets}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — SHA-256: {{.SHA256}}</div>
</li>
{{end}}
</ul>
{{end}}
{{end}}
<h2>Verantwortungsmatrix</h2>
{{template "beteiligte-liste" .}}
<form hx-post="/beitraege/{{.SubmissionID}}/beteiligte" hx-target="#beteiligte" hx-swap="outerHTML">
<label for="role">Rolle</label>
<select id="role" name="role" required>
<option value="creator">Creator</option>
<option value="agentur">Agentur</option>
<option value="marke">Marke</option>
<option value="kanzlei">Kanzlei</option>
</select>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<button type="submit">Beteiligten hinzufügen</button>
</form>
<p class="disclaimer">
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
Prüfung im Einzelfall.
</p>
</div>
</body>
</html>
{{end}}

View File

@@ -1,28 +0,0 @@
{{define "beteiligte-liste"}}
<div id="beteiligte">
{{if not .Participants}}
<p class="hinweis">Noch keine Beteiligten erfasst.</p>
{{else}}
<ul class="beteiligte">
{{range .Participants}}
<li class="beteiligter">
<div class="beteiligter-kopf">
<strong>{{.Name}}</strong> <span class="rolle">({{.Role}})</span>
</div>
<form class="beteiligter-form"
hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/aktualisieren"
hx-target="#beteiligte" hx-swap="outerHTML" hx-trigger="change">
<label><input type="checkbox" name="vorgegeben" {{if .Vorgegeben}}checked{{end}}> Vorgegeben</label>
<label><input type="checkbox" name="freigegeben" {{if .Freigegeben}}checked{{end}}> Freigegeben</label>
{{if .ApprovedAt}}<span class="hinweis">freigegeben am {{.ApprovedAt}}</span>{{end}}
</form>
<form hx-post="/beitraege/{{$.SubmissionID}}/beteiligte/{{.ID}}/loeschen"
hx-target="#beteiligte" hx-swap="outerHTML" hx-confirm="Beteiligten wirklich entfernen?">
<button type="submit" class="entfernen">Entfernen</button>
</form>
</li>
{{end}}
</ul>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,22 @@
{{define "betreiber-account-detail"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/betreiber/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1>
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
<h2>Nutzer</h2>
<ul class="beteiligte">
{{range .Users}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
</li>
{{end}}
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -1,10 +1,10 @@
{{define "admin-accounts"}}<!doctype html>
{{define "betreiber-accounts"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/admin">&larr; Admin</a></p>
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Accounts</h1>
{{if not .Accounts}}
<p class="hinweis">Noch keine Accounts.</p>
@@ -12,13 +12,8 @@
<ul class="beitraege-liste">
{{range .Accounts}}
<li>
<a href="/admin/accounts/{{.ID}}">
<a href="/betreiber/accounts/{{.ID}}">
<strong>{{.Name}}</strong> · {{.CreatedAt}}
{{if .Verified}}
<span class="status status-published">verifiziert</span>
{{else}}
<span class="status status-mittel">nicht verifiziert</span>
{{end}}
</a>
</li>
{{end}}

View File

@@ -1,10 +1,10 @@
{{define "admin-audit-log"}}<!doctype html>
{{define "betreiber-audit-log"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<p><a href="/admin">&larr; Admin</a></p>
<p><a href="/betreiber">&larr; Plattform</a></p>
<h1>Audit-Log</h1>
{{if not .Entries}}
<p class="hinweis">Noch keine Einträge.</p>

View File

@@ -0,0 +1,15 @@
{{define "betreiber-dashboard"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Plattform</h1>
<ul class="admin-kacheln">
<li><a href="/betreiber/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
<li><a href="/betreiber/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
</ul>
</div>
</body>
</html>
{{end}}

View File

@@ -4,51 +4,11 @@
<body>
{{template "nav" .Nav}}
<div class="page">
<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" hx-encoding="multipart/form-data" enctype="multipart/form-data">
<label for="platform">Plattform</label>
<select id="platform" name="platform" required>
<option value="instagram">Instagram</option>
<option value="tiktok">TikTok</option>
</select>
<label for="post_type">Beitragstyp</label>
<select id="post_type" name="post_type" required>
<option value="feed">Feed</option>
<option value="reel">Reel</option>
<option value="story">Story</option>
<option value="video">Video</option>
</select>
<label for="consideration">Gegenleistung</label>
<select id="consideration" name="consideration" required>
<option value="">— bitte wählen —</option>
<option value="bezahlt">Bezahlt</option>
<option value="sachbezug">Sachbezug (Produkt, Einladung, ...)</option>
<option value="keine">Keine</option>
<option value="unklar">Unklar</option>
</select>
<p class="hinweis">
Das kann die Prüfung nicht aus der Caption erraten — nur wer
einreicht, weiß, ob eine Gegenleistung vorlag.
</p>
<label for="caption">Caption</label>
<textarea id="caption" name="caption" rows="6" required></textarea>
<label for="standbild">Standbild (optional)</label>
<input type="file" id="standbild" name="standbild" accept="image/*">
<p class="hinweis">
Screenshot des veröffentlichten Beitrags — wird Teil des
Nachweis-Dossiers, sobald der Beitrag archiviert wird.
</p>
<button type="submit">Prüfen</button>
</form>
<div id="ergebnis"></div>
<h1>Willkommen bei Deklarix</h1>
<p class="hinweis">
Der geführte Fragebogen für die Antragsprüfung folgt in der nächsten
Ausbaustufe. Datenmodell, Regelwerk und Werkzeugkatalog stehen bereits.
</p>
</div>
</body>
</html>

View File

@@ -1,23 +0,0 @@
{{define "kanzleien"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Kanzlei-Verzeichnis</h1>
<p class="hinweis">
Kostenloses Verzeichnis auf Deklarix verifizierter Kanzleien. Keine
Vermittlung, keine Empfehlung — die Auswahl trifft allein der Nutzer.
</p>
{{if not .Kanzleien}}
<p class="hinweis">Noch keine Kanzlei gelistet.</p>
{{else}}
<ul class="beitraege-liste">
{{range .Kanzleien}}
<li><span class="kanzlei-eintrag">{{.Name}}</span></li>
{{end}}
</ul>
{{end}}
</div>
</body>
</html>
{{end}}

View File

@@ -8,10 +8,8 @@
{{define "nav"}}
<nav>
<a href="/">Prüfen</a>
<a href="/beitraege">Beiträge</a>
<a href="/verbindungen">Verbindungen</a>
{{if .IsAdmin}}<a href="/admin">Admin</a>{{end}}
<a href="/">Start</a>
{{if .IsBetreiber}}<a href="/betreiber">Plattform</a>{{end}}
<form method="post" action="/logout" style="display:inline">
<button type="submit">Abmelden</button>
</form>

View File

@@ -3,21 +3,17 @@
<head>{{template "head" .}}</head>
<body>
<div class="page">
<h1>Registrieren</h1>
<h1>Firma registrieren</h1>
<p class="hinweis">
Testzugang ist sofort aktiv. Der Bezahlbetrieb wird nach Prüfung durch
den Betreiber freigeschaltet.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<form method="post" action="/register">
<label for="account_name">Name (Creator, Agentur, Marke oder Kanzlei)</label>
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
<label for="role">Rolle</label>
<select id="role" name="role" required>
<option value="creator">Creator</option>
<option value="agentur">Agentur</option>
<option value="marke">Marke</option>
<option value="kanzlei">Kanzlei</option>
</select>
<label for="email">E-Mail</label>
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort (mind. 8 Zeichen)</label>

View File

@@ -1,35 +0,0 @@
{{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}}
{{if .CanArchive}}
<form hx-post="/veroeffentlichen" hx-target="#ergebnis" hx-swap="innerHTML">
<input type="hidden" name="submission_id" value="{{.SubmissionID}}">
<button type="submit">Als veröffentlicht markieren &amp; archivieren</button>
</form>
{{end}}
<p class="disclaimer">
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
Prüfung im Einzelfall.
</p>
{{end}}

View File

@@ -1,41 +0,0 @@
{{define "verbindungen"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<h1>Verbindungen</h1>
<p class="hinweis">
Verbinde deinen eigenen Instagram- oder TikTok-Account, damit die
Beweissicherung veröffentlichte Beiträge künftig direkt abrufen kann.
Ohne Verbindung funktioniert die Prüfung wie gewohnt mit manuellem
Screenshot-Upload.
</p>
<ul class="beteiligte">
{{range .Connections}}
<li class="beteiligter">
<div class="beteiligter-kopf">
<strong>{{.Platform}}</strong>
{{if .Connected}}
<span class="status status-published">verbunden seit {{.ConnectedAt}}</span>
{{else if .Configured}}
<span class="status status-mittel">nicht verbunden</span>
{{else}}
<span class="status">noch nicht konfiguriert</span>
{{end}}
</div>
{{if .Connected}}
<form method="post" action="/verbindungen/{{.Platform}}/trennen">
<button type="submit" class="entfernen">Trennen</button>
</form>
{{else if .Configured}}
<p><a href="/oauth/{{.Platform}}/start">Verbinden</a></p>
{{end}}
</li>
{{end}}
</ul>
</div>
</body>
</html>
{{end}}