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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
139
internal/web/betreiber_handlers.go
Normal file
139
internal/web/betreiber_handlers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, `"`)]
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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">← 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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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">← 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 & 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}}
|
||||
@@ -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}}
|
||||
22
internal/web/templates/betreiber_account_detail.html
Normal file
22
internal/web/templates/betreiber_account_detail.html
Normal 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">← 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}}
|
||||
@@ -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">← Admin</a></p>
|
||||
<p><a href="/betreiber">← 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}}
|
||||
@@ -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">← Admin</a></p>
|
||||
<p><a href="/betreiber">← Plattform</a></p>
|
||||
<h1>Audit-Log</h1>
|
||||
{{if not .Entries}}
|
||||
<p class="hinweis">Noch keine Einträge.</p>
|
||||
15
internal/web/templates/betreiber_dashboard.html
Normal file
15
internal/web/templates/betreiber_dashboard.html
Normal 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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & archivieren</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<p class="disclaimer">
|
||||
Diese Prüfung ist keine Rechtsberatung und ersetzt keine anwaltliche
|
||||
Prüfung im Einzelfall.
|
||||
</p>
|
||||
{{end}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user