Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790ab20651 | ||
|
|
ae59e745c0 | ||
|
|
8e5d06aafb |
@@ -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
|
||||||
|
|||||||
@@ -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
65
internal/store/asset.go
Normal 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
|
||||||
|
}
|
||||||
99
internal/store/asset_test.go
Normal file
99
internal/store/asset_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TRIGGER asset_append_only ON asset;
|
||||||
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal file
9
internal/store/migrations/0005_asset_append_only.up.sql
Normal 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();
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
191
internal/web/asset_handlers_test.go
Normal file
191
internal/web/asset_handlers_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">← Alle Accounts</a></p>
|
<p><a href="/admin/accounts">← Alle Accounts</a></p>
|
||||||
<h1>{{.Name}}</h1>
|
<h1>{{.Name}}</h1>
|
||||||
|
|||||||
@@ -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">← Admin</a></p>
|
<p><a href="/admin">← Admin</a></p>
|
||||||
<h1>Accounts</h1>
|
<h1>Accounts</h1>
|
||||||
|
|||||||
@@ -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">← Admin</a></p>
|
<p><a href="/admin">← Admin</a></p>
|
||||||
<h1>Audit-Log</h1>
|
<h1>Audit-Log</h1>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">← Alle Beiträge</a></p>
|
<p><a href="/beitraege">← Alle Beiträge</a></p>
|
||||||
<h1>{{.Platform}} · {{.PostType}}</h1>
|
<h1>{{.Platform}} · {{.PostType}}</h1>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user