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:
103
internal/socialconnect/instagram.go
Normal file
103
internal/socialconnect/instagram.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package socialconnect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
instagramDefaultAuthorizeURL = "https://api.instagram.com/oauth/authorize"
|
||||
instagramDefaultTokenURL = "https://api.instagram.com/oauth/access_token"
|
||||
instagramDefaultLongLivedTokenURL = "https://graph.instagram.com/access_token"
|
||||
|
||||
// instagram_business_basic ist die einzige Berechtigung, die wir
|
||||
// brauchen (Profil + Medien lesen) — mehr zu verlangen verzögert nur
|
||||
// den App-Review, siehe Paket-Kommentar.
|
||||
instagramScope = "instagram_business_basic"
|
||||
)
|
||||
|
||||
// InstagramConnector implementiert Connector für Instagram (Instagram
|
||||
// API with Instagram Login). Der Flow läuft zweistufig: der
|
||||
// Autorisierungscode wird zuerst gegen ein 1 Stunde gültiges Token
|
||||
// getauscht, das anschließend gegen ein 60 Tage gültiges langlebiges
|
||||
// Token getauscht wird — ein einzelner API-Aufruf reicht dafür nicht.
|
||||
type InstagramConnector struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
|
||||
// Überschreibbar für Tests (Default: die echten Instagram-Endpunkte).
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
LongLivedTokenURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewInstagramConnector erstellt einen InstagramConnector mit den
|
||||
// echten Instagram-Endpunkten.
|
||||
func NewInstagramConnector(clientID, clientSecret, redirectURL string) *InstagramConnector {
|
||||
return &InstagramConnector{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: redirectURL,
|
||||
AuthorizeURL: instagramDefaultAuthorizeURL,
|
||||
TokenURL: instagramDefaultTokenURL,
|
||||
LongLivedTokenURL: instagramDefaultLongLivedTokenURL,
|
||||
HTTPClient: http.DefaultClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *InstagramConnector) Platform() string { return "instagram" }
|
||||
|
||||
func (c *InstagramConnector) AuthorizationURL(state string) string {
|
||||
v := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"scope": {instagramScope},
|
||||
"response_type": {"code"},
|
||||
"state": {state},
|
||||
}
|
||||
return c.AuthorizeURL + "?" + v.Encode()
|
||||
}
|
||||
|
||||
func (c *InstagramConnector) Exchange(ctx context.Context, code string) (Token, error) {
|
||||
form := url.Values{
|
||||
"client_id": {c.ClientID},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"grant_type": {"authorization_code"},
|
||||
"redirect_uri": {c.RedirectURL},
|
||||
"code": {code},
|
||||
}
|
||||
var short struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
UserID any `json:"user_id"` // liefert Instagram mal als Zahl, mal als String
|
||||
}
|
||||
if err := postForm(ctx, c.HTTPClient, c.TokenURL, form, &short); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: %w", err)
|
||||
}
|
||||
if short.AccessToken == "" {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram code exchange: kein access_token in der Antwort")
|
||||
}
|
||||
|
||||
long := url.Values{
|
||||
"grant_type": {"ig_exchange_token"},
|
||||
"client_secret": {c.ClientSecret},
|
||||
"access_token": {short.AccessToken},
|
||||
}
|
||||
var longResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := getJSON(ctx, c.HTTPClient, c.LongLivedTokenURL, long, &longResp); err != nil {
|
||||
return Token{}, fmt.Errorf("socialconnect: instagram long-lived token exchange: %w", err)
|
||||
}
|
||||
|
||||
return Token{
|
||||
AccessToken: longResp.AccessToken,
|
||||
ExpiresAt: time.Now().Add(time.Duration(longResp.ExpiresIn) * time.Second),
|
||||
PlatformUserID: fmt.Sprint(short.UserID),
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user