Compare commits

...

2 Commits

Author SHA1 Message Date
noroot
6fd7831784 feat: Erinnerung zum Sichern von Story-Insights vor Ablauf
Ausgangspunkt: Instagram hält Story-Insights nach eigener Aussage nur
24 Stunden vor, auch der offizielle Datenexport enthält sie nicht mehr
danach. Der Standbild-Screenshot beim Prüfen entsteht direkt beim
Veröffentlichen, bevor nennenswerte Kennzahlen existieren — er kann
das strukturell nicht auffangen. Eine OAuth-Anbindung allein löst das
auch nicht: selbst mit API-Zugriff bräuchte es einen Abruf innerhalb
desselben 24h-Fensters.

- Migration 0007: asset.purpose ('initial' | 'insights', Default
  'initial' erhält die Bedeutung aller Bestandszeilen). Ein Beitrag
  kann jetzt mehrere Insights-Nachweise über die Zeit bekommen.
  GetLatestAssetForSubmission berücksichtigt weiterhin nur 'initial',
  damit ein späterer Insights-Upload nie den beim Archivieren
  referenzierten Original-Screenshot verdrängt.
- internal/web/insights_reminder.go: computeInsightsReminder — reine,
  ungetestete gegen echte Instagram-Daten, aber isoliert testbare
  Logik fürs Erinnerungs-Timing (Produktentscheidung, keine Rechtsnorm,
  daher nicht in rules/*.yaml).
- GET /beitraege/{id} zeigt die Erinnerung bei veröffentlichten
  "story"-Beiträgen ohne existierendes insights-Asset; POST
  /beitraege/{id}/insights speichert einen weiteren Screenshot (gleiche
  Validierung wie das initiale Standbild, wiederverwendet über
  readUploadedAsset/storeAsset mit purpose-Parameter).
- Bewusst nur In-App-Banner in dieser Ausbaustufe, kein Mail-/Push-
  Versand — dafür fehlt aktuell ein SMTP-Relay/Versanddienst, siehe
  CLAUDE.md-Hinweis dazu.

Volle Testsuite inkl. echter Postgres-Tests grün; End-to-End gegen
einen laufenden Server verifiziert (Story archivieren → Erinnerung
sichtbar → Insights-Upload → Erinnerung verschwindet, Nachweis
gelistet, Mandantentrennung beim Upload durchgesetzt).
2026-08-28 10:10:44 +02:00
noroot
5813e6209c feat: technisches Grundgerüst für Instagram-/TikTok-OAuth (Plattform-Verbindung)
Vorbereitung für automatische Beweissicherung statt manuellem
Screenshot-Upload: ein Kunde kann künftig seinen eigenen Instagram-
oder TikTok-Account per Standard-OAuth-Consent verbinden. Bewusst nur
das Grundgerüst — Meta/TikTok verlangen vor öffentlicher Nutzung eine
einmalige Business-Verification/App-Review (Wochen Vorlauf, siehe
CLAUDE.md-Abschnitt "Plattform-Verbindung (OAuth)"), die separat von
dieser Codeänderung läuft.

- internal/socialconnect: Connector-Interface + InstagramConnector/
  TikTokConnector (reiner Authorization-Code-Flow, kein DB-Zugriff).
  Instagram tauscht den Code zweistufig (kurzlebiges → 60-Tage-Token),
  TikTok liefert Access-/Refresh-Token direkt. Endpunkte/Scopes wurden
  gegen aktuelle Entwicklerdokumentation gebaut, nie gegen die echte
  API verifiziert (keine Zugangsdaten vorhanden) — Hinweis dazu im
  Paket- und CLAUDE.md-Kommentar.
- Migration 0006: platform_connection (NICHT append-only, anders als
  finding/extraction/asset — ein Token wird ersetzt, keine Korrektur-
  Zeile), höchstens eine Verbindung pro Account+Plattform.
- internal/web: GET /verbindungen (Übersicht je Plattform: verbunden/
  nicht verbunden/nicht konfiguriert), GET /oauth/{platform}/start
  (State-Cookie gegen CSRF, Redirect zum Consent-Screen),
  GET /oauth/{platform}/callback (State prüfen, Code tauschen,
  Verbindung speichern), POST /verbindungen/{platform}/trennen.
- Ohne gesetzte Client-Credentials + PUBLIC_BASE_URL bleibt die
  Funktion inaktiv (kein Connector konfiguriert, /verbindungen zeigt
  "nicht konfiguriert", kein Absturz) — main.go loggt das beim Start.

Volle Testsuite inkl. echter Postgres-Tests grün; OAuth-Flow gegen
Fake-Connector/httptest-Server verifiziert (State-Mismatch, Ablehnung
durch Nutzer, Token-Speicherung, Mandantentrennung). Kein Live-Test
gegen echte Meta-/TikTok-Endpunkte möglich, da noch keine echten
Client-Credentials existieren.
2026-08-28 09:32:43 +02:00
28 changed files with 1757 additions and 70 deletions

View File

@@ -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
@@ -167,10 +168,14 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
Account wie verändert); append-only aus demselben Grund wie
`finding`/`extraction`/`evidence_package`
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
- `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
- `asset` — hochgeladenes Standbild, Pfad, SHA-256; append-only aus
demselben Grund wie `finding`/`extraction`/`evidence_package` — ein
Beweisstück wird nicht nachträglich ausgetauscht. `purpose` = `initial`
(das Beweisfoto beim Prüfen, optional) oder `insights` (siehe
„Insights-Erinnerung" unten; ein Beitrag kann mehrere `insights`-Assets
über die Zeit bekommen). `GetLatestAssetForSubmission` berücksichtigt
nur `initial`, damit ein späterer Insights-Upload nie den beim
Archivieren referenzierten Original-Screenshot verdrängt
- `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,
@@ -180,6 +185,12 @@ 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`, `asset`,
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
@@ -203,6 +214,58 @@ 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. 24 Wochen; TikTok-Audit: ca. 12
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.
**Insights-Erinnerung:** Story-Insights hält Instagram nach eigener
Aussage nur 24 Stunden vor — danach sind sie auch über den offiziellen
Datenexport nicht mehr zu bekommen, und der ursprüngliche Standbild-
Screenshot beim Prüfen (der direkt beim Veröffentlichen entsteht, bevor
nennenswerte Kennzahlen existieren) kann sie naturgemäß nicht erfassen.
`GET /beitraege/{id}` zeigt deshalb bei veröffentlichten `story`-
Beiträgen eine Erinnerung, solange keine `insights`-Asset existiert
(`internal/web/insights_reminder.go`, `computeInsightsReminder` —
reine Produktentscheidung zum Erinnerungs-Timing, keine Rechtsnorm,
daher bewusst nicht in `rules/*.yaml`). `POST /beitraege/{id}/insights`
speichert einen zusätzlichen Screenshot als `asset` mit
`purpose='insights'`, gehasht wie jedes andere Beweisstück — aber
NICHT im Metadaten-Hash des ursprünglichen Dossiers enthalten (das
wird beim Archivieren einmalig fixiert). Bewusst nur ein In-App-
Banner in dieser ersten Ausbaustufe, kein Mail-/Push-Versand — dafür
fehlt aktuell ein SMTP-Relay/Versanddienst; vor einer echten
Benachrichtigung per E-Mail ist das eine offene Rückfrage (welcher
Versanddienst, welche Absenderdomain/SPF/DKIM).
---
## Go Commands
@@ -396,7 +459,9 @@ sudo systemctl start deklarix
sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, ASSET_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

View File

@@ -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"
)
@@ -53,7 +54,21 @@ func main() {
assetDir = "assets"
}
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir)
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)
}

View 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
}

View 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
}

View 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")
}
}

View 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
}

View 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)
}
}

View File

@@ -9,14 +9,17 @@ import (
"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.
// Asset ist eine zu einem Beitrag hochgeladene Datei. Append-only wie
// extraction/finding/evidence_package — ein hochgeladenes Beweisstück
// wird nicht nachträglich ausgetauscht, siehe Migration. Purpose
// unterscheidet das ursprüngliche Beweisfoto beim Prüfen ("initial")
// von einem späteren Nachweis flüchtiger Kennzahlen ("insights") —
// siehe Migration 0007 und CLAUDE.md, Abschnitt Insights-Erinnerung.
type Asset struct {
ID string
SubmissionID string
Kind string
Purpose string
Path string
SHA256 string
CreatedAt time.Time
@@ -25,14 +28,14 @@ type Asset struct {
// 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) {
func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, purpose, 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,
INSERT INTO asset (submission_id, kind, purpose, path, sha256)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, submission_id, kind, purpose, path, sha256, created_at
`, submissionID, kind, purpose, path, sha256Hex).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
)
if err != nil {
return Asset{}, fmt.Errorf("store: create asset: %w", err)
@@ -41,19 +44,23 @@ func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha25
}
// 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.
// mit purpose="initial" eines Beitrags — bewusst ohne spätere
// "insights"-Assets, damit ein erneutes Archivieren immer denselben
// ursprünglichen Beweis referenziert, egal wie viele Insights-
// Screenshots danach noch hinzukommen. Liefert ErrNotFound, wenn keins
// 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
SELECT id, submission_id, kind, purpose, path, sha256, created_at
FROM asset
WHERE submission_id = $1
WHERE submission_id = $1 AND purpose = 'initial'
ORDER BY created_at DESC
LIMIT 1
`, submissionID).Scan(
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Asset{}, ErrNotFound
@@ -63,3 +70,29 @@ func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID st
}
return a, nil
}
// ListAssetsForSubmission liefert alle Assets eines Beitrags
// (initiales Standbild und alle Insights-Nachweise), älteste zuerst.
func (s *Store) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]Asset, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, submission_id, kind, purpose, path, sha256, created_at
FROM asset WHERE submission_id = $1 ORDER BY created_at
`, submissionID)
if err != nil {
return nil, fmt.Errorf("store: list assets for submission: %w", err)
}
defer rows.Close()
var out []Asset
for rows.Next() {
var a Asset
if err := rows.Scan(&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan asset: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list assets for submission: %w", err)
}
return out, nil
}

View File

@@ -17,11 +17,11 @@ func TestAssetCreateAndGetLatest(t *testing.T) {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}
if a.SubmissionID != sub.ID || a.Kind != "image" {
if a.SubmissionID != sub.ID || a.Kind != "image" || a.Purpose != "initial" {
t.Fatalf("CreateAsset = %+v, unerwartete Werte", a)
}
@@ -58,10 +58,10 @@ func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
t.Fatalf("CreateSubmission: %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/erstes.jpg", "erstehash"); err != nil {
if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/erstes.jpg", "erstehash"); err != nil {
t.Fatalf("CreateAsset (1): %v", err)
}
second, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/zweites.jpg", "zweitehash")
second, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/zweites.jpg", "zweitehash")
if err != nil {
t.Fatalf("CreateAsset (2): %v", err)
}
@@ -75,6 +75,59 @@ func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
}
}
func TestGetLatestAssetForSubmissionIgnoresInsightsAssets(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
initial, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "initialhash")
if err != nil {
t.Fatalf("CreateAsset (initial): %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "insightshash"); err != nil {
t.Fatalf("CreateAsset (insights): %v", err)
}
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("GetLatestAssetForSubmission: %v", err)
}
if got.ID != initial.ID {
t.Fatalf("expected GetLatestAssetForSubmission to keep returning the initial asset even after an insights asset was added later, got %+v", got)
}
}
func TestListAssetsForSubmissionReturnsAllPurposes(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
accID := testAccountID(t, s)
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "h1"); err != nil {
t.Fatalf("CreateAsset (initial): %v", err)
}
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "h2"); err != nil {
t.Fatalf("CreateAsset (insights): %v", err)
}
list, err := s.ListAssetsForSubmission(ctx, sub.ID)
if err != nil {
t.Fatalf("ListAssetsForSubmission: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 assets, got %d: %+v", len(list), list)
}
if list[0].Purpose != "initial" || list[1].Purpose != "insights" {
t.Fatalf("expected initial before insights (created_at order), got %+v", list)
}
}
func TestAssetIsAppendOnly(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
@@ -83,7 +136,7 @@ func TestAssetIsAppendOnly(t *testing.T) {
if err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
a, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/x.jpg", "hash")
a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/x.jpg", "hash")
if err != nil {
t.Fatalf("CreateAsset: %v", err)
}

View File

@@ -0,0 +1 @@
DROP TABLE platform_connection;

View 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)
);

View File

@@ -0,0 +1 @@
ALTER TABLE asset DROP COLUMN purpose;

View File

@@ -0,0 +1,9 @@
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
-- angelegten Zeilen unverändert.
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
CHECK (purpose IN ('initial', 'insights'));

View 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
}

View 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)
}
}

View File

@@ -2,6 +2,7 @@ package web
import (
"net/http"
"time"
"github.com/netcell-it/deklarix/internal/store"
)
@@ -55,20 +56,27 @@ type participantView struct {
ApprovedAt string // leer, wenn noch nicht freigegeben
}
type insightsAssetView struct {
CreatedAt string
SHA256 string
}
type submissionDetailData struct {
Title string
Nav navData
SubmissionID string
Platform string
PostType string
Caption string
Status string
CreatedAt string
CanArchive bool
IsPublished bool
DossierURL string
Findings []findingView
Participants []participantView
Title string
Nav navData
SubmissionID string
Platform string
PostType string
Caption string
Status string
CreatedAt string
CanArchive bool
IsPublished bool
DossierURL string
Findings []findingView
Participants []participantView
InsightsReminder insightsReminder
InsightsAssets []insightsAssetView
}
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
@@ -126,17 +134,76 @@ func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request)
return
}
assets, err := s.store.ListAssetsForSubmission(ctx, sub.ID)
if err != nil {
http.Error(w, "Assets konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
return
}
var insightsAssets []insightsAssetView
hasInsightsAsset := false
for _, a := range assets {
if a.Purpose == "insights" {
hasInsightsAsset = true
insightsAssets = append(insightsAssets, insightsAssetView{
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), SHA256: a.SHA256,
})
}
}
var reminder insightsReminder
if sub.Status == "published" {
if pkg, err := s.store.GetLatestEvidencePackage(ctx, sub.ID); err == nil {
reminder = computeInsightsReminder(sub.PostType, pkg.CreatedAt, time.Now(), hasInsightsAsset)
}
}
data := submissionDetailData{
Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
InsightsReminder: reminder, InsightsAssets: insightsAssets,
}
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
}
}
// handleAddInsightsAsset speichert einen zusätzlichen Screenshot der
// Kennzahlen (Insights) eines bereits veröffentlichten Beitrags — siehe
// insightsReminder. Nutzt dieselbe Validierung wie das initiale
// Standbild beim Prüfen (readUploadedAsset/storeAsset), nur mit
// purpose="insights" statt "initial" und als eigener, jederzeit
// wiederholbarer Upload statt einmalig beim Anlegen der Submission.
func (s *Server) handleAddInsightsAsset(w http.ResponseWriter, r *http.Request) {
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
if err != nil {
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
if err := r.ParseMultipartForm(1 << 20); err != nil {
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
return
}
asset, err := s.readUploadedAsset(r, "insights_standbild")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if asset == nil {
http.Error(w, "kein Standbild hochgeladen", http.StatusBadRequest)
return
}
if err := s.storeAsset(r.Context(), sub.ID, "insights", asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/beitraege/"+sub.ID, http.StatusSeeOther)
}
type participantListData struct {
SubmissionID string
Participants []participantView

View File

@@ -34,11 +34,11 @@ type uploadedAsset struct {
sha256Hex string
}
// readUploadedAsset liest das optionale "standbild"-Feld. Liefert
// readUploadedAsset liest das optionale Datei-Feld fieldName. Liefert
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
// ein Standbild ist keine Pflichtangabe.
func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
file, header, err := r.FormFile("standbild")
func (s *Server) readUploadedAsset(r *http.Request, fieldName string) (*uploadedAsset, error) {
file, header, err := r.FormFile(fieldName)
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile.
@@ -70,16 +70,22 @@ func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
}
// 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 {
// speichert die Asset-Zeile. purpose ist "initial" (das Beweisfoto beim
// Prüfen, ein Dateiname pro Submission reicht) oder "insights" (kann
// mehrfach vorkommen, braucht daher einen eindeutigen Dateinamen).
func (s *Server) storeAsset(ctx context.Context, submissionID, purpose string, ua *uploadedAsset) error {
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
}
path := filepath.Join(s.assetDir, submissionID+ua.extension)
filename := submissionID + ua.extension
if purpose != "initial" {
filename = fmt.Sprintf("%s-%s-%d%s", submissionID, purpose, time.Now().UnixNano(), ua.extension)
}
path := filepath.Join(s.assetDir, filename)
if err := os.WriteFile(path, ua.data, 0o640); err != nil {
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err)
}
if _, err := s.store.CreateAsset(ctx, submissionID, "image", path, ua.sha256Hex); err != nil {
if _, err := s.store.CreateAsset(ctx, submissionID, "image", purpose, path, ua.sha256Hex); err != nil {
return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
}
return nil
@@ -145,7 +151,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
return
}
asset, err := s.readUploadedAsset(r)
asset, err := s.readUploadedAsset(r, "standbild")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -172,7 +178,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
}
if asset != nil {
if err := s.storeAsset(ctx, sub.ID, asset); err != nil {
if err := s.storeAsset(ctx, sub.ID, "initial", asset); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -0,0 +1,132 @@
package web_test
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/web"
)
// postInsightsUpload lädt einen Insights-Screenshot für einen Beitrag hoch.
func postInsightsUpload(t *testing.T, s *web.Server, cookie *http.Cookie, submissionID string, imageBytes []byte) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="insights_standbild"; filename="insights.png"`},
"Content-Type": {"image/png"},
})
if err != nil {
t.Fatalf("CreatePart: %v", err)
}
if _, err := part.Write(imageBytes); err != nil {
t.Fatalf("Write: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("multipart Close: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/beitraege/"+submissionID+"/insights", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
return w
}
func TestStorySubmissionShowsInsightsReminderAfterArchiving(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
if archiveResp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
}
body := detailResp.Body.String()
if !strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected an insights reminder for a freshly archived story, got: %s", body)
}
_ = fs
}
func TestInsightsUploadClearsReminder(t *testing.T) {
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
subID := checkAndReturnSubID2(t, s, cookie, "story")
if resp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}); resp.Code != http.StatusOK {
t.Fatalf("archive status = %d, body: %s", resp.Code, resp.Body.String())
}
uploadResp := postInsightsUpload(t, s, cookie, subID, tinyPNG)
if uploadResp.Code != http.StatusSeeOther {
t.Fatalf("insights upload status = %d, want 303, body: %s", uploadResp.Code, uploadResp.Body.String())
}
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
body := detailResp.Body.String()
if strings.Contains(body, "Insights jetzt sichern") {
t.Errorf("expected the reminder to be gone after securing insights, got: %s", body)
}
if !strings.Contains(body, "Gesicherte Insights-Nachweise") {
t.Errorf("expected the secured insights section, got: %s", body)
}
}
func TestInsightsUploadRejectsForeignSubmission(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fakeExtractor{facts: rules.Facts{
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
}}, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
subID := checkAndReturnSubID2(t, s, cookieA, "story")
resp := postInsightsUpload(t, s, cookieB, subID, tinyPNG)
if resp.Code != http.StatusNotFound {
t.Fatalf("cross-tenant insights upload status = %d, want 404", resp.Code)
}
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
t.Fatal("expected no asset from a rejected cross-tenant upload")
}
}
// checkAndReturnSubID2 ist checkAndReturnSubID mit ueberschreibbarem post_type.
func checkAndReturnSubID2(t *testing.T, s interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}, cookie *http.Cookie, postType string) string {
t.Helper()
form := checkForm("post_type", postType)
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
}
body := w.Body.String()
const marker = `name="submission_id" value="`
idx := strings.Index(body, marker)
if idx == -1 {
t.Fatalf("expected a submission_id field in the result, got: %s", body)
}
rest := body[idx+len(marker):]
return rest[:strings.Index(rest, `"`)]
}

View File

@@ -0,0 +1,47 @@
package web
import (
"fmt"
"time"
)
// storyInsightsWindow ist das Zeitfenster, in dem Instagram nach
// eigener Aussage Story-Insights überhaupt vorhält — danach sind sie
// auch über den offiziellen Datenexport nicht mehr zu bekommen. Reine
// Produktentscheidung (Erinnerungs-Timing), keine Rechtsnorm — bewusst
// hier als Konstante und nicht in rules/*.yaml, das ist ausschließlich
// für die Kennzeichnungsprüfung reserviert (siehe CLAUDE.md).
const storyInsightsWindow = 24 * time.Hour
// insightsReminder beschreibt, ob und wie dringend eine Erinnerung
// angezeigt werden soll, die Kennzahlen (Insights) eines veröffentlichten
// Beitrags per Screenshot zu sichern, bevor sie unwiederbringlich
// verschwinden.
type insightsReminder struct {
Show bool
Urgent bool // Fenster läuft noch
Expired bool // Fenster ist wahrscheinlich schon vorbei
Message string
}
// computeInsightsReminder berechnet den Erinnerungsstatus. Nur für
// "story"-Beiträge relevant (siehe storyInsightsWindow); alle anderen
// Beitragstypen bekommen keine Erinnerung, da ihre Kennzahlen nicht auf
// dieselbe Art flüchtig sind.
func computeInsightsReminder(postType string, publishedAt time.Time, now time.Time, hasInsightsAsset bool) insightsReminder {
if postType != "story" || hasInsightsAsset || publishedAt.IsZero() {
return insightsReminder{}
}
elapsed := now.Sub(publishedAt)
if elapsed >= storyInsightsWindow {
return insightsReminder{
Show: true, Expired: true,
Message: "Das Zeitfenster für Story-Insights ist bei Instagram nach eigener Aussage abgelaufen (24 Stunden) — die Kennzahlen sind wahrscheinlich nicht mehr abrufbar.",
}
}
remainingHours := int((storyInsightsWindow - elapsed).Hours()) + 1
return insightsReminder{
Show: true, Urgent: true,
Message: fmt.Sprintf("Noch ca. %d Stunde(n), um die Story-Insights zu sichern, bevor sie bei Instagram verschwinden.", remainingHours),
}
}

View File

@@ -0,0 +1,45 @@
package web
import (
"testing"
"time"
)
func TestComputeInsightsReminderOnlyForStory(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("feed", now.Add(-time.Hour), now, false)
if r.Show {
t.Errorf("expected no reminder for a non-story post type, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWhenAlreadySecured(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-time.Hour), now, true)
if r.Show {
t.Errorf("expected no reminder once an insights asset already exists, got %+v", r)
}
}
func TestComputeInsightsReminderUrgentWithinWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-2*time.Hour), now, false)
if !r.Show || !r.Urgent || r.Expired {
t.Fatalf("expected an urgent, non-expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderExpiredAfterWindow(t *testing.T) {
now := time.Now()
r := computeInsightsReminder("story", now.Add(-25*time.Hour), now, false)
if !r.Show || !r.Expired || r.Urgent {
t.Fatalf("expected an expired reminder, got %+v", r)
}
}
func TestComputeInsightsReminderSkippedWithoutPublishTime(t *testing.T) {
r := computeInsightsReminder("story", time.Time{}, time.Now(), false)
if r.Show {
t.Errorf("expected no reminder without a known publish time, got %+v", r)
}
}

View 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)
}

View 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())
}
}

View File

@@ -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"
)
@@ -70,8 +71,13 @@ type Store interface {
DeleteSession(ctx context.Context, token string) error
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error)
CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error)
UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (store.PlatformConnection, error)
ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]store.PlatformConnection, error)
DeletePlatformConnection(ctx context.Context, accountID, platform string) error
}
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -83,14 +89,19 @@ type Server struct {
timestamper evidence.Timestamper
dossierDir string
assetDir string
connectors map[string]socialconnect.Connector
templates *template.Template
}
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
// assetDir das Verzeichnis für hochgeladene Standbilder.
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string) (*Server, error) {
// 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)
@@ -103,6 +114,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
timestamper: timestamper,
dossierDir: dossierDir,
assetDir: assetDir,
connectors: connectors,
templates: tmpl,
}
@@ -122,7 +134,12 @@ 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("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset))
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
mux.HandleFunc("GET /verbindungen", s.requirePage(s.handleConnectionsList))
mux.HandleFunc("GET /oauth/{platform}/start", s.requirePage(s.handleOAuthStart))
mux.HandleFunc("GET /oauth/{platform}/callback", s.requirePage(s.handleOAuthCallback))
mux.HandleFunc("POST /verbindungen/{platform}/trennen", s.requireAPI(s.handleDisconnect))
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdminDashboard))
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))

View File

@@ -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"
)
@@ -68,21 +69,26 @@ type fakeStore struct {
evidencePkgs map[string]store.EvidencePackage
participants map[string]store.Participant
auditLog []store.AuditEntry
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset
assets map[string][]store.Asset // submissionID -> alle Assets, aeltestes zuerst
// 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{},
assets: map[string]store.Asset{},
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{},
}
}
@@ -344,24 +350,93 @@ 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) {
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, purpose, 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(),
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Purpose: purpose,
Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
}
f.assets[submissionID] = a
f.assets[submissionID] = append(f.assets[submissionID], a)
return a, nil
}
// GetLatestAssetForSubmission liefert wie die echte Store-Implementierung
// nur das zuletzt hochgeladene Asset mit purpose="initial".
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 {
var latest store.Asset
found := false
for _, a := range f.assets[submissionID] {
if a.Purpose == "initial" {
latest = a
found = true
}
}
if !found {
return store.Asset{}, store.ErrNotFound
}
return a, nil
return latest, nil
}
func (f *fakeStore) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.assets[submissionID], 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) {
@@ -522,7 +597,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(), 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)
}

View File

@@ -35,6 +35,28 @@
{{end}}
{{if .IsPublished}}
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
{{if .InsightsReminder.Show}}
<div class="{{if .InsightsReminder.Expired}}fehler{{else}}rueckfrage{{end}}">
<p>{{.InsightsReminder.Message}}</p>
<form method="post" action="/beitraege/{{.SubmissionID}}/insights" enctype="multipart/form-data">
<label for="insights_standbild">Insights-Screenshot</label>
<input type="file" id="insights_standbild" name="insights_standbild" accept="image/*" required>
<button type="submit">Insights jetzt sichern</button>
</form>
</div>
{{end}}
{{if .InsightsAssets}}
<h2>Gesicherte Insights-Nachweise</h2>
<ul class="beteiligte">
{{range .InsightsAssets}}
<li class="beteiligter">
<div class="beteiligter-kopf">{{.CreatedAt}} — SHA-256: {{.SHA256}}</div>
</li>
{{end}}
</ul>
{{end}}
{{end}}
<h2>Verantwortungsmatrix</h2>

View File

@@ -10,6 +10,7 @@
<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>

View 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}}

View File

@@ -22,6 +22,16 @@ ASSET_DIR=/var/lib/deklarix/assets
# 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-