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.
This commit is contained in:
noroot
2026-08-28 09:32:43 +02:00
parent 790ab20651
commit 5813e6209c
18 changed files with 1246 additions and 15 deletions

View File

@@ -112,6 +112,7 @@ Managed Postgres in der EU (DSGVO).
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung │ ├── rules/ # YAML-Loader, Auswertung, Versionierung
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log │ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
│ ├── dossier/ # PDF-Erzeugung │ ├── dossier/ # PDF-Erzeugung
│ ├── socialconnect/ # OAuth-Flow Instagram/TikTok (Plattform-Verbindung)
│ ├── store/ # Postgres, Migrationen │ ├── store/ # Postgres, Migrationen
│ └── web/ # Handler, Templates │ └── web/ # Handler, Templates
├── rules/ # YAML-Regeln, versioniert im Git ├── rules/ # YAML-Regeln, versioniert im Git
@@ -180,6 +181,12 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
- `participant` — Beteiligter an einer Submission mit Rolle - `participant` — Beteiligter an einer Submission mit Rolle
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur (`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben) Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
- `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`, **Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`,
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit `evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
@@ -203,6 +210,39 @@ gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
existierender behandelt (404), nie mit einer expliziten 403 bestätigt — existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
sonst würde die Antwort selbst verraten, dass die ID existiert. 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.
--- ---
## Go Commands ## Go Commands
@@ -396,7 +436,9 @@ sudo systemctl start deklarix
sudo systemctl status deklarix sudo systemctl status deklarix
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR, # Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
# DOSSIER_DIR, 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 # Logs prüfen
journalctl -u deklarix -f journalctl -u deklarix -f

View File

@@ -9,6 +9,7 @@ import (
"github.com/netcell-it/deklarix/internal/evidence" "github.com/netcell-it/deklarix/internal/evidence"
"github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/socialconnect"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web" "github.com/netcell-it/deklarix/internal/web"
) )
@@ -53,7 +54,21 @@ func main() {
assetDir = "assets" 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 { if err != nil {
log.Fatalf("web server: %v", err) 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

@@ -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,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

@@ -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/evidence"
"github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/socialconnect"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
) )
@@ -72,6 +73,10 @@ type Store interface {
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error) ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error)
GetLatestAssetForSubmission(ctx context.Context, submissionID 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. // Server bündelt Routing und Abhängigkeiten der Web-Schicht.
@@ -83,14 +88,19 @@ type Server struct {
timestamper evidence.Timestamper timestamper evidence.Timestamper
dossierDir string dossierDir string
assetDir string assetDir string
connectors map[string]socialconnect.Connector
templates *template.Template templates *template.Template
} }
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird // NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
// einmal beim Start geladen, nicht pro Request. dossierDir ist das // einmal beim Start geladen, nicht pro Request. dossierDir ist das
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden; // Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
// assetDir das Verzeichnis für hochgeladene Standbilder. // assetDir das Verzeichnis für hochgeladene Standbilder. connectors
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string) (*Server, error) { // 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") tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
if err != nil { if err != nil {
return nil, fmt.Errorf("web: templates parsen: %w", err) return nil, fmt.Errorf("web: templates parsen: %w", err)
@@ -103,6 +113,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
timestamper: timestamper, timestamper: timestamper,
dossierDir: dossierDir, dossierDir: dossierDir,
assetDir: assetDir, assetDir: assetDir,
connectors: connectors,
templates: tmpl, templates: tmpl,
} }
@@ -123,6 +134,10 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant)) 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}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList) 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", s.requireAdmin(s.handleAdminDashboard))
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList)) mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail)) 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/auth"
"github.com/netcell-it/deklarix/internal/extract" "github.com/netcell-it/deklarix/internal/extract"
"github.com/netcell-it/deklarix/internal/rules" "github.com/netcell-it/deklarix/internal/rules"
"github.com/netcell-it/deklarix/internal/socialconnect"
"github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/store"
"github.com/netcell-it/deklarix/internal/web" "github.com/netcell-it/deklarix/internal/web"
) )
@@ -69,6 +70,10 @@ type fakeStore struct {
participants map[string]store.Participant participants map[string]store.Participant
auditLog []store.AuditEntry auditLog []store.AuditEntry
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset 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 { func newFakeStore() *fakeStore {
@@ -83,6 +88,7 @@ func newFakeStore() *fakeStore {
evidencePkgs: map[string]store.EvidencePackage{}, evidencePkgs: map[string]store.EvidencePackage{},
participants: map[string]store.Participant{}, participants: map[string]store.Participant{},
assets: map[string]store.Asset{}, assets: map[string]store.Asset{},
platformConnections: map[string]map[string]store.PlatformConnection{},
} }
} }
@@ -364,6 +370,59 @@ func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionI
return a, nil 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) { func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -522,7 +581,12 @@ func loadRealRules(t *testing.T) []rules.Rule {
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server { func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
t.Helper() t.Helper()
s, err := web.NewServer(ex, loadRealRules(t), fs, fakeTimestamper{}, t.TempDir(), 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 { if err != nil {
t.Fatalf("NewServer: %v", err) t.Fatalf("NewServer: %v", err)
} }

View File

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

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. # Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
#TSA_URL=https://freetsa.org/tsr #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. # Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst # Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart- # prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-