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.
96 lines
2.9 KiB
Go
96 lines
2.9 KiB
Go
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
|
|
}
|