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:
142
internal/web/oauth_handlers.go
Normal file
142
internal/web/oauth_handlers.go
Normal 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)
|
||||
}
|
||||
225
internal/web/oauth_handlers_test.go
Normal file
225
internal/web/oauth_handlers_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/netcell-it/deklarix/internal/evidence"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,10 @@ type Store interface {
|
||||
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
||||
CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex 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.
|
||||
@@ -83,14 +88,19 @@ type Server struct {
|
||||
timestamper evidence.Timestamper
|
||||
dossierDir string
|
||||
assetDir string
|
||||
connectors map[string]socialconnect.Connector
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
||||
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
||||
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
|
||||
// assetDir das Verzeichnis für hochgeladene Standbilder.
|
||||
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string) (*Server, error) {
|
||||
// assetDir das Verzeichnis für hochgeladene Standbilder. connectors
|
||||
// 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")
|
||||
if err != nil {
|
||||
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,
|
||||
dossierDir: dossierDir,
|
||||
assetDir: assetDir,
|
||||
connectors: connectors,
|
||||
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}/loeschen", s.requireAPI(s.handleDeleteParticipant))
|
||||
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/accounts", s.requireAdmin(s.handleAdminAccountList))
|
||||
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<nav>
|
||||
<a href="/">Prüfen</a>
|
||||
<a href="/beitraege">Beiträge</a>
|
||||
<a href="/verbindungen">Verbindungen</a>
|
||||
{{if .IsAdmin}}<a href="/admin">Admin</a>{{end}}
|
||||
<form method="post" action="/logout" style="display:inline">
|
||||
<button type="submit">Abmelden</button>
|
||||
|
||||
41
internal/web/templates/verbindungen.html
Normal file
41
internal/web/templates/verbindungen.html
Normal 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}}
|
||||
Reference in New Issue
Block a user