Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5813e6209c | ||
|
|
790ab20651 | ||
|
|
ae59e745c0 | ||
|
|
8e5d06aafb | ||
|
|
835ad9f0a7 |
81
CLAUDE.md
81
CLAUDE.md
@@ -112,6 +112,7 @@ Managed Postgres in der EU (DSGVO).
|
||||
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
||||
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
||||
│ ├── dossier/ # PDF-Erzeugung
|
||||
│ ├── socialconnect/ # OAuth-Flow Instagram/TikTok (Plattform-Verbindung)
|
||||
│ ├── store/ # Postgres, Migrationen
|
||||
│ └── web/ # Handler, Templates
|
||||
├── rules/ # YAML-Regeln, versioniert im Git
|
||||
@@ -139,14 +140,38 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
|
||||
`session`), mehr braucht der MVP nicht:
|
||||
|
||||
- `account` — ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
||||
eigene Organisation); jede Submission gehört genau einem Account
|
||||
eigene Organisation); jede Submission gehört genau einem Account.
|
||||
`verified` markiert einen Kanzlei-Account als für das öffentliche,
|
||||
kostenlose Kanzlei-Verzeichnis (`GET /kanzleien`) freigegeben — nur
|
||||
vom Admin-Bereich aus setzbar, nie vom Mandanten selbst
|
||||
- `app_user` — ein Login innerhalb eines Accounts (E-Mail, Passwort-
|
||||
Hash, Rolle)
|
||||
Hash, Rolle). Rolle ist eine von `creator`, `agentur`, `marke`,
|
||||
`kanzlei` **oder `admin`**. `admin` ist Betreiber-Personal
|
||||
(Netcell-IT), nicht an einen Mandanten-Geschäftszweck gebunden,
|
||||
zuständig für den Admin-Bereich (`/admin/...`: Accounts-Übersicht,
|
||||
Kanzlei-Verzeichnis-Freigabe, Audit-Log). Es gibt **keine**
|
||||
Selbstregistrierung für `admin` über `POST /register` (das Formular
|
||||
bietet die Rolle nicht an) — der erste Admin wird einmalig per SQL
|
||||
angelegt:
|
||||
```sql
|
||||
INSERT INTO account (name) VALUES ('Deklarix Admin') RETURNING id;
|
||||
INSERT INTO app_user (account_id, email, password_hash, role)
|
||||
VALUES ('<account-id>', '<login>', '<bcrypt-hash>', 'admin');
|
||||
```
|
||||
(bcrypt-Hash z. B. über `internal/auth.HashPassword` in einem
|
||||
Wegwerf-`cmd/`-Programm erzeugen, da `internal/` von außerhalb des
|
||||
Moduls nicht importierbar ist)
|
||||
- `session` — eine angemeldete Sitzung (Token, Ablaufzeit); bewusst
|
||||
eine echte Tabelle statt zustandsloser signierter Tokens, damit
|
||||
Logout eine Sitzung wirklich beendet
|
||||
- `audit_log` — Protokoll der Admin-Aktionen (wer hat wann welchen
|
||||
Account wie verändert); append-only aus demselben Grund wie
|
||||
`finding`/`extraction`/`evidence_package`
|
||||
- `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
|
||||
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
|
||||
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
|
||||
@@ -156,10 +181,17 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
|
||||
- `participant` — Beteiligter an einer Submission mit Rolle
|
||||
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
||||
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
|
||||
- `platform_connection` — die per OAuth hergestellte Verbindung eines
|
||||
Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
|
||||
Abschnitt „Plattform-Verbindung (OAuth)" unten). NICHT append-only —
|
||||
Tokens laufen ab und werden erneuert, eine Verbindung kann getrennt
|
||||
und neu hergestellt werden; höchstens eine Verbindung pro
|
||||
Account+Plattform (`UNIQUE(account_id, platform)`)
|
||||
|
||||
**Append-only.** Kein UPDATE auf `finding`, `extraction` oder
|
||||
`evidence_package`. Korrekturen sind neue Zeilen mit Verweis auf die alte.
|
||||
Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||
**Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`,
|
||||
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
|
||||
Verweis auf die alte. Ein Beweisarchiv (bzw. Protokoll), in dem man
|
||||
Zeilen ändern kann, ist keines mehr.
|
||||
|
||||
**Beweiskette:** SHA-256 über jedes Asset und über die kanonisierte
|
||||
JSON-Repräsentation der Metadaten, RFC-3161-Zeitstempel über diesen Hash.
|
||||
@@ -178,6 +210,39 @@ gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
|
||||
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
|
||||
sonst würde die Antwort selbst verraten, dass die ID existiert.
|
||||
|
||||
**Plattform-Verbindung (OAuth):** Jeder Kunde kann optional seinen
|
||||
eigenen Instagram- oder TikTok-Account verbinden (`GET /verbindungen`),
|
||||
damit die Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||
per API abrufen kann, statt ihn manuell hochzuladen — reiner
|
||||
Authorization-Code-Flow, jeder Kunde autorisiert nur seinen eigenen
|
||||
Account (`internal/socialconnect`, Persistenz in `platform_connection`).
|
||||
Der manuelle Standbild-Upload bleibt der primäre Weg und funktioniert
|
||||
unabhängig davon weiter; OAuth reduziert nur Reibung, ist kein
|
||||
Ersatz für die Pre-Publish-Prüfung (die läuft zwingend vor
|
||||
Veröffentlichung, wenn auf der Plattform noch nichts existiert — dafür
|
||||
kann OAuth nichts abrufen).
|
||||
|
||||
Technisch ist der Flow fertig (Connector-Interface, CSRF-Schutz per
|
||||
State-Cookie, Token-Speicherung), aber **ohne aktive Meta-/TikTok-
|
||||
Freigabe nutzlos**: Instagram (`instagram_business_basic`) und TikTok
|
||||
(Login Kit + Content Posting API) verlangen jeweils eine einmalige,
|
||||
plattformseitige Prüfung des Deklarix-Betreiberkontos (Meta Business
|
||||
Verification + App Review: ca. 2–4 Wochen; TikTok-Audit: ca. 1–2
|
||||
Wochen), bevor sich beliebige Kunden selbst verbinden können. Bis dahin
|
||||
lässt sich mit bis zu 25 (Meta) bzw. 10 (TikTok) manuell eingetragenen
|
||||
Testern trotzdem schon mit einem echten Piloten testen. Ohne gesetzte
|
||||
Konfiguration (`INSTAGRAM_CLIENT_ID`/`_SECRET`,
|
||||
`TIKTOK_CLIENT_KEY`/`_SECRET`, `PUBLIC_BASE_URL`) zeigt
|
||||
`GET /verbindungen` beide Plattformen als „noch nicht konfiguriert"
|
||||
ohne Verbinden-Button — kein Absturz, kein stiller Fallback.
|
||||
|
||||
**Vorsicht bei künftigen Änderungen:** Instagram-/TikTok-Endpunkte,
|
||||
Scopes und Token-Formate in `internal/socialconnect` wurden ohne echte
|
||||
Zugangsdaten gegen die Entwicklerdokumentation gebaut, nie gegen die
|
||||
echte API verifiziert — vor dem ersten echten Verbindungsversuch mit
|
||||
realen Credentials die Konstanten in `internal/socialconnect/*.go` noch
|
||||
einmal gegen die dann aktuelle Meta-/TikTok-Dokumentation prüfen.
|
||||
|
||||
---
|
||||
|
||||
## Go Commands
|
||||
@@ -371,7 +436,9 @@ sudo systemctl start deklarix
|
||||
sudo systemctl status deklarix
|
||||
|
||||
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
|
||||
# DOSSIER_DIR, TSA_URL)
|
||||
# DOSSIER_DIR, ASSET_DIR, TSA_URL, PUBLIC_BASE_URL,
|
||||
# INSTAGRAM_CLIENT_ID/_SECRET, TIKTOK_CLIENT_KEY/_SECRET — letztere vier
|
||||
# optional, ohne sie zeigt /verbindungen nur "nicht konfiguriert")
|
||||
|
||||
# Logs prüfen
|
||||
journalctl -u deklarix -f
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"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"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
@@ -48,7 +49,26 @@ func main() {
|
||||
dossierDir = "dossiers"
|
||||
}
|
||||
|
||||
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir)
|
||||
assetDir := os.Getenv("ASSET_DIR")
|
||||
if assetDir == "" {
|
||||
assetDir = "assets"
|
||||
}
|
||||
|
||||
connectors := map[string]socialconnect.Connector{}
|
||||
publicBaseURL := os.Getenv("PUBLIC_BASE_URL")
|
||||
if publicBaseURL != "" {
|
||||
if id, secret := os.Getenv("INSTAGRAM_CLIENT_ID"), os.Getenv("INSTAGRAM_CLIENT_SECRET"); id != "" && secret != "" {
|
||||
connectors["instagram"] = socialconnect.NewInstagramConnector(id, secret, publicBaseURL+"/oauth/instagram/callback")
|
||||
}
|
||||
if key, secret := os.Getenv("TIKTOK_CLIENT_KEY"), os.Getenv("TIKTOK_CLIENT_SECRET"); key != "" && secret != "" {
|
||||
connectors["tiktok"] = socialconnect.NewTikTokConnector(key, secret, publicBaseURL+"/oauth/tiktok/callback")
|
||||
}
|
||||
}
|
||||
if len(connectors) == 0 {
|
||||
log.Print("keine Plattform-Verbindungen konfiguriert (INSTAGRAM_CLIENT_ID/TIKTOK_CLIENT_KEY/PUBLIC_BASE_URL fehlen) — /verbindungen zeigt nur manuelle Beweissicherung an")
|
||||
}
|
||||
|
||||
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir, connectors)
|
||||
if err != nil {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
|
||||
82
internal/socialconnect/connector.go
Normal file
82
internal/socialconnect/connector.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package socialconnect implementiert den OAuth-Authorization-Code-Flow,
|
||||
// mit dem ein Kunde seinen eigenen Instagram- oder TikTok-Account mit
|
||||
// Deklarix verbindet — Ziel ist, dass die Beweissicherung einen
|
||||
// veröffentlichten Beitrag später direkt abrufen kann, statt ihn manuell
|
||||
// hochladen zu müssen. Reine HTTP-Logik gegen die jeweilige Plattform-
|
||||
// API, keine Datenbankzugriffe — Persistenz der Verbindung liegt in
|
||||
// internal/store (platform_connection).
|
||||
//
|
||||
// WICHTIG: Instagram- und TikTok-Endpunkte, Scopes und Token-Formate
|
||||
// ändern sich häufiger als andere APIs. Vor dem ersten echten
|
||||
// Verbindungsversuch mit realen Client-Credentials die Konstanten hier
|
||||
// gegen die aktuelle Meta-/TikTok-Entwicklerdokumentation prüfen —
|
||||
// dieser Code wurde ohne echte Zugangsdaten gebaut und gegen die
|
||||
// Dokumentation, nicht gegen die echte API, verifiziert.
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token ist das Ergebnis eines erfolgreichen Code-Tauschs.
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time // Nullwert, wenn die Plattform keine Ablaufzeit liefert
|
||||
PlatformUserID string
|
||||
}
|
||||
|
||||
// Connector kapselt den OAuth-Flow einer einzelnen Plattform. *InstagramConnector
|
||||
// und *TikTokConnector erfüllen dieses Interface; internal/web hält eine
|
||||
// Menge konfigurierter Connectors (nur die, für die echte Client-
|
||||
// Credentials gesetzt sind — siehe cmd/deklarix/main.go).
|
||||
type Connector interface {
|
||||
// Platform ist der interne Bezeichner ("instagram" | "tiktok"), wie
|
||||
// er auch in platform_connection.platform gespeichert wird.
|
||||
Platform() string
|
||||
AuthorizationURL(state string) string
|
||||
Exchange(ctx context.Context, code string) (Token, error)
|
||||
}
|
||||
|
||||
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return doJSON(client, req, out)
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, endpoint string, query url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doJSON(client, req, out)
|
||||
}
|
||||
|
||||
func doJSON(client *http.Client, req *http.Request, out any) error {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("antwort lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("unerwarteter Status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("antwort parsen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
103
internal/socialconnect/instagram.go
Normal file
103
internal/socialconnect/instagram.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
instagramDefaultAuthorizeURL = "https://api.instagram.com/oauth/authorize"
|
||||
instagramDefaultTokenURL = "https://api.instagram.com/oauth/access_token"
|
||||
instagramDefaultLongLivedTokenURL = "https://graph.instagram.com/access_token"
|
||||
|
||||
// instagram_business_basic ist die einzige Berechtigung, die wir
|
||||
// brauchen (Profil + Medien lesen) — mehr zu verlangen verzögert nur
|
||||
// den App-Review, siehe Paket-Kommentar.
|
||||
instagramScope = "instagram_business_basic"
|
||||
)
|
||||
|
||||
// InstagramConnector implementiert Connector für Instagram (Instagram
|
||||
// API with Instagram Login). Der Flow läuft zweistufig: der
|
||||
// Autorisierungscode wird zuerst gegen ein 1 Stunde gültiges Token
|
||||
// getauscht, das anschließend gegen ein 60 Tage gültiges langlebiges
|
||||
// Token getauscht wird — ein einzelner API-Aufruf reicht dafür nicht.
|
||||
type InstagramConnector struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
|
||||
// Überschreibbar für Tests (Default: die echten Instagram-Endpunkte).
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
LongLivedTokenURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewInstagramConnector erstellt einen InstagramConnector mit den
|
||||
// echten Instagram-Endpunkten.
|
||||
func NewInstagramConnector(clientID, clientSecret, redirectURL string) *InstagramConnector {
|
||||
return &InstagramConnector{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
AuthorizeURL: instagramDefaultAuthorizeURL,
|
||||
TokenURL: instagramDefaultTokenURL,
|
||||
LongLivedTokenURL: instagramDefaultLongLivedTokenURL,
|
||||
HTTPClient: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *InstagramConnector) Platform() string { return "instagram" }
|
||||
|
||||
func (c *InstagramConnector) AuthorizationURL(state string) string {
|
||||
v := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"scope": {instagramScope},
|
||||
"response_type": {"code"},
|
||||
"state": {state},
|
||||
}
|
||||
return c.AuthorizeURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
func (c *InstagramConnector) Exchange(ctx context.Context, code string) (Token, error) {
|
||||
form := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"code": {code},
|
||||
}
|
||||
var short struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
UserID any `json:"user_id"` // liefert Instagram mal als Zahl, mal als String
|
||||
}
|
||||
if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &short); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: %w", err)
|
||||
}
|
||||
if short.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: kein access_token in der Antwort")
|
||||
}
|
||||
|
||||
long := url.Values{
|
||||
"grant_type": {"ig_exchange_token"},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"access_token": {short.AccessToken},
|
||||
}
|
||||
var longResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := getJSON(ctx, c.HTTPClient, c.LongLivedTokenURL, long, &longResp); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram long-lived token exchange: %w", err)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: longResp.AccessToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(longResp.ExpiresIn) * time.Second),
|
||||
PlatformUserID: fmt.Sprint(short.UserID),
|
||||
}, nil
|
||||
}
|
||||
82
internal/socialconnect/instagram_test.go
Normal file
82
internal/socialconnect/instagram_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstagramAuthorizationURL(t *testing.T) {
|
||||
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||
u := c.AuthorizationURL("state-abc")
|
||||
|
||||
for _, want := range []string{
|
||||
"https://api.instagram.com/oauth/authorize?",
|
||||
"client_id=client-123",
|
||||
"state=state-abc",
|
||||
"scope=instagram_business_basic",
|
||||
"response_type=code",
|
||||
} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstagramExchangeSuccess(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm: %v", err)
|
||||
}
|
||||
if r.FormValue("code") != "der-code" {
|
||||
t.Errorf("code = %q, want der-code", r.FormValue("code"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"short-lived-token","user_id":"17841400000000000"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
longLivedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("access_token") != "short-lived-token" {
|
||||
t.Errorf("access_token query = %q, want short-lived-token", r.URL.Query().Get("access_token"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"long-lived-token","expires_in":5184000}`))
|
||||
}))
|
||||
defer longLivedServer.Close()
|
||||
|
||||
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
c.LongLivedTokenURL = longLivedServer.URL
|
||||
|
||||
tok, err := c.Exchange(context.Background(), "der-code")
|
||||
if err != nil {
|
||||
t.Fatalf("Exchange: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "long-lived-token" {
|
||||
t.Errorf("AccessToken = %q, want long-lived-token", tok.AccessToken)
|
||||
}
|
||||
if tok.PlatformUserID != "17841400000000000" {
|
||||
t.Errorf("PlatformUserID = %q, want 17841400000000000", tok.PlatformUserID)
|
||||
}
|
||||
if tok.ExpiresAt.IsZero() {
|
||||
t.Error("expected a non-zero ExpiresAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstagramExchangePropagatesTokenEndpointError(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error_message":"ungueltiger code"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
|
||||
if _, err := c.Exchange(context.Background(), "falscher-code"); err == nil {
|
||||
t.Fatal("expected an error when the token endpoint returns 400")
|
||||
}
|
||||
}
|
||||
95
internal/socialconnect/tiktok.go
Normal file
95
internal/socialconnect/tiktok.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
tiktokDefaultAuthorizeURL = "https://www.tiktok.com/v2/auth/authorize/"
|
||||
tiktokDefaultTokenURL = "https://open.tiktokapis.com/v2/oauth/token/"
|
||||
|
||||
// user.info.basic reicht für Profil-Grunddaten; video.list für den
|
||||
// späteren Abruf veröffentlichter Videos (Beweissicherung). Mehr
|
||||
// Scopes verlangen als nötig verzögert nur den Audit, siehe
|
||||
// Paket-Kommentar.
|
||||
tiktokScope = "user.info.basic,video.list"
|
||||
)
|
||||
|
||||
// TikTokConnector implementiert Connector für TikTok Login Kit v2 (Web-
|
||||
// Flow — PKCE ist bei TikTok nur für Desktop/Mobile-Apps Pflicht, beim
|
||||
// Web-Flow schützt allein der state-Parameter gegen CSRF, siehe
|
||||
// TikTok-Dokumentation "Web").
|
||||
type TikTokConnector struct {
|
||||
ClientKey string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
|
||||
// Überschreibbar für Tests (Default: die echten TikTok-Endpunkte).
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewTikTokConnector erstellt einen TikTokConnector mit den echten
|
||||
// TikTok-Endpunkten.
|
||||
func NewTikTokConnector(clientKey, clientSecret, redirectURL string) *TikTokConnector {
|
||||
return &TikTokConnector{
|
||||
ClientKey: clientKey,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
AuthorizeURL: tiktokDefaultAuthorizeURL,
|
||||
TokenURL: tiktokDefaultTokenURL,
|
||||
HTTPClient: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TikTokConnector) Platform() string { return "tiktok" }
|
||||
|
||||
func (c *TikTokConnector) AuthorizationURL(state string) string {
|
||||
v := url.Values{
|
||||
"client_key": {c.ClientKey},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"scope": {tiktokScope},
|
||||
"response_type": {"code"},
|
||||
"state": {state},
|
||||
}
|
||||
return c.AuthorizeURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
func (c *TikTokConnector) Exchange(ctx context.Context, code string) (Token, error) {
|
||||
form := url.Values{
|
||||
"client_key": {c.ClientKey},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"code": {code},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
}
|
||||
var resp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
OpenID string `json:"open_id"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &resp); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %w", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: %s: %s", resp.Error, resp.ErrorDescription)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("socialconnect: tiktok code exchange: kein access_token in der Antwort")
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: resp.AccessToken,
|
||||
RefreshToken: resp.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second),
|
||||
PlatformUserID: resp.OpenID,
|
||||
}, nil
|
||||
}
|
||||
70
internal/socialconnect/tiktok_test.go
Normal file
70
internal/socialconnect/tiktok_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTikTokAuthorizationURL(t *testing.T) {
|
||||
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||
u := c.AuthorizationURL("state-xyz")
|
||||
|
||||
for _, want := range []string{
|
||||
"https://www.tiktok.com/v2/auth/authorize/?",
|
||||
"client_key=client-key-123",
|
||||
"state=state-xyz",
|
||||
"response_type=code",
|
||||
} {
|
||||
if !strings.Contains(u, want) {
|
||||
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokExchangeSuccess(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm: %v", err)
|
||||
}
|
||||
if r.FormValue("client_key") != "client-key-123" {
|
||||
t.Errorf("client_key = %q, want client-key-123", r.FormValue("client_key"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"tt-access","refresh_token":"tt-refresh","expires_in":86400,"open_id":"tt-open-id-1"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
|
||||
tok, err := c.Exchange(context.Background(), "der-code")
|
||||
if err != nil {
|
||||
t.Fatalf("Exchange: %v", err)
|
||||
}
|
||||
if tok.AccessToken != "tt-access" || tok.RefreshToken != "tt-refresh" || tok.PlatformUserID != "tt-open-id-1" {
|
||||
t.Errorf("unexpected token: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokExchangePropagatesPlatformError(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TikTok liefert Fehler oft mit Status 200, Fehlerfeldern im Body.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"error":"invalid_grant","error_description":"code abgelaufen"}`))
|
||||
}))
|
||||
defer tokenServer.Close()
|
||||
|
||||
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||
c.TokenURL = tokenServer.URL
|
||||
|
||||
_, err := c.Exchange(context.Background(), "abgelaufener-code")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the platform response contains an error field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_grant") {
|
||||
t.Errorf("error = %v, want it to mention invalid_grant", err)
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,32 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Account ist ein Mandant (Creator, Agentur, Marke oder Kanzlei als
|
||||
// eigene Organisation). Jeder Beitrag gehört genau einem Account.
|
||||
// Verified ist nur für Kanzlei-Accounts relevant: ob sie im öffentlichen
|
||||
// Kanzlei-Verzeichnis gelistet werden (siehe Migration 0004).
|
||||
type Account struct {
|
||||
ID string
|
||||
Name string
|
||||
Verified bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAccount legt einen neuen Mandanten an.
|
||||
// CreateAccount legt einen neuen Mandanten an (verified startet false —
|
||||
// jede Freigabe fürs Kanzlei-Verzeichnis ist eine bewusste Admin-Aktion).
|
||||
func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO account (name) VALUES ($1)
|
||||
RETURNING id, name, created_at
|
||||
`, name).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
RETURNING id, name, verified, created_at
|
||||
`, name).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: create account: %w", err)
|
||||
}
|
||||
@@ -31,10 +38,85 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error)
|
||||
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, created_at FROM account WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.Name, &a.CreatedAt)
|
||||
SELECT id, name, verified, created_at FROM account WHERE id = $1
|
||||
`, id).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: get account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListAccounts liefert alle Mandanten, neueste zuerst — für den
|
||||
// Admin-Bereich (Accounts-Verwaltung).
|
||||
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, verified, created_at FROM account ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list accounts: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListVerifiedKanzleien liefert alle Accounts, die fürs öffentliche
|
||||
// Kanzlei-Verzeichnis freigegeben sind UND mindestens einen Nutzer der
|
||||
// Rolle "kanzlei" haben — verified allein reicht nicht, falls ein Admin
|
||||
// versehentlich einen Nicht-Kanzlei-Account markiert.
|
||||
func (s *Store) ListVerifiedKanzleien(ctx context.Context) ([]Account, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT DISTINCT a.id, a.name, a.verified, a.created_at
|
||||
FROM account a
|
||||
JOIN app_user u ON u.account_id = a.id
|
||||
WHERE a.verified = true AND u.role = 'kanzlei'
|
||||
ORDER BY a.name
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list verified kanzleien: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Account
|
||||
for rows.Next() {
|
||||
var a Account
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan account: %w", err)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list verified kanzleien: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetAccountVerified setzt die Freigabe fürs Kanzlei-Verzeichnis.
|
||||
func (s *Store) SetAccountVerified(ctx context.Context, id string, verified bool) (Account, error) {
|
||||
var a Account
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE account SET verified = $2 WHERE id = $1
|
||||
RETURNING id, name, verified, created_at
|
||||
`, id, verified).Scan(&a.ID, &a.Name, &a.Verified, &a.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("store: set account verified: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
221
internal/store/admin_test.go
Normal file
221
internal/store/admin_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppUserRoleAllowsAdmin(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
u, err := s.CreateUser(ctx, accID, "admin@example.com", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser mit role=admin: %v", err)
|
||||
}
|
||||
if u.Role != "admin" {
|
||||
t.Fatalf("Role = %q, want admin", u.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountVerifiedDefaultsFalseAndCanBeSet(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
acc, err := s.CreateAccount(ctx, "Kanzlei Musterfrau")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if acc.Verified {
|
||||
t.Fatal("expected a new account to be unverified by default")
|
||||
}
|
||||
|
||||
updated, err := s.SetAccountVerified(ctx, acc.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetAccountVerified: %v", err)
|
||||
}
|
||||
if !updated.Verified {
|
||||
t.Fatal("expected the account to be verified after SetAccountVerified(true)")
|
||||
}
|
||||
|
||||
got, err := s.GetAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount: %v", err)
|
||||
}
|
||||
if !got.Verified {
|
||||
t.Fatal("expected verified=true to persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountVerifiedNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.SetAccountVerified(context.Background(), "00000000-0000-0000-0000-000000000000", true)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unknown account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAccounts(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
after, err := s.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
if len(after) != len(before)+1 {
|
||||
t.Fatalf("expected exactly one more account, got %d -> %d", len(before), len(after))
|
||||
}
|
||||
found := false
|
||||
for _, a := range after {
|
||||
if a.ID == acc.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected the newly created account in ListAccounts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVerifiedKanzleienRequiresBothVerifiedAndKanzleiRole(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Verifiziert, aber kein Kanzlei-Nutzer -> darf nicht auftauchen.
|
||||
verifiedNonKanzlei, err := s.CreateAccount(ctx, "Verifizierte Marke")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, verifiedNonKanzlei.ID, "marke@example.com", "hash", "marke"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.SetAccountVerified(ctx, verifiedNonKanzlei.ID, true); err != nil {
|
||||
t.Fatalf("SetAccountVerified: %v", err)
|
||||
}
|
||||
|
||||
// Kanzlei-Nutzer, aber nicht verifiziert -> darf nicht auftauchen.
|
||||
unverifiedKanzlei, err := s.CreateAccount(ctx, "Unverifizierte Kanzlei")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, unverifiedKanzlei.ID, "unverifiziert@example.com", "hash", "kanzlei"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
// Beides erfuellt -> muss auftauchen.
|
||||
verifiedKanzlei, err := s.CreateAccount(ctx, "Verifizierte Kanzlei")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, verifiedKanzlei.ID, "verifiziert@example.com", "hash", "kanzlei"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.SetAccountVerified(ctx, verifiedKanzlei.ID, true); err != nil {
|
||||
t.Fatalf("SetAccountVerified: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListVerifiedKanzleien(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListVerifiedKanzleien: %v", err)
|
||||
}
|
||||
byID := map[string]bool{}
|
||||
for _, a := range list {
|
||||
byID[a.ID] = true
|
||||
}
|
||||
if byID[verifiedNonKanzlei.ID] {
|
||||
t.Error("verified non-kanzlei account should not appear in the directory")
|
||||
}
|
||||
if byID[unverifiedKanzlei.ID] {
|
||||
t.Error("unverified kanzlei account should not appear in the directory")
|
||||
}
|
||||
if !byID[verifiedKanzlei.ID] {
|
||||
t.Error("expected the verified kanzlei account in the directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListUsersForAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
otherAccID := testAccountID(t, s)
|
||||
|
||||
if _, err := s.CreateUser(ctx, accID, "eins@example.com", "hash", "creator"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, accID, "zwei@example.com", "hash", "agentur"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if _, err := s.CreateUser(ctx, otherAccID, "fremd@example.com", "hash", "marke"); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
list, err := s.ListUsersForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsersForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected exactly 2 users for this account, got %d: %+v", len(list), list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogCreateAndList(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-audit@example.com", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "manuell freigegeben")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
if entry.ActorUserID != admin.ID {
|
||||
t.Fatalf("ActorUserID = %q, want %q", entry.ActorUserID, admin.ID)
|
||||
}
|
||||
|
||||
list, err := s.ListAuditLog(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAuditLog: %v", err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatal("expected at least one audit entry")
|
||||
}
|
||||
if list[0].ID != entry.ID {
|
||||
t.Fatalf("expected the newest entry first, got %+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditLogIsAppendOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
admin, err := s.CreateUser(ctx, accID, "admin-appendonly@example.com", "hash", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
entry, err := s.CreateAuditEntry(ctx, admin.ID, "account.verified", "account", accID, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuditEntry: %v", err)
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE audit_log SET action = 'geaendert' WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected UPDATE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `DELETE FROM audit_log WHERE id = $1`, entry.ID)
|
||||
if err == nil {
|
||||
t.Fatal("expected DELETE on audit_log to be rejected by the append-only trigger")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
61
internal/store/auditlog.go
Normal file
61
internal/store/auditlog.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditEntry ist ein Protokolleintrag einer Admin-Aktion. Append-only:
|
||||
// siehe Migration 0004 — ein Protokoll, das man ändern kann, ist kein
|
||||
// Nachweis mehr, aus demselben Grund wie bei finding/extraction.
|
||||
type AuditEntry struct {
|
||||
ID string
|
||||
ActorUserID string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAuditEntry protokolliert eine Admin-Aktion.
|
||||
func (s *Store) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (AuditEntry, error) {
|
||||
var e AuditEntry
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO audit_log (actor_user_id, action, target_type, target_id, details)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
`, actorUserID, action, targetType, targetID, details).Scan(
|
||||
&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return AuditEntry{}, fmt.Errorf("store: create audit entry: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// ListAuditLog liefert die letzten Protokolleinträge, neueste zuerst.
|
||||
func (s *Store) ListAuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, actor_user_id, action, target_type, target_id, details, created_at
|
||||
FROM audit_log ORDER BY created_at DESC LIMIT $1
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []AuditEntry
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.ActorUserID, &e.Action, &e.TargetType, &e.TargetID, &e.Details, &e.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan audit entry: %w", err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list audit log: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
6
internal/store/migrations/0004_admin.down.sql
Normal file
6
internal/store/migrations/0004_admin.down.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
DROP TRIGGER audit_log_append_only ON audit_log;
|
||||
DROP TABLE audit_log;
|
||||
ALTER TABLE account DROP COLUMN verified;
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei'));
|
||||
36
internal/store/migrations/0004_admin.up.sql
Normal file
36
internal/store/migrations/0004_admin.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- "admin" ist eine fünfte app_user-Rolle: Betreiber-Personal (Netcell-IT),
|
||||
-- nicht an einen Mandanten-Geschäftszweck (creator/agentur/marke/kanzlei)
|
||||
-- gebunden, sondern zuständig für die Plattform selbst (Accounts,
|
||||
-- Kanzlei-Verzeichnis, Audit-Log). Bewusst KEIN eigenes account_role-Feld
|
||||
-- getrennt von app_user.role — ein Admin-Login ist genauso ein app_user
|
||||
-- wie jeder andere, nur mit einer anderen Rolle. Es gibt bewusst keine
|
||||
-- Selbstregistrierung für "admin" über /register (siehe internal/web) —
|
||||
-- der erste Admin wird per SQL angelegt (siehe CLAUDE.md).
|
||||
ALTER TABLE app_user DROP CONSTRAINT app_user_role_check;
|
||||
ALTER TABLE app_user ADD CONSTRAINT app_user_role_check
|
||||
CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei', 'admin'));
|
||||
|
||||
-- verified markiert eine Kanzlei-Account als für das öffentliche,
|
||||
-- kostenlose Kanzlei-Verzeichnis freigegeben (siehe CLAUDE.md, § 49b
|
||||
-- Abs. 3 BRAO: keine Sachvorteile, kein Routing — das Verzeichnis ist
|
||||
-- eine reine Auflistung, keine Vermittlung). Nur für Accounts mit
|
||||
-- mindestens einem Nutzer der Rolle "kanzlei" sinnvoll; das erzwingt die
|
||||
-- Anwendungsschicht, nicht die Datenbank.
|
||||
ALTER TABLE account ADD COLUMN verified BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Audit-Log für Admin-Aktionen: append-only aus demselben Grund wie
|
||||
-- finding/extraction/evidence_package — ein Protokoll, das man ändern
|
||||
-- kann, ist kein Nachweis mehr.
|
||||
CREATE TABLE audit_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_user_id UUID NOT NULL REFERENCES app_user (id),
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER audit_log_append_only
|
||||
BEFORE UPDATE OR DELETE ON audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
@@ -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();
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE platform_connection;
|
||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
|
||||
-- OAuth verbinden (siehe internal/socialconnect), damit die
|
||||
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||
-- abrufen kann statt ihn manuell hochzuladen. Anders als
|
||||
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
|
||||
-- und werden erneuert, eine Verbindung kann getrennt und neu
|
||||
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
|
||||
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
|
||||
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
|
||||
-- ON CONFLICT in UpsertPlatformConnection).
|
||||
CREATE TABLE platform_connection (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES account (id),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||
platform_user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (account_id, platform)
|
||||
);
|
||||
108
internal/store/platformconnection.go
Normal file
108
internal/store/platformconnection.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// PlatformConnection ist die per OAuth hergestellte Verbindung eines
|
||||
// Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
|
||||
// internal/socialconnect). Anders als asset/finding/extraction NICHT
|
||||
// append-only — ein abgelaufenes oder erneuertes Token ersetzt das alte,
|
||||
// eine getrennte Verbindung wird wirklich gelöscht.
|
||||
type PlatformConnection struct {
|
||||
ID string
|
||||
AccountID string
|
||||
Platform string
|
||||
PlatformUserID string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt *time.Time
|
||||
ConnectedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertPlatformConnection legt eine Verbindung an oder ersetzt die
|
||||
// bestehende für dasselbe Account+Plattform-Paar (z. B. bei erneutem
|
||||
// Verbinden nach Trennen, oder wenn refresh_token erneuert wurde).
|
||||
func (s *Store) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (PlatformConnection, error) {
|
||||
var c PlatformConnection
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO platform_connection (account_id, platform, platform_user_id, access_token, refresh_token, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (account_id, platform) DO UPDATE SET
|
||||
platform_user_id = EXCLUDED.platform_user_id,
|
||||
access_token = EXCLUDED.access_token,
|
||||
refresh_token = EXCLUDED.refresh_token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
connected_at = now()
|
||||
RETURNING id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||
`, accountID, platform, platformUserID, accessToken, refreshToken, expiresAt).Scan(
|
||||
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return PlatformConnection{}, fmt.Errorf("store: upsert platform connection: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListPlatformConnectionsForAccount liefert alle Plattform-Verbindungen
|
||||
// eines Accounts.
|
||||
func (s *Store) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]PlatformConnection, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||
FROM platform_connection WHERE account_id = $1 ORDER BY platform
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list platform connections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []PlatformConnection
|
||||
for rows.Next() {
|
||||
var c PlatformConnection
|
||||
if err := rows.Scan(&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan platform connection: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list platform connections: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetPlatformConnection liest die Verbindung eines Accounts zu einer
|
||||
// bestimmten Plattform. Liefert ErrNotFound, wenn keine Verbindung
|
||||
// besteht — der Normalfall, solange der Kunde nichts verbunden hat.
|
||||
func (s *Store) GetPlatformConnection(ctx context.Context, accountID, platform string) (PlatformConnection, error) {
|
||||
var c PlatformConnection
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||
FROM platform_connection WHERE account_id = $1 AND platform = $2
|
||||
`, accountID, platform).Scan(
|
||||
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PlatformConnection{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PlatformConnection{}, fmt.Errorf("store: get platform connection: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DeletePlatformConnection trennt eine Plattform-Verbindung.
|
||||
func (s *Store) DeletePlatformConnection(ctx context.Context, accountID, platform string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM platform_connection WHERE account_id = $1 AND platform = $2`, accountID, platform)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: delete platform connection: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
114
internal/store/platformconnection_test.go
Normal file
114
internal/store/platformconnection_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
func TestPlatformConnectionUpsertGetDelete(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
expires := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
c, err := s.UpsertPlatformConnection(ctx, accID, "instagram", "ig-user-1", "access-1", "", &expires)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection: %v", err)
|
||||
}
|
||||
if c.Platform != "instagram" || c.PlatformUserID != "ig-user-1" {
|
||||
t.Fatalf("UpsertPlatformConnection = %+v, unerwartete Werte", c)
|
||||
}
|
||||
|
||||
got, err := s.GetPlatformConnection(ctx, accID, "instagram")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlatformConnection: %v", err)
|
||||
}
|
||||
if got.AccessToken != "access-1" {
|
||||
t.Fatalf("AccessToken = %q, want access-1", got.AccessToken)
|
||||
}
|
||||
|
||||
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected exactly 1 connection, got %d", len(list))
|
||||
}
|
||||
|
||||
if err := s.DeletePlatformConnection(ctx, accID, "instagram"); err != nil {
|
||||
t.Fatalf("DeletePlatformConnection: %v", err)
|
||||
}
|
||||
if _, err := s.GetPlatformConnection(ctx, accID, "instagram"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformConnectionUpsertReplacesExisting(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
first, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-alt", "refresh-alt", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection (1): %v", err)
|
||||
}
|
||||
second, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-neu", "refresh-neu", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection (2): %v", err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("expected the same row to be updated (same account+platform), got a new ID")
|
||||
}
|
||||
if second.AccessToken != "access-neu" || second.RefreshToken != "refresh-neu" {
|
||||
t.Fatalf("UpsertPlatformConnection (2) = %+v, tokens wurden nicht ersetzt", second)
|
||||
}
|
||||
|
||||
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected exactly 1 connection after upsert-replace, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlatformConnectionNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
_, err := s.GetPlatformConnection(ctx, accID, "instagram")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePlatformConnectionNotFound(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accID := testAccountID(t, s)
|
||||
|
||||
err := s.DeletePlatformConnection(ctx, accID, "tiktok")
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformConnectionIsolatedPerAccount(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
accA := testAccountID(t, s)
|
||||
accB := testAccountID(t, s)
|
||||
|
||||
if _, err := s.UpsertPlatformConnection(ctx, accA, "instagram", "ig-a", "token-a", "", nil); err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection (A): %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.GetPlatformConnection(ctx, accB, "instagram"); !errors.Is(err, store.ErrNotFound) {
|
||||
t.Fatalf("Mandant B sollte keine Verbindung von Mandant A sehen, err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -75,3 +75,29 @@ func (s *Store) GetUser(ctx context.Context, id string) (User, error) {
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsersForAccount liefert alle Logins eines Mandanten — für den
|
||||
// Admin-Bereich (Account-Detailansicht).
|
||||
func (s *Store) ListUsersForAccount(ctx context.Context, accountID string) ([]User, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, account_id, email, password_hash, role, created_at
|
||||
FROM app_user WHERE account_id = $1 ORDER BY created_at
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.AccountID, &u.Email, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list users for account: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
217
internal/web/admin_handlers.go
Normal file
217
internal/web/admin_handlers.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type kanzleiListItem struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type kanzleiListData struct {
|
||||
Title string
|
||||
Kanzleien []kanzleiListItem
|
||||
}
|
||||
|
||||
// handlePublicKanzleiList zeigt das öffentliche, kostenlose Kanzlei-
|
||||
// Verzeichnis — keine Anmeldung nötig. Bewusst nur Name, kein Kontakt-
|
||||
// Button/Routing (siehe CLAUDE.md, § 49b Abs. 3 BRAO): Nutzer wählen
|
||||
// selbst, es gibt keine Vermittlung.
|
||||
func (s *Server) handlePublicKanzleiList(w http.ResponseWriter, r *http.Request) {
|
||||
accounts, err := s.store.ListVerifiedKanzleien(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Verzeichnis konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := kanzleiListData{Title: "Kanzlei-Verzeichnis"}
|
||||
for _, a := range accounts {
|
||||
data.Kanzleien = append(data.Kanzleien, kanzleiListItem{Name: a.Name})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "kanzleien", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type adminDashboardData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountCount int
|
||||
UnverifiedCount int
|
||||
RecentAuditCount int
|
||||
}
|
||||
|
||||
// handleAdminDashboard zeigt eine kurze Übersicht als Einstieg in den
|
||||
// Admin-Bereich.
|
||||
func (s *Server) handleAdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
accounts, err := s.store.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
unverified := 0
|
||||
for _, a := range accounts {
|
||||
if !a.Verified {
|
||||
unverified++
|
||||
}
|
||||
}
|
||||
auditLog, err := s.store.ListAuditLog(ctx, 5)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := adminDashboardData{
|
||||
Title: "Admin", Nav: navFor(r), AccountCount: len(accounts), UnverifiedCount: unverified, RecentAuditCount: len(auditLog),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "admin-dashboard", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type adminAccountListItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Verified bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type adminAccountListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Accounts []adminAccountListItem
|
||||
}
|
||||
|
||||
// handleAdminAccountList listet alle Mandanten der Plattform.
|
||||
func (s *Server) handleAdminAccountList(w http.ResponseWriter, r *http.Request) {
|
||||
accounts, err := s.store.ListAccounts(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "Accounts konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := adminAccountListData{Title: "Accounts", Nav: navFor(r)}
|
||||
for _, a := range accounts {
|
||||
data.Accounts = append(data.Accounts, adminAccountListItem{
|
||||
ID: a.ID, Name: a.Name, Verified: a.Verified, CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "admin-accounts", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type adminUserView struct {
|
||||
Email string
|
||||
Role string
|
||||
}
|
||||
|
||||
type adminAccountDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
AccountID string
|
||||
Name string
|
||||
Verified bool
|
||||
HasKanzlei bool
|
||||
Users []adminUserView
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// handleAdminAccountDetail zeigt einen Mandanten mit seinen Logins und —
|
||||
// falls mindestens ein Login die Rolle "kanzlei" hat — der Möglichkeit,
|
||||
// die Freigabe fürs öffentliche Kanzlei-Verzeichnis zu setzen.
|
||||
func (s *Server) handleAdminAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
acc, err := s.store.GetAccount(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListUsersForAccount(ctx, acc.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "Nutzer konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := adminAccountDetailData{
|
||||
Title: "Account", Nav: navFor(r), AccountID: acc.ID, Name: acc.Name, Verified: acc.Verified,
|
||||
CreatedAt: acc.CreatedAt.Format("02.01.2006 15:04"),
|
||||
}
|
||||
for _, u := range users {
|
||||
data.Users = append(data.Users, adminUserView{Email: u.Email, Role: u.Role})
|
||||
if u.Role == "kanzlei" {
|
||||
data.HasKanzlei = true
|
||||
}
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "admin-account-detail", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminSetVerified schaltet die Freigabe fürs Kanzlei-Verzeichnis
|
||||
// um und protokolliert die Aktion im Audit-Log.
|
||||
func (s *Server) handleAdminSetVerified(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
accountID := r.PathValue("id")
|
||||
ctx := r.Context()
|
||||
|
||||
if _, err := s.store.GetAccount(ctx, accountID); err != nil {
|
||||
http.Error(w, "Account nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
verified := r.FormValue("verified") == "true"
|
||||
|
||||
updated, err := s.store.SetAccountVerified(ctx, accountID, verified)
|
||||
if err != nil {
|
||||
http.Error(w, "Freigabe konnte nicht gesetzt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
action := "account.unverified"
|
||||
details := "Kanzlei-Verzeichnis: Freigabe entzogen"
|
||||
if updated.Verified {
|
||||
action = "account.verified"
|
||||
details = "Kanzlei-Verzeichnis: Freigabe erteilt"
|
||||
}
|
||||
if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, action, "account", accountID, details); err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/accounts/"+accountID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type adminAuditEntryView struct {
|
||||
CreatedAt string
|
||||
Action string
|
||||
TargetType string
|
||||
TargetID string
|
||||
Details string
|
||||
}
|
||||
|
||||
type adminAuditLogData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Entries []adminAuditEntryView
|
||||
}
|
||||
|
||||
// handleAdminAuditLog zeigt das Protokoll der Admin-Aktionen.
|
||||
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := s.store.ListAuditLog(r.Context(), 200)
|
||||
if err != nil {
|
||||
http.Error(w, "Audit-Log konnte nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := adminAuditLogData{Title: "Audit-Log", Nav: navFor(r)}
|
||||
for _, e := range entries {
|
||||
data.Entries = append(data.Entries, adminAuditEntryView{
|
||||
CreatedAt: e.CreatedAt.Format("02.01.2006 15:04:05"), Action: e.Action,
|
||||
TargetType: e.TargetType, TargetID: e.TargetID, Details: e.Details,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "admin-audit-log", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
198
internal/web/admin_handlers_test.go
Normal file
198
internal/web/admin_handlers_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
)
|
||||
|
||||
func TestAdminRoutesRejectNonAdminWith404(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
||||
|
||||
for _, path := range []string{"/admin", "/admin/accounts", "/admin/audit-log"} {
|
||||
resp := getWithCookie(t, s, cookie, path)
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s status = %d, want 404 for a non-admin user", path, resp.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRoutesRedirectToLoginWithoutSession(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{})
|
||||
|
||||
resp := getWithCookie(t, s, nil, "/admin")
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 redirect to /login", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDashboardAccessibleForAdmin(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{}, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/admin")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestNavShowsAdminLinkOnlyForAdmins deckt genau den gemeldeten Fall ab:
|
||||
// nach der Anmeldung als Admin landet man auf der normalen Startseite
|
||||
// (jeder Nutzer hat einen Account+Login, auch ein Admin) — ohne einen
|
||||
// sichtbaren Weg zu /admin wäre der Admin-Bereich für einen Admin, der
|
||||
// die URL nicht auswendig kennt, praktisch unerreichbar.
|
||||
func TestNavShowsAdminLinkOnlyForAdmins(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{}, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
||||
tenantCookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
||||
|
||||
adminResp := getWithCookie(t, s, adminCookie, "/")
|
||||
if !strings.Contains(adminResp.Body.String(), `href="/admin"`) {
|
||||
t.Errorf("expected an /admin nav link for an admin user, got: %s", adminResp.Body.String())
|
||||
}
|
||||
|
||||
tenantResp := getWithCookie(t, s, tenantCookie, "/")
|
||||
if strings.Contains(tenantResp.Body.String(), `href="/admin"`) {
|
||||
t.Errorf("expected no /admin nav link for a non-admin user, got: %s", tenantResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountListShowsAllAccountsAcrossTenants(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{}, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
||||
seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
resp := getWithCookie(t, s, adminCookie, "/admin/accounts")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
for _, want := range []string{"Mandant A", "Mandant B", "Deklarix Admin"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("expected %q in the admin account list, got: %s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountDetailShowsUsersAndVerifyToggleOnlyForKanzlei(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{}, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
||||
|
||||
creatorCookie := seedAccount(t, fs, "Nur Creator", "creator@example.com")
|
||||
_ = creatorCookie
|
||||
var creatorAccID string
|
||||
for id, acc := range fs.accounts {
|
||||
if acc.Name == "Nur Creator" {
|
||||
creatorAccID = id
|
||||
}
|
||||
}
|
||||
|
||||
kanzleiCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
|
||||
_ = kanzleiCookie
|
||||
var kanzleiAccID string
|
||||
for id, acc := range fs.accounts {
|
||||
if acc.Name == "Kanzlei Musterfrau" {
|
||||
kanzleiAccID = id
|
||||
}
|
||||
}
|
||||
|
||||
creatorResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+creatorAccID)
|
||||
if creatorResp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", creatorResp.Code)
|
||||
}
|
||||
if strings.Contains(creatorResp.Body.String(), "verifizieren") {
|
||||
t.Errorf("expected no verify action for a non-kanzlei account, got: %s", creatorResp.Body.String())
|
||||
}
|
||||
|
||||
kanzleiResp := getWithCookie(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID)
|
||||
if kanzleiResp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", kanzleiResp.Code)
|
||||
}
|
||||
if !strings.Contains(kanzleiResp.Body.String(), "kanzlei@example.com") {
|
||||
t.Errorf("expected the kanzlei user's email on the account detail page, got: %s", kanzleiResp.Body.String())
|
||||
}
|
||||
if !strings.Contains(kanzleiResp.Body.String(), "/admin/accounts/"+kanzleiAccID+"/verifizieren") {
|
||||
t.Errorf("expected a verify action for a kanzlei account, got: %s", kanzleiResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminVerifyAddsAccountToPublicDirectoryAndAuditLog(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{}, fs)
|
||||
adminCookie := seedAccountWithRole(t, fs, "Deklarix Admin", "admin@example.com", "admin")
|
||||
seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
|
||||
|
||||
var kanzleiAccID string
|
||||
for id, acc := range fs.accounts {
|
||||
if acc.Name == "Kanzlei Musterfrau" {
|
||||
kanzleiAccID = id
|
||||
}
|
||||
}
|
||||
|
||||
// Vor der Freigabe taucht die Kanzlei nicht im oeffentlichen
|
||||
// Verzeichnis auf.
|
||||
before := getWithCookie(t, s, nil, "/kanzleien")
|
||||
if strings.Contains(before.Body.String(), "Kanzlei Musterfrau") {
|
||||
t.Fatalf("kanzlei should not be public before verification, got: %s", before.Body.String())
|
||||
}
|
||||
|
||||
verifyResp := postForm(t, s, adminCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
|
||||
if verifyResp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("verify status = %d, want 303, body: %s", verifyResp.Code, verifyResp.Body.String())
|
||||
}
|
||||
|
||||
after := getWithCookie(t, s, nil, "/kanzleien")
|
||||
if !strings.Contains(after.Body.String(), "Kanzlei Musterfrau") {
|
||||
t.Fatalf("expected the kanzlei to be listed publicly after verification, got: %s", after.Body.String())
|
||||
}
|
||||
|
||||
if len(fs.auditLog) != 1 {
|
||||
t.Fatalf("expected exactly one audit entry, got %d", len(fs.auditLog))
|
||||
}
|
||||
if fs.auditLog[0].Action != "account.verified" || fs.auditLog[0].TargetID != kanzleiAccID {
|
||||
t.Errorf("unexpected audit entry: %+v", fs.auditLog[0])
|
||||
}
|
||||
|
||||
auditPageResp := getWithCookie(t, s, adminCookie, "/admin/audit-log")
|
||||
if !strings.Contains(auditPageResp.Body.String(), "account.verified") {
|
||||
t.Errorf("expected the audit entry on the audit log page, got: %s", auditPageResp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminVerifyRejectsNonAdmin(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{}, fs)
|
||||
tenantCookie := seedAccountWithRole(t, fs, "Kanzlei Musterfrau", "kanzlei@example.com", "kanzlei")
|
||||
|
||||
var kanzleiAccID string
|
||||
for id, acc := range fs.accounts {
|
||||
if acc.Name == "Kanzlei Musterfrau" {
|
||||
kanzleiAccID = id
|
||||
}
|
||||
}
|
||||
|
||||
resp := postForm(t, s, tenantCookie, "/admin/accounts/"+kanzleiAccID+"/verifizieren", url.Values{"verified": {"true"}})
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for a non-admin actor", resp.Code)
|
||||
}
|
||||
if fs.accounts[kanzleiAccID].Verified {
|
||||
t.Fatal("account should not have been verified by a non-admin request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKanzleiDirectoryRequiresNoLogin(t *testing.T) {
|
||||
s, _, _ := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{Platform: "instagram"}})
|
||||
|
||||
req := getWithCookie(t, s, nil, "/kanzleien")
|
||||
if req.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 without any session", req.Code)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type submissionListItem struct {
|
||||
|
||||
type submissionListData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Submissions []submissionListItem
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ func (s *Server) handleSubmissionList(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
data := submissionListData{Title: "Beiträge"}
|
||||
data := submissionListData{Title: "Beiträge", Nav: navFor(r)}
|
||||
for _, sum := range summaries {
|
||||
data.Submissions = append(data.Submissions, submissionListItem{
|
||||
ID: sum.ID, Platform: sum.Platform, PostType: sum.PostType, Status: sum.Status,
|
||||
@@ -56,6 +57,7 @@ type participantView struct {
|
||||
|
||||
type submissionDetailData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
SubmissionID string
|
||||
Platform string
|
||||
PostType string
|
||||
@@ -125,7 +127,7 @@ func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
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"),
|
||||
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
|
||||
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"})
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/dossier"
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
"github.com/netcell-it/deklarix/internal/extract"
|
||||
"github.com/netcell-it/deklarix/internal/rules"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
// maxAssetSize begrenzt ein hochgeladenes Standbild auf 8 MiB — genug für
|
||||
// einen Screenshot, nicht genug, um den Server mit Uploads zu fluten.
|
||||
const maxAssetSize = 8 << 20
|
||||
|
||||
// uploadedAsset ist ein bereits gelesenes und geprüftes Standbild, das
|
||||
// nach dem Anlegen der Submission (die submission_id als Fremdschlüssel
|
||||
// braucht) tatsächlich gespeichert wird. Getrennt von storeAsset, damit
|
||||
// ein ungültiger Upload (falscher Typ, zu groß) *vor* dem Anlegen der
|
||||
// Submission scheitert, statt eine Beitrags-Zeile ohne Asset zu hinterlassen.
|
||||
type uploadedAsset struct {
|
||||
data []byte
|
||||
extension string
|
||||
sha256Hex string
|
||||
}
|
||||
|
||||
// readUploadedAsset liest das optionale "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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"ok":true}`)
|
||||
@@ -21,10 +92,12 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
type indexData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -52,8 +125,14 @@ type resultData struct {
|
||||
// veröffentlicht wurde — das sind unterschiedliche Zeitpunkte im
|
||||
// Lebenszyklus (siehe CLAUDE.md, Funktion 1 vs. 2).
|
||||
func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "ungültiges Formular", http.StatusBadRequest)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
|
||||
// ErrNotMultipart ist kein Fehlerfall: ParseMultipartForm ruft intern
|
||||
// zuerst ParseForm auf, das Formularfelder auch aus einem klassischen
|
||||
// urlencoded-Body liest (kein Standbild dabei, aber alle anderen
|
||||
// Felder sind trotzdem gültig) — nur ein wirklich kaputter oder zu
|
||||
// großer Body soll hier abbrechen.
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
|
||||
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,6 +145,12 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
asset, err := s.readUploadedAsset(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
accountID := currentUser(r).AccountID
|
||||
|
||||
@@ -86,6 +171,13 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
if err != nil {
|
||||
http.Error(w, "Extraktion konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
@@ -180,14 +272,33 @@ 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 {
|
||||
SubmissionID string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
Facts rules.Facts
|
||||
Findings []rules.Finding
|
||||
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings})
|
||||
SubmissionID string
|
||||
Platform string
|
||||
PostType string
|
||||
Caption string
|
||||
Facts rules.Facts
|
||||
Findings []rules.Finding
|
||||
AssetSHA256Hex string
|
||||
}{sub.ID, sub.Platform, sub.PostType, sub.Caption, facts, dossierFindings, assetSHA256Hex})
|
||||
if err != nil {
|
||||
http.Error(w, "Metadaten-Hash fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -217,6 +328,7 @@ func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
Facts: facts,
|
||||
Findings: dossierFindings,
|
||||
AssetHash: assetHash,
|
||||
MetadataHash: metadataHash,
|
||||
TimestampToken: timestampToken,
|
||||
GeneratedAt: time.Now(),
|
||||
|
||||
@@ -68,6 +68,41 @@ func (s *Server) requireAPI(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin schützt den Admin-Bereich. Ohne Sitzung geht es wie bei
|
||||
// requirePage zu /login; mit einer Sitzung, aber ohne Admin-Rolle, gibt
|
||||
// es 404 statt 403 — sonst würde eine 403 einem angemeldeten, aber
|
||||
// unprivilegierten Nutzer verraten, dass unter dieser URL überhaupt
|
||||
// etwas existiert.
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" {
|
||||
http.Error(w, "nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Kontext gelegt haben. Panics, wenn es aufgerufen wird, ohne dass eine
|
||||
// dieser Middlewares vorgeschaltet war — das ist ein Programmierfehler,
|
||||
|
||||
142
internal/web/oauth_handlers.go
Normal file
142
internal/web/oauth_handlers.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"github.com/netcell-it/deklarix/internal/store"
|
||||
)
|
||||
|
||||
const oauthStateCookieName = "deklarix_oauth_state"
|
||||
|
||||
// knownPlatforms sind alle Plattformen, die die Verbindungs-Übersicht
|
||||
// anzeigt — unabhängig davon, ob dafür schon ein Connector konfiguriert
|
||||
// ist (siehe cmd/deklarix/main.go). Eine unkonfigurierte Plattform zeigt
|
||||
// "nicht konfiguriert" statt eines Verbinden-Buttons.
|
||||
var knownPlatforms = []string{"instagram", "tiktok"}
|
||||
|
||||
type connectionView struct {
|
||||
Platform string
|
||||
Configured bool
|
||||
Connected bool
|
||||
ConnectedAt string
|
||||
}
|
||||
|
||||
type connectionsData struct {
|
||||
Title string
|
||||
Nav navData
|
||||
Connections []connectionView
|
||||
}
|
||||
|
||||
// handleConnectionsList zeigt, welche Plattformen der Account verbunden
|
||||
// hat — Grundlage für die spätere automatische Beweissicherung
|
||||
// (Post per API statt manuellem Screenshot-Upload abrufen).
|
||||
func (s *Server) handleConnectionsList(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
existing, err := s.store.ListPlatformConnectionsForAccount(r.Context(), accountID)
|
||||
if err != nil {
|
||||
http.Error(w, "Verbindungen konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
connectedAt := map[string]string{}
|
||||
for _, c := range existing {
|
||||
connectedAt[c.Platform] = c.ConnectedAt.Format("02.01.2006 15:04")
|
||||
}
|
||||
|
||||
data := connectionsData{Title: "Verbindungen", Nav: navFor(r)}
|
||||
for _, platform := range knownPlatforms {
|
||||
_, configured := s.connectors[platform]
|
||||
at, connected := connectedAt[platform]
|
||||
data.Connections = append(data.Connections, connectionView{
|
||||
Platform: platform, Configured: configured, Connected: connected, ConnectedAt: at,
|
||||
})
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "verbindungen", data); err != nil {
|
||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleOAuthStart leitet zum Consent-Screen der Plattform weiter. Der
|
||||
// state-Wert wird in einem kurzlebigen Cookie gehalten und beim
|
||||
// Callback gegengeprüft — Schutz gegen CSRF (ein Angreifer könnte sonst
|
||||
// einen fremden Autorisierungscode gegen das Konto des Opfers
|
||||
// einschleusen).
|
||||
func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
|
||||
connector, ok := s.connectors[r.PathValue("platform")]
|
||||
if !ok {
|
||||
http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := auth.NewSessionToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Anfrage konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oauthStateCookieName, Value: state, Path: "/oauth",
|
||||
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
http.Redirect(w, r, connector.AuthorizationURL(state), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleOAuthCallback verarbeitet die Rückleitung von der Plattform:
|
||||
// state prüfen, Code gegen ein Token tauschen, Verbindung speichern.
|
||||
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
connector, ok := s.connectors[r.PathValue("platform")]
|
||||
if !ok {
|
||||
http.Error(w, "Plattform nicht konfiguriert", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Der Nutzer hat die Autorisierung abgelehnt — kein Fehler unsererseits.
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
stateCookie, err := r.Cookie(oauthStateCookieName)
|
||||
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
|
||||
http.Error(w, "ungültiger oder abgelaufener State-Parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: oauthStateCookieName, Value: "", Path: "/oauth", MaxAge: -1})
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "kein Autorisierungscode erhalten", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := connector.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
http.Error(w, "Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if !token.ExpiresAt.IsZero() {
|
||||
expiresAt = &token.ExpiresAt
|
||||
}
|
||||
accountID := currentUser(r).AccountID
|
||||
if _, err := s.store.UpsertPlatformConnection(r.Context(), accountID, connector.Platform(), token.PlatformUserID, token.AccessToken, token.RefreshToken, expiresAt); err != nil {
|
||||
http.Error(w, "Verbindung konnte nicht gespeichert werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleDisconnect trennt eine Plattform-Verbindung.
|
||||
func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||
accountID := currentUser(r).AccountID
|
||||
platform := r.PathValue("platform")
|
||||
if err := s.store.DeletePlatformConnection(r.Context(), accountID, platform); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "Verbindung konnte nicht getrennt werden: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/verbindungen", http.StatusSeeOther)
|
||||
}
|
||||
225
internal/web/oauth_handlers_test.go
Normal file
225
internal/web/oauth_handlers_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netcell-it/deklarix/internal/socialconnect"
|
||||
)
|
||||
|
||||
// fakeConnector ist ein socialconnect.Connector-Fake für Tests — kein
|
||||
// echter HTTP-Aufruf gegen Instagram/TikTok nötig.
|
||||
type fakeConnector struct {
|
||||
platform string
|
||||
token socialconnect.Token
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeConnector) Platform() string { return f.platform }
|
||||
func (f fakeConnector) AuthorizationURL(state string) string {
|
||||
return "https://provider.example/authorize?state=" + state + "&platform=" + f.platform
|
||||
}
|
||||
func (f fakeConnector) Exchange(ctx context.Context, code string) (socialconnect.Token, error) {
|
||||
if f.err != nil {
|
||||
return socialconnect.Token{}, f.err
|
||||
}
|
||||
return f.token, nil
|
||||
}
|
||||
|
||||
func TestConnectionsListShowsUnconfiguredPlatformsWithoutConnectButton(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/verbindungen")
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
body := resp.Body.String()
|
||||
if strings.Contains(body, "/oauth/instagram/start") || strings.Contains(body, "/oauth/tiktok/start") {
|
||||
t.Errorf("expected no connect links when no connector is configured, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "instagram") || !strings.Contains(body, "tiktok") {
|
||||
t.Errorf("expected both platforms to be listed regardless of configuration, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionsListShowsConnectButtonWhenConfigured(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
||||
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
||||
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/verbindungen")
|
||||
body := resp.Body.String()
|
||||
if !strings.Contains(body, "/oauth/instagram/start") {
|
||||
t.Errorf("expected an Instagram connect link, got: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "/oauth/tiktok/start") {
|
||||
t.Errorf("expected no TikTok connect link (not configured), got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartRedirectsToProviderAndSetsStateCookie(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
||||
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
||||
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/start", nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", w.Code)
|
||||
}
|
||||
loc := w.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "https://provider.example/authorize?") {
|
||||
t.Fatalf("Location = %q, want a redirect to the provider", loc)
|
||||
}
|
||||
var stateCookie *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == "deklarix_oauth_state" {
|
||||
stateCookie = c
|
||||
}
|
||||
}
|
||||
if stateCookie == nil || stateCookie.Value == "" {
|
||||
t.Fatal("expected a non-empty deklarix_oauth_state cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartRejectsUnconfiguredPlatform(t *testing.T) {
|
||||
s, _, cookie := newAuthedTestServer(t, fakeExtractor{})
|
||||
|
||||
resp := getWithCookie(t, s, cookie, "/oauth/instagram/start")
|
||||
if resp.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 for an unconfigured platform", resp.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackStoresConnectionOnValidState(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
expires := time.Now().Add(60 * 24 * time.Hour)
|
||||
connectors := map[string]socialconnect.Connector{
|
||||
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{
|
||||
AccessToken: "ig-token", PlatformUserID: "ig-user-1", ExpiresAt: expires,
|
||||
}},
|
||||
}
|
||||
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
||||
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=gueltiger-state", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "gueltiger-state"})
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if loc := w.Header().Get("Location"); loc != "/verbindungen" {
|
||||
t.Fatalf("Location = %q, want /verbindungen", loc)
|
||||
}
|
||||
|
||||
var accID string
|
||||
for id := range fs.accounts {
|
||||
accID = id
|
||||
}
|
||||
conn, err := fs.GetPlatformConnection(context.Background(), accID, "instagram")
|
||||
if err != nil {
|
||||
t.Fatalf("expected a stored connection, got err: %v", err)
|
||||
}
|
||||
if conn.AccessToken != "ig-token" || conn.PlatformUserID != "ig-user-1" {
|
||||
t.Errorf("unexpected connection: %+v", conn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackRejectsMismatchedState(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
connectors := map[string]socialconnect.Connector{
|
||||
"instagram": fakeConnector{platform: "instagram", token: socialconnect.Token{AccessToken: "x", PlatformUserID: "y"}},
|
||||
}
|
||||
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
||||
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?code=der-code&state=falscher-state", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.AddCookie(&http.Cookie{Name: "deklarix_oauth_state", Value: "anderer-state"})
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 for a state mismatch", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackHandlesUserDenial(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
connectors := map[string]socialconnect.Connector{
|
||||
"instagram": fakeConnector{platform: "instagram"},
|
||||
}
|
||||
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
||||
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/oauth/instagram/callback?error=access_denied", nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
s.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303 (redirect back, no error page)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisconnectRemovesConnection(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
||||
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
||||
cookie := seedAccount(t, fs, "Mandant", "mandant@example.com")
|
||||
var accID string
|
||||
for id := range fs.accounts {
|
||||
accID = id
|
||||
}
|
||||
if _, err := fs.UpsertPlatformConnection(context.Background(), accID, "instagram", "u1", "tok", "", nil); err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection: %v", err)
|
||||
}
|
||||
|
||||
resp := postForm(t, s, cookie, "/verbindungen/instagram/trennen", url.Values{})
|
||||
if resp.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
|
||||
}
|
||||
if _, err := fs.GetPlatformConnection(context.Background(), accID, "instagram"); err == nil {
|
||||
t.Fatal("expected the connection to be gone after disconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionsIsolatedPerTenant(t *testing.T) {
|
||||
fs := newFakeStore()
|
||||
connectors := map[string]socialconnect.Connector{"instagram": fakeConnector{platform: "instagram"}}
|
||||
s := newServerWithConnectors(t, fakeExtractor{}, fs, connectors)
|
||||
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||
|
||||
var accA string
|
||||
for id, acc := range fs.accounts {
|
||||
if acc.Name == "Mandant A" {
|
||||
accA = id
|
||||
}
|
||||
}
|
||||
if _, err := fs.UpsertPlatformConnection(context.Background(), accA, "instagram", "u1", "tok", "", nil); err != nil {
|
||||
t.Fatalf("UpsertPlatformConnection: %v", err)
|
||||
}
|
||||
|
||||
respA := getWithCookie(t, s, cookieA, "/verbindungen")
|
||||
if !strings.Contains(respA.Body.String(), "verbunden seit") {
|
||||
t.Errorf("expected Mandant A to see their own connection, got: %s", respA.Body.String())
|
||||
}
|
||||
respB := getWithCookie(t, s, cookieB, "/verbindungen")
|
||||
if strings.Contains(respB.Body.String(), "verbunden seit") {
|
||||
t.Errorf("expected Mandant B to see no connection, got: %s", respB.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -57,12 +58,25 @@ type Store interface {
|
||||
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, path, sha256Hex string) (store.Asset, error)
|
||||
GetLatestAssetForSubmission(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.
|
||||
@@ -73,13 +87,20 @@ type Server struct {
|
||||
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.
|
||||
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir string) (*Server, error) {
|
||||
// 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)
|
||||
@@ -91,6 +112,8 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
|
||||
store: st,
|
||||
timestamper: timestamper,
|
||||
dossierDir: dossierDir,
|
||||
assetDir: assetDir,
|
||||
connectors: connectors,
|
||||
templates: tmpl,
|
||||
}
|
||||
|
||||
@@ -110,6 +133,16 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
|
||||
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("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
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/netcell-it/deklarix/internal/auth"
|
||||
"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"
|
||||
"github.com/netcell-it/deklarix/internal/web"
|
||||
)
|
||||
@@ -67,19 +68,27 @@ type fakeStore struct {
|
||||
findings map[string][]store.Finding
|
||||
evidencePkgs map[string]store.EvidencePackage
|
||||
participants map[string]store.Participant
|
||||
auditLog []store.AuditEntry
|
||||
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset
|
||||
|
||||
// platformConnections ist verschachtelt nach accountID -> platform,
|
||||
// wie die UNIQUE(account_id, platform)-Beschränkung der echten Tabelle.
|
||||
platformConnections map[string]map[string]store.PlatformConnection
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
accounts: map[string]store.Account{},
|
||||
users: map[string]store.User{},
|
||||
usersByEmail: map[string]string{},
|
||||
sessions: map[string]store.Session{},
|
||||
submissions: map[string]store.Submission{},
|
||||
extractions: map[string]store.Extraction{},
|
||||
findings: map[string][]store.Finding{},
|
||||
evidencePkgs: map[string]store.EvidencePackage{},
|
||||
participants: map[string]store.Participant{},
|
||||
accounts: map[string]store.Account{},
|
||||
users: map[string]store.User{},
|
||||
usersByEmail: map[string]string{},
|
||||
sessions: map[string]store.Session{},
|
||||
submissions: map[string]store.Submission{},
|
||||
extractions: map[string]store.Extraction{},
|
||||
findings: map[string][]store.Finding{},
|
||||
evidencePkgs: map[string]store.EvidencePackage{},
|
||||
participants: map[string]store.Participant{},
|
||||
assets: map[string]store.Asset{},
|
||||
platformConnections: map[string]map[string]store.PlatformConnection{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +105,98 @@ func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Accou
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetAccount(ctx context.Context, id string) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc, ok := f.accounts[id]
|
||||
if !ok {
|
||||
return store.Account{}, store.ErrNotFound
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAccounts(ctx context.Context) ([]store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Account
|
||||
for _, acc := range f.accounts {
|
||||
out = append(out, acc)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListVerifiedKanzleien(ctx context.Context) ([]store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Account
|
||||
for _, acc := range f.accounts {
|
||||
if !acc.Verified {
|
||||
continue
|
||||
}
|
||||
hasKanzlei := false
|
||||
for _, u := range f.users {
|
||||
if u.AccountID == acc.ID && u.Role == "kanzlei" {
|
||||
hasKanzlei = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasKanzlei {
|
||||
out = append(out, acc)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetAccountVerified(ctx context.Context, id string, verified bool) (store.Account, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
acc, ok := f.accounts[id]
|
||||
if !ok {
|
||||
return store.Account{}, store.ErrNotFound
|
||||
}
|
||||
acc.Verified = verified
|
||||
f.accounts[id] = acc
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListUsersForAccount(ctx context.Context, accountID string) ([]store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.User
|
||||
for _, u := range f.users {
|
||||
if u.AccountID == accountID {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
e := store.AuditEntry{
|
||||
ID: f.newID(), ActorUserID: actorUserID, Action: action, TargetType: targetType,
|
||||
TargetID: targetID, Details: details, CreatedAt: time.Now(),
|
||||
}
|
||||
f.auditLog = append(f.auditLog, e)
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
// Neueste zuerst, wie die echte Store-Implementierung (ORDER BY
|
||||
// created_at DESC) — hier reicht eine Umkehrung der Einfuegereihenfolge.
|
||||
out := make([]store.AuditEntry, len(f.auditLog))
|
||||
for i, e := range f.auditLog {
|
||||
out[len(f.auditLog)-1-i] = e
|
||||
}
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateUser(ctx context.Context, accountID, email, passwordHash, role string) (store.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -249,6 +350,79 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
|
||||
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) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
byPlatform, ok := f.platformConnections[accountID]
|
||||
if !ok {
|
||||
byPlatform = map[string]store.PlatformConnection{}
|
||||
f.platformConnections[accountID] = byPlatform
|
||||
}
|
||||
c := store.PlatformConnection{
|
||||
ID: f.newID(), AccountID: accountID, Platform: platform, PlatformUserID: platformUserID,
|
||||
AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, ConnectedAt: time.Now(),
|
||||
}
|
||||
if existing, ok := byPlatform[platform]; ok {
|
||||
c.ID = existing.ID
|
||||
}
|
||||
byPlatform[platform] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.PlatformConnection
|
||||
for _, c := range f.platformConnections[accountID] {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetPlatformConnection(ctx context.Context, accountID, platform string) (store.PlatformConnection, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
c, ok := f.platformConnections[accountID][platform]
|
||||
if !ok {
|
||||
return store.PlatformConnection{}, store.ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeletePlatformConnection(ctx context.Context, accountID, platform string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
byPlatform, ok := f.platformConnections[accountID]
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
if _, ok := byPlatform[platform]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(byPlatform, platform)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -407,7 +581,12 @@ func loadRealRules(t *testing.T) []rules.Rule {
|
||||
|
||||
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
|
||||
t.Helper()
|
||||
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir())
|
||||
return newServerWithConnectors(t, ex, fs, nil)
|
||||
}
|
||||
|
||||
func newServerWithConnectors(t *testing.T, ex web.Extractor, fs *fakeStore, connectors map[string]socialconnect.Connector) *web.Server {
|
||||
t.Helper()
|
||||
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), t.TempDir(), connectors)
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
@@ -417,6 +596,11 @@ func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
|
||||
// seedAccount legt direkt im fakeStore (ohne HTTP) einen Account, einen
|
||||
// Nutzer und eine gültige Sitzung an und liefert das Session-Cookie.
|
||||
func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.Cookie {
|
||||
t.Helper()
|
||||
return seedAccountWithRole(t, fs, accountName, email, "creator")
|
||||
}
|
||||
|
||||
func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc, err := fs.CreateAccount(ctx, accountName)
|
||||
@@ -427,7 +611,7 @@ func seedAccount(t *testing.T, fs *fakeStore, accountName, email string) *http.C
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
user, err := fs.CreateUser(ctx, acc.ID, email, hash, "creator")
|
||||
user, err := fs.CreateUser(ctx, acc.ID, email, hash, role)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
@@ -585,6 +769,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) {
|
||||
fs := newFakeStore()
|
||||
s := newServer(t, fakeExtractor{}, fs)
|
||||
|
||||
@@ -260,7 +260,8 @@ nav button {
|
||||
color: var(--color-niedrig);
|
||||
}
|
||||
|
||||
.status-checked {
|
||||
.status-checked,
|
||||
.status-mittel {
|
||||
background: var(--color-mittel-bg);
|
||||
color: var(--color-mittel);
|
||||
}
|
||||
@@ -352,6 +353,37 @@ button.entfernen {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-kacheln {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-kacheln a {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow);
|
||||
background: #fff;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.kanzlei-eintrag {
|
||||
display: block;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Ab hier mehr Platz (Tablet/Desktop) — der Container bekommt spürbaren
|
||||
Rand statt volle Breite, sonst bleibt alles identisch. */
|
||||
@media (min-width: 640px) {
|
||||
|
||||
39
internal/web/templates/admin_account_detail.html
Normal file
39
internal/web/templates/admin_account_detail.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{{define "admin-account-detail"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/admin/accounts">← Alle Accounts</a></p>
|
||||
<h1>{{.Name}}</h1>
|
||||
<p class="hinweis">Angelegt am {{.CreatedAt}}</p>
|
||||
|
||||
<h2>Nutzer</h2>
|
||||
<ul class="beteiligte">
|
||||
{{range .Users}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">{{.Email}} <span class="rolle">({{.Role}})</span></div>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
{{if .HasKanzlei}}
|
||||
<h2>Kanzlei-Verzeichnis</h2>
|
||||
{{if .Verified}}
|
||||
<p>Status: <span class="status status-published">verifiziert — im öffentlichen Verzeichnis gelistet</span></p>
|
||||
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
|
||||
<input type="hidden" name="verified" value="false">
|
||||
<button type="submit">Freigabe entziehen</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p>Status: <span class="status status-mittel">nicht verifiziert — noch nicht gelistet</span></p>
|
||||
<form method="post" action="/admin/accounts/{{.AccountID}}/verifizieren">
|
||||
<input type="hidden" name="verified" value="true">
|
||||
<button type="submit">Fürs Verzeichnis freigeben</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
30
internal/web/templates/admin_accounts.html
Normal file
30
internal/web/templates/admin_accounts.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{define "admin-accounts"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/admin">← Admin</a></p>
|
||||
<h1>Accounts</h1>
|
||||
{{if not .Accounts}}
|
||||
<p class="hinweis">Noch keine Accounts.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Accounts}}
|
||||
<li>
|
||||
<a href="/admin/accounts/{{.ID}}">
|
||||
<strong>{{.Name}}</strong> · {{.CreatedAt}}
|
||||
{{if .Verified}}
|
||||
<span class="status status-published">verifiziert</span>
|
||||
{{else}}
|
||||
<span class="status status-mittel">nicht verifiziert</span>
|
||||
{{end}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
24
internal/web/templates/admin_audit_log.html
Normal file
24
internal/web/templates/admin_audit_log.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{{define "admin-audit-log"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/admin">← Admin</a></p>
|
||||
<h1>Audit-Log</h1>
|
||||
{{if not .Entries}}
|
||||
<p class="hinweis">Noch keine Einträge.</p>
|
||||
{{else}}
|
||||
<ul class="beteiligte">
|
||||
{{range .Entries}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">{{.CreatedAt}} — <strong>{{.Action}}</strong> ({{.TargetType}} {{.TargetID}})</div>
|
||||
{{if .Details}}<p>{{.Details}}</p>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
17
internal/web/templates/admin_dashboard.html
Normal file
17
internal/web/templates/admin_dashboard.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{{define "admin-dashboard"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Admin</h1>
|
||||
<ul class="admin-kacheln">
|
||||
<li><a href="/admin/accounts">Accounts <span class="status">{{.AccountCount}}</span></a></li>
|
||||
<li><a href="/admin/accounts">Unverifizierte Kanzleien <span class="status status-mittel">{{.UnverifiedCount}}</span></a></li>
|
||||
<li><a href="/admin/audit-log">Audit-Log <span class="status">{{.RecentAuditCount}} neueste</span></a></li>
|
||||
<li><a href="/kanzleien">Öffentliches Kanzlei-Verzeichnis ansehen</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Beiträge</h1>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<p><a href="/beitraege">← Alle Beiträge</a></p>
|
||||
<h1>{{.Platform}} · {{.PostType}}</h1>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .}}
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Pre-Publish-Prüfung</h1>
|
||||
<p>Caption und Plattform eingeben, um auf Kennzeichnungsrisiken zu prüfen.</p>
|
||||
|
||||
<form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML">
|
||||
<form hx-post="/pruefen" hx-target="#ergebnis" hx-swap="innerHTML" hx-encoding="multipart/form-data" enctype="multipart/form-data">
|
||||
<label for="platform">Plattform</label>
|
||||
<select id="platform" name="platform" required>
|
||||
<option value="instagram">Instagram</option>
|
||||
@@ -38,6 +38,13 @@
|
||||
<label for="caption">Caption</label>
|
||||
<textarea id="caption" name="caption" rows="6" required></textarea>
|
||||
|
||||
<label for="standbild">Standbild (optional)</label>
|
||||
<input type="file" id="standbild" name="standbild" accept="image/*">
|
||||
<p class="hinweis">
|
||||
Screenshot des veröffentlichten Beitrags — wird Teil des
|
||||
Nachweis-Dossiers, sobald der Beitrag archiviert wird.
|
||||
</p>
|
||||
|
||||
<button type="submit">Prüfen</button>
|
||||
</form>
|
||||
|
||||
|
||||
23
internal/web/templates/kanzleien.html
Normal file
23
internal/web/templates/kanzleien.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{{define "kanzleien"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>Kanzlei-Verzeichnis</h1>
|
||||
<p class="hinweis">
|
||||
Kostenloses Verzeichnis auf Deklarix verifizierter Kanzleien. Keine
|
||||
Vermittlung, keine Empfehlung — die Auswahl trifft allein der Nutzer.
|
||||
</p>
|
||||
{{if not .Kanzleien}}
|
||||
<p class="hinweis">Noch keine Kanzlei gelistet.</p>
|
||||
{{else}}
|
||||
<ul class="beitraege-liste">
|
||||
{{range .Kanzleien}}
|
||||
<li><span class="kanzlei-eintrag">{{.Name}}</span></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -10,6 +10,8 @@
|
||||
<nav>
|
||||
<a href="/">Prüfen</a>
|
||||
<a href="/beitraege">Beiträge</a>
|
||||
<a href="/verbindungen">Verbindungen</a>
|
||||
{{if .IsAdmin}}<a href="/admin">Admin</a>{{end}}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
</form>
|
||||
|
||||
41
internal/web/templates/verbindungen.html
Normal file
41
internal/web/templates/verbindungen.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{{define "verbindungen"}}<!doctype html>
|
||||
<html lang="de">
|
||||
<head>{{template "head" .}}</head>
|
||||
<body>
|
||||
{{template "nav" .Nav}}
|
||||
<div class="page">
|
||||
<h1>Verbindungen</h1>
|
||||
<p class="hinweis">
|
||||
Verbinde deinen eigenen Instagram- oder TikTok-Account, damit die
|
||||
Beweissicherung veröffentlichte Beiträge künftig direkt abrufen kann.
|
||||
Ohne Verbindung funktioniert die Prüfung wie gewohnt mit manuellem
|
||||
Screenshot-Upload.
|
||||
</p>
|
||||
|
||||
<ul class="beteiligte">
|
||||
{{range .Connections}}
|
||||
<li class="beteiligter">
|
||||
<div class="beteiligter-kopf">
|
||||
<strong>{{.Platform}}</strong>
|
||||
{{if .Connected}}
|
||||
<span class="status status-published">verbunden seit {{.ConnectedAt}}</span>
|
||||
{{else if .Configured}}
|
||||
<span class="status status-mittel">nicht verbunden</span>
|
||||
{{else}}
|
||||
<span class="status">noch nicht konfiguriert</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Connected}}
|
||||
<form method="post" action="/verbindungen/{{.Platform}}/trennen">
|
||||
<button type="submit" class="entfernen">Trennen</button>
|
||||
</form>
|
||||
{{else if .Configured}}
|
||||
<p><a href="/oauth/{{.Platform}}/start">Verbinden</a></p>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -13,11 +13,25 @@ RULES_DIR=/usr/share/deklarix/rules
|
||||
# Archivieren eines Beitrags automatisch angelegt.
|
||||
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
|
||||
# eIDAS-qualifiziert — siehe CLAUDE.md, Offene Punkte). Vor echtem
|
||||
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
|
||||
#TSA_URL=https://freetsa.org/tsr
|
||||
|
||||
# Optionale Plattform-Verbindung (OAuth, siehe CLAUDE.md "Plattform-
|
||||
# Verbindung (OAuth)"). Ohne PUBLIC_BASE_URL und die Credentials der
|
||||
# jeweiligen Plattform zeigt /verbindungen nur "nicht konfiguriert" —
|
||||
# kein Absturz, keine dieser vier Variablen ist Pflicht.
|
||||
#PUBLIC_BASE_URL=https://app.deklarix.de
|
||||
#INSTAGRAM_CLIENT_ID=
|
||||
#INSTAGRAM_CLIENT_SECRET=
|
||||
#TIKTOK_CLIENT_KEY=
|
||||
#TIKTOK_CLIENT_SECRET=
|
||||
|
||||
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
|
||||
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
|
||||
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||
|
||||
Reference in New Issue
Block a user