Ausgangspunkt: Instagram hält Story-Insights nach eigener Aussage nur
24 Stunden vor, auch der offizielle Datenexport enthält sie nicht mehr
danach. Der Standbild-Screenshot beim Prüfen entsteht direkt beim
Veröffentlichen, bevor nennenswerte Kennzahlen existieren — er kann
das strukturell nicht auffangen. Eine OAuth-Anbindung allein löst das
auch nicht: selbst mit API-Zugriff bräuchte es einen Abruf innerhalb
desselben 24h-Fensters.
- Migration 0007: asset.purpose ('initial' | 'insights', Default
'initial' erhält die Bedeutung aller Bestandszeilen). Ein Beitrag
kann jetzt mehrere Insights-Nachweise über die Zeit bekommen.
GetLatestAssetForSubmission berücksichtigt weiterhin nur 'initial',
damit ein späterer Insights-Upload nie den beim Archivieren
referenzierten Original-Screenshot verdrängt.
- internal/web/insights_reminder.go: computeInsightsReminder — reine,
ungetestete gegen echte Instagram-Daten, aber isoliert testbare
Logik fürs Erinnerungs-Timing (Produktentscheidung, keine Rechtsnorm,
daher nicht in rules/*.yaml).
- GET /beitraege/{id} zeigt die Erinnerung bei veröffentlichten
"story"-Beiträgen ohne existierendes insights-Asset; POST
/beitraege/{id}/insights speichert einen weiteren Screenshot (gleiche
Validierung wie das initiale Standbild, wiederverwendet über
readUploadedAsset/storeAsset mit purpose-Parameter).
- Bewusst nur In-App-Banner in dieser Ausbaustufe, kein Mail-/Push-
Versand — dafür fehlt aktuell ein SMTP-Relay/Versanddienst, siehe
CLAUDE.md-Hinweis dazu.
Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Story archivieren → Erinnerung
sichtbar → Insights-Upload → Erinnerung verschwindet, Nachweis
gelistet, Mandantentrennung beim Upload durchgesetzt).
158 lines
8.1 KiB
Go
158 lines
8.1 KiB
Go
// Package web ist die HTTP-Schicht: Routing, Templates, Handler.
|
|
// Bewusst html/template + htmx, kein Frontend-Build (siehe CLAUDE.md,
|
|
// Stack). Jede Submission gehört einem Account (Mandant); Auth-Cookies
|
|
// binden einen Request an einen angemeldeten Nutzer und damit an dessen
|
|
// Account — siehe middleware.go.
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/netcell-it/deklarix/internal/evidence"
|
|
"github.com/netcell-it/deklarix/internal/extract"
|
|
"github.com/netcell-it/deklarix/internal/rules"
|
|
"github.com/netcell-it/deklarix/internal/socialconnect"
|
|
"github.com/netcell-it/deklarix/internal/store"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// Extractor ist die Schnittstelle, die der Server für Stufe 1 braucht.
|
|
// *extract.Engine erfüllt sie (regelbasiert, kein externer Dienst);
|
|
// Tests injizieren einen Fake, um Facts unabhängig von der echten
|
|
// Erkennungslogik vorzugeben.
|
|
type Extractor interface {
|
|
Extract(ctx context.Context, in extract.Input) (extract.Result, error)
|
|
ModelVersion() string
|
|
}
|
|
|
|
// Store ist die Schnittstelle, die der Server zur Persistenz braucht —
|
|
// bewusst schmal (nur was diese Handler tatsächlich nutzen), nicht der
|
|
// komplette *store.Store. *store.Store erfüllt sie; Tests injizieren
|
|
// einen Fake statt eine echte Datenbank zu brauchen.
|
|
type Store interface {
|
|
CreateSubmission(ctx context.Context, accountID, platform, postType, caption string) (store.Submission, error)
|
|
GetSubmission(ctx context.Context, id string) (store.Submission, error)
|
|
SetSubmissionStatus(ctx context.Context, id, status string) error
|
|
CreateExtraction(ctx context.Context, submissionID string, payload []byte, modelVersion, promptVersion string) (store.Extraction, error)
|
|
GetLatestExtraction(ctx context.Context, submissionID string) (store.Extraction, error)
|
|
CreateFinding(ctx context.Context, submissionID string, extractionID *string, ruleID string, ruleVersion int, severity, title, fix string, sources []string) (store.Finding, error)
|
|
ListCurrentFindings(ctx context.Context, submissionID string) ([]store.Finding, error)
|
|
CreateEvidencePackage(ctx context.Context, submissionID, dossierPath, sha256Hex string, timestampToken []byte) (store.EvidencePackage, error)
|
|
GetLatestEvidencePackage(ctx context.Context, submissionID string) (store.EvidencePackage, error)
|
|
ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error)
|
|
|
|
CreateParticipant(ctx context.Context, submissionID, role, name string, vorgegeben, freigegeben bool) (store.Participant, error)
|
|
ListParticipants(ctx context.Context, submissionID string) ([]store.Participant, error)
|
|
GetParticipant(ctx context.Context, id string) (store.Participant, error)
|
|
UpdateParticipant(ctx context.Context, id string, vorgegeben, freigegeben bool) (store.Participant, error)
|
|
DeleteParticipant(ctx context.Context, id string) error
|
|
|
|
CreateAccount(ctx context.Context, name string) (store.Account, error)
|
|
GetAccount(ctx context.Context, id string) (store.Account, error)
|
|
ListAccounts(ctx context.Context) ([]store.Account, error)
|
|
ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error)
|
|
SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error)
|
|
CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error)
|
|
GetUserByEmail(ctx context.Context, email string) (store.User, error)
|
|
GetUser(ctx context.Context, id string) (store.User, error)
|
|
ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error)
|
|
CreateSession(ctx context.Context, token, userID string, expiresAt time.Time) (store.Session, error)
|
|
GetSession(ctx context.Context, token string) (store.Session, error)
|
|
DeleteSession(ctx context.Context, token string) error
|
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
|
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
|
CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error)
|
|
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
|
|
ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error)
|
|
|
|
UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error)
|
|
ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error)
|
|
DeletePlatformConnection(ctx context.Context, accountID, platform string) error
|
|
}
|
|
|
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
|
type Server struct {
|
|
mux *http.ServeMux
|
|
extractor Extractor
|
|
ruleSet []rules.Rule
|
|
store Store
|
|
timestamper evidence.Timestamper
|
|
dossierDir string
|
|
assetDir string
|
|
connectors map[string]socialconnect.Connector
|
|
templates *template.Template
|
|
}
|
|
|
|
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
|
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
|
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
|
|
// assetDir das Verzeichnis für hochgeladene Standbilder. connectors
|
|
// enthält nur die Plattformen, für die echte Client-Credentials
|
|
// konfiguriert sind (siehe cmd/deklarix/main.go) — eine leere oder nil
|
|
// Map ist gültig, dann zeigt /verbindungen "nicht konfiguriert" statt
|
|
// eines Verbinden-Buttons.
|
|
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string, connectors map[string]socialconnect.Connector) (*Server, error) {
|
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
|
}
|
|
|
|
s := &Server{
|
|
extractor: extractor,
|
|
ruleSet: ruleSet,
|
|
store: st,
|
|
timestamper: timestamper,
|
|
dossierDir: dossierDir,
|
|
assetDir: assetDir,
|
|
connectors: connectors,
|
|
templates: tmpl,
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /health", s.handleHealth)
|
|
mux.HandleFunc("GET /register", s.handleRegisterForm)
|
|
mux.HandleFunc("POST /register", s.handleRegister)
|
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
|
mux.HandleFunc("POST /login", s.handleLogin)
|
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
|
mux.HandleFunc("GET /{$}", s.requirePage(s.handleIndex))
|
|
mux.HandleFunc("POST /pruefen", s.requireAPI(s.handleCheck))
|
|
mux.HandleFunc("POST /veroeffentlichen", s.requireAPI(s.handleArchive))
|
|
mux.HandleFunc("GET /dossier/{id}", s.requireAPI(s.handleDossierDownload))
|
|
mux.HandleFunc("GET /beitraege", s.requirePage(s.handleSubmissionList))
|
|
mux.HandleFunc("GET /beitraege/{id}", s.requirePage(s.handleSubmissionDetail))
|
|
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
|
|
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
|
|
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
|
|
mux.HandleFunc("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset))
|
|
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
|
|
mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList))
|
|
mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart))
|
|
mux.HandleFunc("GET /oauth/{platform}/callback", s.requirePage(s.handleOAuthCallback))
|
|
mux.HandleFunc("POST /verbindungen/{platform}/trennen", s.requireAPI(s.handleDisconnect))
|
|
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard))
|
|
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
|
|
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))
|
|
mux.HandleFunc("POST /admin/accounts/{id}/verifizieren", s.requireAdmin(s.handleAdminSetVerified))
|
|
mux.HandleFunc("GET /admin/audit-log", s.requireAdmin(s.handleAdminAuditLog))
|
|
mux.Handle("GET /static/", http.FileServerFS(staticFS))
|
|
s.mux = mux
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// ServeHTTP macht Server zu einem http.Handler.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
s.mux.ServeHTTP(w, r)
|
|
}
|