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:
@@ -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"
|
||||
)
|
||||
@@ -69,20 +70,25 @@ type fakeStore struct {
|
||||
participants map[string]store.Participant
|
||||
auditLog []store.AuditEntry
|
||||
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 {
|
||||
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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +370,59 @@ func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionI
|
||||
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) {
|
||||
f.mu.Lock()
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user