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:
82
internal/socialconnect/connector.go
Normal file
82
internal/socialconnect/connector.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Package socialconnect implementiert den OAuth-Authorization-Code-Flow,
|
||||
// mit dem ein Kunde seinen eigenen Instagram- oder TikTok-Account mit
|
||||
// Deklarix verbindet — Ziel ist, dass die Beweissicherung einen
|
||||
// veröffentlichten Beitrag später direkt abrufen kann, statt ihn manuell
|
||||
// hochladen zu müssen. Reine HTTP-Logik gegen die jeweilige Plattform-
|
||||
// API, keine Datenbankzugriffe — Persistenz der Verbindung liegt in
|
||||
// internal/store (platform_connection).
|
||||
//
|
||||
// WICHTIG: Instagram- und TikTok-Endpunkte, Scopes und Token-Formate
|
||||
// ändern sich häufiger als andere APIs. Vor dem ersten echten
|
||||
// Verbindungsversuch mit realen Client-Credentials die Konstanten hier
|
||||
// gegen die aktuelle Meta-/TikTok-Entwicklerdokumentation prüfen —
|
||||
// dieser Code wurde ohne echte Zugangsdaten gebaut und gegen die
|
||||
// Dokumentation, nicht gegen die echte API, verifiziert.
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Token ist das Ergebnis eines erfolgreichen Code-Tauschs.
|
||||
type Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresAt time.Time // Nullwert, wenn die Plattform keine Ablaufzeit liefert
|
||||
PlatformUserID string
|
||||
}
|
||||
|
||||
// Connector kapselt den OAuth-Flow einer einzelnen Plattform. *InstagramConnector
|
||||
// und *TikTokConnector erfüllen dieses Interface; internal/web hält eine
|
||||
// Menge konfigurierter Connectors (nur die, für die echte Client-
|
||||
// Credentials gesetzt sind — siehe cmd/deklarix/main.go).
|
||||
type Connector interface {
|
||||
// Platform ist der interne Bezeichner ("instagram" | "tiktok"), wie
|
||||
// er auch in platform_connection.platform gespeichert wird.
|
||||
Platform() string
|
||||
AuthorizationURL(state string) string
|
||||
Exchange(ctx context.Context, code string) (Token, error)
|
||||
}
|
||||
|
||||
func postForm(ctx context.Context, client *http.Client, endpoint string, form url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return doJSON(client, req, out)
|
||||
}
|
||||
|
||||
func getJSON(ctx context.Context, client *http.Client, endpoint string, query url.Values, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return doJSON(client, req, out)
|
||||
}
|
||||
|
||||
func doJSON(client *http.Client, req *http.Request, out any) error {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("antwort lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("unerwarteter Status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("antwort parsen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user