Compare commits

...

3 Commits

Author SHA1 Message Date
noroot
790ab20651 feat: Standbild-Upload bei der Pre-Publish-Prüfung
CLAUDE.md beschreibt die Prüfung seit dem ersten Commit als "Caption,
Standbild und Vertragslage rein" — bisher wurde nur die Caption
verarbeitet, das asset-Schema aus Migration 0001 blieb ungenutzt.

- internal/store/asset.go: CreateAsset/GetLatestAssetForSubmission.
  Migration 0005 macht asset append-only (Trigger fehlte seit 0001,
  weil bis jetzt nichts hineinschrieb) — ein hochgeladenes Beweisstück
  wird nicht nachträglich ausgetauscht, aus demselben Grund wie bei
  extraction/finding/evidence_package.
- handleCheck liest ein optionales "standbild"-Formularfeld (Bild-
  Upload, max. 8 MiB, Content-Type muss image/* sein), validiert es
  VOR dem Anlegen der Submission (ein ungültiger Upload hinterlässt so
  keine leere Beitrags-Zeile), speichert es danach unter ASSET_DIR und
  legt die Asset-Zeile an.
- handleArchive bindet den Asset-Hash (falls vorhanden) in den
  Metadaten-Hash und ins PDF-Dossier ein (dossier.Data.AssetHash war
  bereits vorbereitet, wurde aber nie befüllt).
- index.html: Formular auf multipart/form-data umgestellt
  (hx-encoding + enctype), neues optionales Dateifeld. handleCheck
  bleibt abwärtskompatibel zu urlencoded-Requests (ParseMultipartForm
  liefert ErrNotMultipart, das wird wie "kein Bild hochgeladen"
  behandelt, nicht wie ein Fehler).
- ASSET_DIR neue Konfigurationsvariable (Default "assets", wie
  DOSSIER_DIR relativ zu WorkingDirectory=/var/lib/deklarix — kein
  postinst-Healing nötig, anders als bei RULES_DIR, dessen Default
  nicht zum installierten Pfad passt).

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Upload, Hash in DB, Hash im
erzeugten PDF via pdftotext, Ablehnung bei falschem Dateityp).
2026-08-27 21:45:03 +02:00
noroot
ae59e745c0 fix: Admin nach Login direkt zum Admin-Bereich leiten
Ein Admin-Login hat kein eigenes Produkt zu prüfen — die Pre-Publish-
Prüfung ("/") ist die Startseite für Mandanten (Creator/Agentur/Marke/
Kanzlei). Nutzer hat nach dem Login zu Recht gefragt, wieso ein Admin
dieses Formular sieht. Login leitet role=admin jetzt direkt zu /admin,
alle anderen Rollen weiterhin zu "/".
2026-08-27 19:06:05 +02:00
noroot
8e5d06aafb fix: Admin-Link in der Navigation zeigen
Nach dem Login landet jeder Nutzer (auch ein Admin, jeder Login gehört
zu einem Account) auf der normalen Startseite — ohne einen Link zu
/admin in der Navigation war der Admin-Bereich für einen Admin ohne
die URL im Kopf praktisch unerreichbar (genau das hat der Nutzer nach
dem Login gemeldet).

navData{IsAdmin} wird jetzt von jeder angemeldeten Seite (Start,
Beiträge, Beitrag-Detail, alle Admin-Seiten) an den gemeinsamen
"nav"-Template-Block durchgereicht; der Link erscheint nur für
role=admin. Test deckt beide Fälle ab (Admin sieht den Link, Mandant
nicht).
2026-08-27 19:04:04 +02:00
24 changed files with 629 additions and 31 deletions

View File

@@ -167,7 +167,10 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
Account wie verändert); append-only aus demselben Grund wie Account wie verändert); append-only aus demselben Grund wie
`finding`/`extraction`/`evidence_package` `finding`/`extraction`/`evidence_package`
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte - `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
- `asset` — Bild oder Datei, Pfad, SHA-256 - `asset` — hochgeladenes Standbild (optional bei der Prüfung), Pfad,
SHA-256; append-only aus demselben Grund wie `finding`/`extraction`/
`evidence_package` — ein Beweisstück wird nicht nachträglich
ausgetauscht
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version - `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere, - `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert, Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
@@ -178,7 +181,7 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur (`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben) Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
**Append-only.** Kein UPDATE auf `finding`, `extraction`, **Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`,
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit `evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man
Zeilen ändern kann, ist keines mehr. Zeilen ändern kann, ist keines mehr.
@@ -393,7 +396,7 @@ sudo systemctl start deklarix
sudo systemctl status deklarix sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR, # Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, TSA_URL) # DOSSIER_DIR, ASSET_DIR, TSA_URL)
# Logs prüfen # Logs prüfen
journalctl -u deklarix -f journalctl -u deklarix -f

View File

@@ -48,7 +48,12 @@ func main() {
dossierDir = "dossiers" dossierDir = "dossiers"
} }
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir) assetDir := os.Getenv("ASSET_DIR")
if assetDir == "" {
assetDir = "assets"
}
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir)
if err != nil { if err != nil {
log.Fatalf("web server: %v", err) log.Fatalf("web server: %v", err)
} }

65
internal/store/asset.go Normal file
View File

@@ -0,0 +1,65 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Asset ist eine zu einem Beitrag hochgeladene Datei (aktuell: das
// Standbild/der Screenshot). Append-only wie extraction/finding/
// evidence_package — ein hochgeladenes Beweisstück wird nicht
// nachträglich ausgetauscht, siehe Migration.
type Asset struct {
ID string
SubmissionID string
Kind string
Path string
SHA256 string
CreatedAt time.Time
}
// CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte
// SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form,
// in der evidence_package.SHA256 seinen Hash speichert.
func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (Asset, error) {
var a Asset
err := s.Pool.QueryRow(ctx, `
INSERT INTO asset (submission_id, kind, path, sha256)
VALUES ($1, $2, $3, $4)
RETURNING id, submission_id, kind, path, sha256, created_at
`, submissionID, kind, path, sha256Hex).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
)
if err != nil {
return Asset{}, fmt.Errorf("store: create asset: %w", err)
}
return a, nil
}
// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset
// eines Beitrags. Liefert ErrNotFound, wenn kein Asset hochgeladen wurde
// — das ist der Normalfall (ein Standbild ist optional), kein Fehler,
// den Aufrufer wie einen echten Datenbankfehler behandeln sollten.
func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) {
var a Asset
err := s.Pool.QueryRow(ctx, `
SELECT id, submission_id, kind, path, sha256, created_at
FROM asset
WHERE submission_id = $1
ORDER BY created_at DESC
LIMIT 1
`, submissionID).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Asset{}, ErrNotFound
}
if err != nil {
return Asset{}, fmt.Errorf("store: get latest asset: %w", err)
}
return a, nil
}

View File

@@ -0,0 +1,99 @@
package store_test
import (
"context"
"errors"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func TestAssetCreateAndGetLatest(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
if a.SubmissionID != sub.ID || a.Kind != "image" {
t.Fatalf("CreateAsset = %+v, unerwartete Werte", a)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != a.ID || got.SHA256 != "deadbeef" {
t.Fatalf("GetLatestAssetForSubmission = %+v, want %+v", got, a)
}
}
func TestGetLatestAssetForSubmissionNotFoundWhenNoneUploaded(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
_, err = s.GetLatestAssetForSubmission(ctx, sub.ID)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("err = %v, want store.ErrNotFound", err)
}
}
func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/erstes.jpg", "erstehash"); err != nil {
t.Fatalf("CreateAsset (1): %v", err)
}
second, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/zweites.jpg", "zweitehash")
if err != nil {
t.Fatalf("CreateAsset (2): %v", err)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != second.ID {
t.Fatalf("expected the newest asset, got %+v", got)
}
}
func TestAssetIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "feed", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/x.jpg", "hash")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
_, err = s.Pool.Exec(ctx, `UPDATE asset SET sha256 = 'geaendert' WHERE id = $1`, a.ID)
if err == nil {
t.Fatal("expected UPDATE on asset to be rejected by the append-only trigger")
}
_, err = s.Pool.Exec(ctx, `DELETE FROM asset WHERE id = $1`, a.ID)
if err == nil {
t.Fatal("expected DELETE on asset to be rejected by the append-only trigger")
}
}

View File

@@ -0,0 +1 @@
DROP TRIGGER asset_append_only ON asset;

View File

@@ -0,0 +1,9 @@
-- asset war in 0001 ohne append-only-Trigger angelegt, weil bis jetzt
-- nichts Assets tatsächlich schrieb. Ein hochgeladenes Standbild ist
-- Teil der Beweiskette (SHA-256, siehe CLAUDE.md) genau wie extraction/
-- finding/evidence_package — es nachträglich austauschen zu können,
-- würde denselben Grund unterlaufen, aus dem diese Tabellen append-only
-- sind.
CREATE TRIGGER asset_append_only
BEFORE UPDATE OR DELETE ON asset
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();

View File

@@ -34,6 +34,7 @@ func (s *Server) handlePublicKanzleiList(w http.ResponseWriter, r *http.Request)
type adminDashboardData struct { type adminDashboardData struct {
Title string Title string
Nav navData
AccountCount int AccountCount int
UnverifiedCount int UnverifiedCount int
RecentAuditCount int RecentAuditCount int
@@ -61,7 +62,7 @@ func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
} }
data := adminDashboardData{ data := adminDashboardData{
Title: "Admin", AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog), Title: "Admin", Nav: navFor(r), AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog),
} }
if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil { if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
@@ -77,6 +78,7 @@ type adminAccountListItem struct {
type adminAccountListData struct { type adminAccountListData struct {
Title string Title string
Nav navData
Accounts []adminAccountListItem Accounts []adminAccountListItem
} }
@@ -87,7 +89,7 @@ func (s *Server) handleAdminAccountList(w http.ResponseWriter, r *http.Request)
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := adminAccountListData{Title: "Accounts"} data := adminAccountListData{Title: "Accounts", Nav: navFor(r)}
for _, a := range accounts { for _, a := range accounts {
data.Accounts = append(data.Accounts, adminAccountListItem{ data.Accounts = append(data.Accounts, adminAccountListItem{
ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
@@ -105,6 +107,7 @@ type adminUserView struct {
type adminAccountDetailData struct { type adminAccountDetailData struct {
Title string Title string
Nav navData
AccountID string AccountID string
Name string Name string
Verified bool Verified bool
@@ -130,7 +133,7 @@ func (s *Server) handleAdminAccountDetail(w http.ResponseWriter, r *http.Request
} }
data := adminAccountDetailData{ data := adminAccountDetailData{
Title: "Account", AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified, Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified,
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"), CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
} }
for _, u := range users { for _, u := range users {
@@ -190,6 +193,7 @@ type adminAuditEntryView struct {
type adminAuditLogData struct { type adminAuditLogData struct {
Title string Title string
Nav navData
Entries []adminAuditEntryView Entries []adminAuditEntryView
} }
@@ -200,7 +204,7 @@ func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return return
} }
data := adminAuditLogData{Title: "Audit-Log"} data := adminAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
for _, e := range entries { for _, e := range entries {
data.Entries = append(data.Entries, adminAuditEntryView{ data.Entries = append(data.Entries, adminAuditEntryView{
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action, CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,

View File

@@ -40,6 +40,28 @@ func TestAdminDashboardAccessibleForAdmin(t *testing.T) {
} }
} }
// 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) { func TestAdminAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
fs := newFakeStore() fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs) s := newServer(t, fakeExtractor{}, fs)

View File

@@ -18,6 +18,7 @@ type submissionListItem struct {
type submissionListData struct { type submissionListData struct {
Title string Title string
Nav navData
Submissions []submissionListItem Submissions []submissionListItem
} }
@@ -31,7 +32,7 @@ func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
return return
} }
data := submissionListData{Title: "Beiträge"} data := submissionListData{Title: "Beiträge", Nav: navFor(r)}
for _, sum := range summaries { for _, sum := range summaries {
data.Submissions = append(data.Submissions, submissionListItem{ data.Submissions = append(data.Submissions, submissionListItem{
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status, ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
@@ -56,6 +57,7 @@ type participantView struct {
type submissionDetailData struct { type submissionDetailData struct {
Title string Title string
Nav navData
SubmissionID string SubmissionID string
Platform string Platform string
PostType string PostType string
@@ -125,7 +127,7 @@ func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request)
} }
data := submissionDetailData{ data := submissionDetailData{
Title: "Beitrag", SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType, 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"), Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published", CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants), DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),

View File

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

View File

@@ -96,6 +96,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"}) s.renderAuthPage(w, "login", authPageData{Title: "Anmelden", Error: "Sitzung konnte nicht gestartet werden"})
return 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)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }

View File

@@ -1,19 +1,90 @@
package web package web
import ( import (
"context"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/netcell-it/deklarix/internal/dossier" "github.com/netcell-it/deklarix/internal/dossier"
"github.com/netcell-it/deklarix/internal/evidence" "github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/store"
) )
// maxAssetSize begrenzt ein hochgeladenes Standbild auf 8 MiB — genug für
// einen Screenshot, nicht genug, um den Server mit Uploads zu fluten.
const maxAssetSize = 8 << 20
// uploadedAsset ist ein bereits gelesenes und geprüftes Standbild, das
// nach dem Anlegen der Submission (die submission_id als Fremdschlüssel
// braucht) tatsächlich gespeichert wird. Getrennt von storeAsset, damit
// ein ungültiger Upload (falscher Typ, zu groß) *vor* dem Anlegen der
// Submission scheitert, statt eine Beitrags-Zeile ohne Asset zu hinterlassen.
type uploadedAsset struct {
data []byte
extension string
sha256Hex string
}
// readUploadedAsset liest das optionale "standbild"-Feld. Liefert
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
file, header, err := r.FormFile("standbild")
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile.
if errors.Is(err, http.ErrMissingFile) || errors.Is(err, http.ErrNotMultipart) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err)
}
defer file.Close()
if !strings.HasPrefix(header.Header.Get("Content-Type"), "image/") {
return nil, fmt.Errorf("nur Bilddateien sind als Standbild erlaubt")
}
data, err := io.ReadAll(io.LimitReader(file, maxAssetSize+1))
if err != nil {
return nil, fmt.Errorf("Standbild konnte nicht gelesen werden: %w", err)
}
if len(data) > maxAssetSize {
return nil, fmt.Errorf("Standbild ist zu groß (max. %d MB)", maxAssetSize/(1<<20))
}
ext := filepath.Ext(header.Filename)
if ext == "" {
ext = ".bin"
}
return &uploadedAsset{data: data, extension: ext, sha256Hex: hex.EncodeToString(evidence.HashBytes(data))}, nil
}
// storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und
// speichert die Asset-Zeile.
func (s *Server) storeAsset(ctx context.Context, submissionID string, ua *uploadedAsset) error {
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
}
path := filepath.Join(s.assetDir, submissionID+ua.extension)
if err := os.WriteFile(path, ua.data, 0o640); err != nil {
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err)
}
if _, err := s.store.CreateAsset(ctx, submissionID, "image", path, ua.sha256Hex); err != nil {
return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
}
return nil
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`) fmt.Fprint(w, `{"ok":true}`)
@@ -21,10 +92,12 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
type indexData struct { type indexData struct {
Title string Title string
Nav navData
} }
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if err := s.templates.ExecuteTemplate(w, "index", indexData{Title: "Pre-Publish-Prüfung"}); err != nil { data := indexData{Title: "Pre-Publish-Prüfung", Nav: navFor(r)}
if err := s.templates.ExecuteTemplate(w, "index", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
} }
} }
@@ -52,8 +125,14 @@ type resultData struct {
// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im // veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im
// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2). // Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2).
func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
http.Error(w, "ungültiges Formular", http.StatusBadRequest) // 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 return
} }
@@ -66,6 +145,12 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
asset, err := s.readUploadedAsset(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx := r.Context() ctx := r.Context()
accountID := currentUser(r).AccountID accountID := currentUser(r).AccountID
@@ -86,6 +171,13 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
if asset != nil {
if err := s.storeAsset(ctx, sub.ID, asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion) ext, err := s.store.CreateExtraction(ctx, sub.ID, result.RawJSON, s.extractor.ModelVersion(), extract.PromptVersion)
if err != nil { if err != nil {
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
@@ -180,6 +272,24 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
} }
} }
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 { metadataHash, err := evidence.HashMetadata(struct {
SubmissionID string SubmissionID string
Platform string Platform string
@@ -187,7 +297,8 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
Caption string Caption string
Facts rules.Facts Facts rules.Facts
Findings []rules.Finding Findings []rules.Finding
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings}) AssetSHA256Hex string
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings, assetSHA256Hex})
if err != nil { if err != nil {
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return return
@@ -217,6 +328,7 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
}, },
Facts: facts, Facts: facts,
Findings: dossierFindings, Findings: dossierFindings,
AssetHash: assetHash,
MetadataHash: metadataHash, MetadataHash: metadataHash,
TimestampToken: timestampToken, TimestampToken: timestampToken,
GeneratedAt: time.Now(), GeneratedAt: time.Now(),

View File

@@ -88,6 +88,21 @@ 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.
type navData struct {
IsAdmin bool
}
// navFor liefert die Nav-Daten für den angemeldeten Nutzer der Anfrage.
// Nur für Seiten hinter requirePage/requireAdmin aufrufbar (braucht
// currentUser).
func navFor(r *http.Request) navData {
return navData{IsAdmin: currentUser(r).Role == "admin"}
}
// currentUser liest den Nutzer, den requirePage/requireAPI in den // currentUser liest den Nutzer, den requirePage/requireAPI in den
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine // Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler, // dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,

View File

@@ -70,6 +70,8 @@ type Store interface {
DeleteSession(ctx context.Context, token string) error DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
} }
// Server bündelt Routing und Abhängigkeiten der Web-Schicht. // Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -80,13 +82,15 @@ type Server struct {
store Store store Store
timestamper evidence.Timestamper timestamper evidence.Timestamper
dossierDir string dossierDir string
assetDir string
templates *template.Template templates *template.Template
} }
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird // NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das // einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden. // Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) { // assetDir das Verzeichnis für hochgeladene Standbilder.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string) (*Server, error) {
tmpl, err := template.ParseFS(templatesFS, "templates/*.html") tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err) return nil, fmt.Errorf("web: templates parsen: %w", err)
@@ -98,6 +102,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
store: st, store: st,
timestamper: timestamper, timestamper: timestamper,
dossierDir: dossierDir, dossierDir: dossierDir,
assetDir: assetDir,
templates: tmpl, templates: tmpl,
} }

View File

@@ -68,6 +68,7 @@ type fakeStore struct {
evidencePkgs map[string]store.EvidencePackage evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant participants map[string]store.Participant
auditLog []store.AuditEntry auditLog []store.AuditEntry
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset
} }
func newFakeStore() *fakeStore { func newFakeStore() *fakeStore {
@@ -81,6 +82,7 @@ func newFakeStore() *fakeStore {
findings: map[string][]store.Finding{}, findings: map[string][]store.Finding{},
evidencePkgs: map[string]store.EvidencePackage{}, evidencePkgs: map[string]store.EvidencePackage{},
participants: map[string]store.Participant{}, participants: map[string]store.Participant{},
assets: map[string]store.Asset{},
} }
} }
@@ -342,6 +344,26 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
return pkg, nil return pkg, nil
} }
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a := store.Asset{
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
}
f.assets[submissionID] = a
return a, nil
}
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
a, ok := f.assets[submissionID]
if !ok {
return store.Asset{}, store.ErrNotFound
}
return a, nil
}
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) { func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -500,7 +522,7 @@ func loadRealRules(t *testing.T) []rules.Rule {
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server { func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
t.Helper() t.Helper()
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir()) s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir())
if err != nil { if err != nil {
t.Fatalf("NewServer: %v", err) t.Fatalf("NewServer: %v", err)
} }
@@ -683,6 +705,30 @@ func TestLoginWithCorrectPassword(t *testing.T) {
} }
} }
func TestLoginRedirectsAdminToAdminDashboard(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs)
hash, err := auth.HashPassword("admin-passwort")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
acc, err := fs.CreateAccount(context.Background(), "Deklarix Admin")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := fs.CreateUser(context.Background(), acc.ID, "admin@example.com", hash, "admin"); err != nil {
t.Fatalf("CreateUser: %v", err)
}
resp := postForm(t, s, nil, "/login", url.Values{"email": {"admin@example.com"}, "password": {"admin-passwort"}})
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
if loc := resp.Header().Get("Location"); loc != "/admin" {
t.Fatalf("Location = %q, want /admin for an admin login", loc)
}
}
func TestLoginRejectsWrongPassword(t *testing.T) { func TestLoginRejectsWrongPassword(t *testing.T) {
fs := newFakeStore() fs := newFakeStore()
s := newServer(t, fakeExtractor{}, fs) s := newServer(t, fakeExtractor{}, fs)

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<p><a href="/admin/accounts">&larr; Alle Accounts</a></p> <p><a href="/admin/accounts">&larr; Alle Accounts</a></p>
<h1>{{.Name}}</h1> <h1>{{.Name}}</h1>

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<p><a href="/admin">&larr; Admin</a></p> <p><a href="/admin">&larr; Admin</a></p>
<h1>Accounts</h1> <h1>Accounts</h1>

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<p><a href="/admin">&larr; Admin</a></p> <p><a href="/admin">&larr; Admin</a></p>
<h1>Audit-Log</h1> <h1>Audit-Log</h1>

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<h1>Admin</h1> <h1>Admin</h1>
<ul class="admin-kacheln"> <ul class="admin-kacheln">

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<h1>Beiträge</h1> <h1>Beiträge</h1>

View File

@@ -2,7 +2,7 @@
<html lang="de"> <html lang="de">
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<p><a href="/beitraege">&larr; Alle Beiträge</a></p> <p><a href="/beitraege">&larr; Alle Beiträge</a></p>
<h1>{{.Platform}} · {{.PostType}}</h1> <h1>{{.Platform}} · {{.PostType}}</h1>

View File

@@ -2,12 +2,12 @@
<html lang="de"> <html lang="de">
<head>{{template "head" .}}</head> <head>{{template "head" .}}</head>
<body> <body>
{{template "nav" .}} {{template "nav" .Nav}}
<div class="page"> <div class="page">
<h1>Pre-Publish-Prüfung</h1> <h1>Pre-Publish-Prüfung</h1>
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p> <p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
<form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML"> <form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML" hx-encoding="multipart/form-data" enctype="multipart/form-data">
<label for="platform">Plattform</label> <label for="platform">Plattform</label>
<select id="platform" name="platform" required> <select id="platform" name="platform" required>
<option value="instagram">Instagram</option> <option value="instagram">Instagram</option>
@@ -38,6 +38,13 @@
<label for="caption">Caption</label> <label for="caption">Caption</label>
<textarea id="caption" name="caption" rows="6" required></textarea> <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> <button type="submit">Prüfen</button>
</form> </form>

View File

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

View File

@@ -13,6 +13,10 @@ RULES_DIR=/usr/share/deklarix/rules
# Archivieren eines Beitrags automatisch angelegt. # Archivieren eines Beitrags automatisch angelegt.
DOSSIER_DIR=/var/lib/deklarix/dossiers DOSSIER_DIR=/var/lib/deklarix/dossiers
# Wo hochgeladene Standbilder abgelegt werden. Wird bei der
# Pre-Publish-Prüfung automatisch angelegt.
ASSET_DIR=/var/lib/deklarix/assets
# RFC-3161-Zeitstempeldienst. Leer = FreeTSA.org (frei, aber NICHT # RFC-3161-Zeitstempeldienst. Leer = FreeTSA.org (frei, aber NICHT
# eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem # eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen. # Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.