Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd7831784 | ||
|
|
5813e6209c |
75
CLAUDE.md
75
CLAUDE.md
@@ -112,6 +112,7 @@ Managed Postgres in der EU (DSGVO).
|
|||||||
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
│ ├── rules/ # YAML-Loader, Auswertung, Versionierung
|
||||||
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
│ ├── evidence/ # Hashing, Zeitstempel, Append-only-Log
|
||||||
│ ├── dossier/ # PDF-Erzeugung
|
│ ├── dossier/ # PDF-Erzeugung
|
||||||
|
│ ├── socialconnect/ # OAuth-Flow Instagram/TikTok (Plattform-Verbindung)
|
||||||
│ ├── store/ # Postgres, Migrationen
|
│ ├── store/ # Postgres, Migrationen
|
||||||
│ └── web/ # Handler, Templates
|
│ └── web/ # Handler, Templates
|
||||||
├── rules/ # YAML-Regeln, versioniert im Git
|
├── rules/ # YAML-Regeln, versioniert im Git
|
||||||
@@ -167,10 +168,14 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
|
|||||||
Account wie verändert); append-only aus demselben Grund wie
|
Account wie verändert); append-only aus demselben Grund wie
|
||||||
`finding`/`extraction`/`evidence_package`
|
`finding`/`extraction`/`evidence_package`
|
||||||
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
|
- `submission` — ein eingereichter Beitrag, Status, Zeitpunkte
|
||||||
- `asset` — hochgeladenes Standbild (optional bei der Prüfung), Pfad,
|
- `asset` — hochgeladenes Standbild, Pfad, SHA-256; append-only aus
|
||||||
SHA-256; append-only aus demselben Grund wie `finding`/`extraction`/
|
demselben Grund wie `finding`/`extraction`/`evidence_package` — ein
|
||||||
`evidence_package` — ein Beweisstück wird nicht nachträglich
|
Beweisstück wird nicht nachträglich ausgetauscht. `purpose` = `initial`
|
||||||
ausgetauscht
|
(das Beweisfoto beim Prüfen, optional) oder `insights` (siehe
|
||||||
|
„Insights-Erinnerung" unten; ein Beitrag kann mehrere `insights`-Assets
|
||||||
|
über die Zeit bekommen). `GetLatestAssetForSubmission` berücksichtigt
|
||||||
|
nur `initial`, damit ein späterer Insights-Upload nie den beim
|
||||||
|
Archivieren referenzierten Original-Screenshot verdrängt
|
||||||
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
|
- `extraction` — das JSON aus Stufe 1, Modellversion, Prompt-Version
|
||||||
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
|
- `finding` — Ergebnis pro Regel: Regel-ID, Regel-Version, Schwere,
|
||||||
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
|
Titel, Korrektur, Fundstellen (zum Zeitpunkt des Findings fixiert,
|
||||||
@@ -180,6 +185,12 @@ Plus drei Tabellen für Auth/Mandantentrennung (`account`, `app_user`,
|
|||||||
- `participant` — Beteiligter an einer Submission mit Rolle
|
- `participant` — Beteiligter an einer Submission mit Rolle
|
||||||
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
(`creator`, `agentur`, `marke`, `kanzlei`) und Beitrag zur
|
||||||
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
|
Verantwortungsmatrix (wer hat vorgegeben, wer freigegeben)
|
||||||
|
- `platform_connection` — die per OAuth hergestellte Verbindung eines
|
||||||
|
Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
|
||||||
|
Abschnitt „Plattform-Verbindung (OAuth)" unten). NICHT append-only —
|
||||||
|
Tokens laufen ab und werden erneuert, eine Verbindung kann getrennt
|
||||||
|
und neu hergestellt werden; höchstens eine Verbindung pro
|
||||||
|
Account+Plattform (`UNIQUE(account_id, platform)`)
|
||||||
|
|
||||||
**Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`,
|
**Append-only.** Kein UPDATE auf `finding`, `extraction`, `asset`,
|
||||||
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
|
`evidence_package` oder `audit_log`. Korrekturen sind neue Zeilen mit
|
||||||
@@ -203,6 +214,58 @@ gültige Sitzung (sonst Redirect zu `/login`); `POST /pruefen`,
|
|||||||
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
|
existierender behandelt (404), nie mit einer expliziten 403 bestätigt —
|
||||||
sonst würde die Antwort selbst verraten, dass die ID existiert.
|
sonst würde die Antwort selbst verraten, dass die ID existiert.
|
||||||
|
|
||||||
|
**Plattform-Verbindung (OAuth):** Jeder Kunde kann optional seinen
|
||||||
|
eigenen Instagram- oder TikTok-Account verbinden (`GET /verbindungen`),
|
||||||
|
damit die Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||||
|
per API abrufen kann, statt ihn manuell hochzuladen — reiner
|
||||||
|
Authorization-Code-Flow, jeder Kunde autorisiert nur seinen eigenen
|
||||||
|
Account (`internal/socialconnect`, Persistenz in `platform_connection`).
|
||||||
|
Der manuelle Standbild-Upload bleibt der primäre Weg und funktioniert
|
||||||
|
unabhängig davon weiter; OAuth reduziert nur Reibung, ist kein
|
||||||
|
Ersatz für die Pre-Publish-Prüfung (die läuft zwingend vor
|
||||||
|
Veröffentlichung, wenn auf der Plattform noch nichts existiert — dafür
|
||||||
|
kann OAuth nichts abrufen).
|
||||||
|
|
||||||
|
Technisch ist der Flow fertig (Connector-Interface, CSRF-Schutz per
|
||||||
|
State-Cookie, Token-Speicherung), aber **ohne aktive Meta-/TikTok-
|
||||||
|
Freigabe nutzlos**: Instagram (`instagram_business_basic`) und TikTok
|
||||||
|
(Login Kit + Content Posting API) verlangen jeweils eine einmalige,
|
||||||
|
plattformseitige Prüfung des Deklarix-Betreiberkontos (Meta Business
|
||||||
|
Verification + App Review: ca. 2–4 Wochen; TikTok-Audit: ca. 1–2
|
||||||
|
Wochen), bevor sich beliebige Kunden selbst verbinden können. Bis dahin
|
||||||
|
lässt sich mit bis zu 25 (Meta) bzw. 10 (TikTok) manuell eingetragenen
|
||||||
|
Testern trotzdem schon mit einem echten Piloten testen. Ohne gesetzte
|
||||||
|
Konfiguration (`INSTAGRAM_CLIENT_ID`/`_SECRET`,
|
||||||
|
`TIKTOK_CLIENT_KEY`/`_SECRET`, `PUBLIC_BASE_URL`) zeigt
|
||||||
|
`GET /verbindungen` beide Plattformen als „noch nicht konfiguriert"
|
||||||
|
ohne Verbinden-Button — kein Absturz, kein stiller Fallback.
|
||||||
|
|
||||||
|
**Vorsicht bei künftigen Änderungen:** Instagram-/TikTok-Endpunkte,
|
||||||
|
Scopes und Token-Formate in `internal/socialconnect` wurden ohne echte
|
||||||
|
Zugangsdaten gegen die Entwicklerdokumentation gebaut, nie gegen die
|
||||||
|
echte API verifiziert — vor dem ersten echten Verbindungsversuch mit
|
||||||
|
realen Credentials die Konstanten in `internal/socialconnect/*.go` noch
|
||||||
|
einmal gegen die dann aktuelle Meta-/TikTok-Dokumentation prüfen.
|
||||||
|
|
||||||
|
**Insights-Erinnerung:** Story-Insights hält Instagram nach eigener
|
||||||
|
Aussage nur 24 Stunden vor — danach sind sie auch über den offiziellen
|
||||||
|
Datenexport nicht mehr zu bekommen, und der ursprüngliche Standbild-
|
||||||
|
Screenshot beim Prüfen (der direkt beim Veröffentlichen entsteht, bevor
|
||||||
|
nennenswerte Kennzahlen existieren) kann sie naturgemäß nicht erfassen.
|
||||||
|
`GET /beitraege/{id}` zeigt deshalb bei veröffentlichten `story`-
|
||||||
|
Beiträgen eine Erinnerung, solange keine `insights`-Asset existiert
|
||||||
|
(`internal/web/insights_reminder.go`, `computeInsightsReminder` —
|
||||||
|
reine Produktentscheidung zum Erinnerungs-Timing, keine Rechtsnorm,
|
||||||
|
daher bewusst nicht in `rules/*.yaml`). `POST /beitraege/{id}/insights`
|
||||||
|
speichert einen zusätzlichen Screenshot als `asset` mit
|
||||||
|
`purpose='insights'`, gehasht wie jedes andere Beweisstück — aber
|
||||||
|
NICHT im Metadaten-Hash des ursprünglichen Dossiers enthalten (das
|
||||||
|
wird beim Archivieren einmalig fixiert). Bewusst nur ein In-App-
|
||||||
|
Banner in dieser ersten Ausbaustufe, kein Mail-/Push-Versand — dafür
|
||||||
|
fehlt aktuell ein SMTP-Relay/Versanddienst; vor einer echten
|
||||||
|
Benachrichtigung per E-Mail ist das eine offene Rückfrage (welcher
|
||||||
|
Versanddienst, welche Absenderdomain/SPF/DKIM).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Go Commands
|
## Go Commands
|
||||||
@@ -396,7 +459,9 @@ sudo systemctl start deklarix
|
|||||||
sudo systemctl status deklarix
|
sudo systemctl status deklarix
|
||||||
|
|
||||||
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
|
# Config: /etc/deklarix/deklarix.env (DATABASE_URL, PORT, RULES_DIR,
|
||||||
# DOSSIER_DIR, ASSET_DIR, TSA_URL)
|
# DOSSIER_DIR, ASSET_DIR, TSA_URL, PUBLIC_BASE_URL,
|
||||||
|
# INSTAGRAM_CLIENT_ID/_SECRET, TIKTOK_CLIENT_KEY/_SECRET — letztere vier
|
||||||
|
# optional, ohne sie zeigt /verbindungen nur "nicht konfiguriert")
|
||||||
|
|
||||||
# Logs prüfen
|
# Logs prüfen
|
||||||
journalctl -u deklarix -f
|
journalctl -u deklarix -f
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/netcell-it/deklarix/internal/evidence"
|
"github.com/netcell-it/deklarix/internal/evidence"
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"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/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
)
|
)
|
||||||
@@ -53,7 +54,21 @@ func main() {
|
|||||||
assetDir = "assets"
|
assetDir = "assets"
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir)
|
connectors := map[string]socialconnect.Connector{}
|
||||||
|
publicBaseURL := os.Getenv("PUBLIC_BASE_URL")
|
||||||
|
if publicBaseURL != "" {
|
||||||
|
if id, secret := os.Getenv("INSTAGRAM_CLIENT_ID"), os.Getenv("INSTAGRAM_CLIENT_SECRET"); id != "" && secret != "" {
|
||||||
|
connectors["instagram"] = socialconnect.NewInstagramConnector(id, secret, publicBaseURL+"/oauth/instagram/callback")
|
||||||
|
}
|
||||||
|
if key, secret := os.Getenv("TIKTOK_CLIENT_KEY"), os.Getenv("TIKTOK_CLIENT_SECRET"); key != "" && secret != "" {
|
||||||
|
connectors["tiktok"] = socialconnect.NewTikTokConnector(key, secret, publicBaseURL+"/oauth/tiktok/callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(connectors) == 0 {
|
||||||
|
log.Print("keine Plattform-Verbindungen konfiguriert (INSTAGRAM_CLIENT_ID/TIKTOK_CLIENT_KEY/PUBLIC_BASE_URL fehlen) — /verbindungen zeigt nur manuelle Beweissicherung an")
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := web.NewServer(extractor, ruleSet, db, timestamper, dossierDir, assetDir, connectors)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web server: %v", err)
|
log.Fatalf("web server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
82
internal/socialconnect/instagram_test.go
Normal file
82
internal/socialconnect/instagram_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package socialconnect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstagramAuthorizationURL(t *testing.T) {
|
||||||
|
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||||
|
u := c.AuthorizationURL("state-abc")
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"https://api.instagram.com/oauth/authorize?",
|
||||||
|
"client_id=client-123",
|
||||||
|
"state=state-abc",
|
||||||
|
"scope=instagram_business_basic",
|
||||||
|
"response_type=code",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(u, want) {
|
||||||
|
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstagramExchangeSuccess(t *testing.T) {
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
t.Fatalf("ParseForm: %v", err)
|
||||||
|
}
|
||||||
|
if r.FormValue("code") != "der-code" {
|
||||||
|
t.Errorf("code = %q, want der-code", r.FormValue("code"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"access_token":"short-lived-token","user_id":"17841400000000000"}`))
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
longLivedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Query().Get("access_token") != "short-lived-token" {
|
||||||
|
t.Errorf("access_token query = %q, want short-lived-token", r.URL.Query().Get("access_token"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"access_token":"long-lived-token","expires_in":5184000}`))
|
||||||
|
}))
|
||||||
|
defer longLivedServer.Close()
|
||||||
|
|
||||||
|
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||||
|
c.TokenURL = tokenServer.URL
|
||||||
|
c.LongLivedTokenURL = longLivedServer.URL
|
||||||
|
|
||||||
|
tok, err := c.Exchange(context.Background(), "der-code")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if tok.AccessToken != "long-lived-token" {
|
||||||
|
t.Errorf("AccessToken = %q, want long-lived-token", tok.AccessToken)
|
||||||
|
}
|
||||||
|
if tok.PlatformUserID != "17841400000000000" {
|
||||||
|
t.Errorf("PlatformUserID = %q, want 17841400000000000", tok.PlatformUserID)
|
||||||
|
}
|
||||||
|
if tok.ExpiresAt.IsZero() {
|
||||||
|
t.Error("expected a non-zero ExpiresAt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstagramExchangePropagatesTokenEndpointError(t *testing.T) {
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error_message":"ungueltiger code"}`))
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
c := NewInstagramConnector("client-123", "secret", "https://app.deklarix.de/oauth/instagram/callback")
|
||||||
|
c.TokenURL = tokenServer.URL
|
||||||
|
|
||||||
|
if _, err := c.Exchange(context.Background(), "falscher-code"); err == nil {
|
||||||
|
t.Fatal("expected an error when the token endpoint returns 400")
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/socialconnect/tiktok.go
Normal file
95
internal/socialconnect/tiktok.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
70
internal/socialconnect/tiktok_test.go
Normal file
70
internal/socialconnect/tiktok_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package socialconnect
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTikTokAuthorizationURL(t *testing.T) {
|
||||||
|
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||||
|
u := c.AuthorizationURL("state-xyz")
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"https://www.tiktok.com/v2/auth/authorize/?",
|
||||||
|
"client_key=client-key-123",
|
||||||
|
"state=state-xyz",
|
||||||
|
"response_type=code",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(u, want) {
|
||||||
|
t.Errorf("AuthorizationURL = %q, want it to contain %q", u, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTikTokExchangeSuccess(t *testing.T) {
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
t.Fatalf("ParseForm: %v", err)
|
||||||
|
}
|
||||||
|
if r.FormValue("client_key") != "client-key-123" {
|
||||||
|
t.Errorf("client_key = %q, want client-key-123", r.FormValue("client_key"))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"access_token":"tt-access","refresh_token":"tt-refresh","expires_in":86400,"open_id":"tt-open-id-1"}`))
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||||
|
c.TokenURL = tokenServer.URL
|
||||||
|
|
||||||
|
tok, err := c.Exchange(context.Background(), "der-code")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exchange: %v", err)
|
||||||
|
}
|
||||||
|
if tok.AccessToken != "tt-access" || tok.RefreshToken != "tt-refresh" || tok.PlatformUserID != "tt-open-id-1" {
|
||||||
|
t.Errorf("unexpected token: %+v", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTikTokExchangePropagatesPlatformError(t *testing.T) {
|
||||||
|
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TikTok liefert Fehler oft mit Status 200, Fehlerfeldern im Body.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"error":"invalid_grant","error_description":"code abgelaufen"}`))
|
||||||
|
}))
|
||||||
|
defer tokenServer.Close()
|
||||||
|
|
||||||
|
c := NewTikTokConnector("client-key-123", "secret", "https://app.deklarix.de/oauth/tiktok/callback")
|
||||||
|
c.TokenURL = tokenServer.URL
|
||||||
|
|
||||||
|
_, err := c.Exchange(context.Background(), "abgelaufener-code")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when the platform response contains an error field")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid_grant") {
|
||||||
|
t.Errorf("error = %v, want it to mention invalid_grant", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,14 +9,17 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Asset ist eine zu einem Beitrag hochgeladene Datei (aktuell: das
|
// Asset ist eine zu einem Beitrag hochgeladene Datei. Append-only wie
|
||||||
// Standbild/der Screenshot). Append-only wie extraction/finding/
|
// extraction/finding/evidence_package — ein hochgeladenes Beweisstück
|
||||||
// evidence_package — ein hochgeladenes Beweisstück wird nicht
|
// wird nicht nachträglich ausgetauscht, siehe Migration. Purpose
|
||||||
// nachträglich ausgetauscht, siehe Migration.
|
// unterscheidet das ursprüngliche Beweisfoto beim Prüfen ("initial")
|
||||||
|
// von einem späteren Nachweis flüchtiger Kennzahlen ("insights") —
|
||||||
|
// siehe Migration 0007 und CLAUDE.md, Abschnitt Insights-Erinnerung.
|
||||||
type Asset struct {
|
type Asset struct {
|
||||||
ID string
|
ID string
|
||||||
SubmissionID string
|
SubmissionID string
|
||||||
Kind string
|
Kind string
|
||||||
|
Purpose string
|
||||||
Path string
|
Path string
|
||||||
SHA256 string
|
SHA256 string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -25,14 +28,14 @@ type Asset struct {
|
|||||||
// CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte
|
// CreateAsset speichert ein Asset. sha256Hex ist der Hex-kodierte
|
||||||
// SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form,
|
// SHA-256-Digest der Datei (siehe evidence.HashBytes) — dieselbe Form,
|
||||||
// in der evidence_package.SHA256 seinen Hash speichert.
|
// in der evidence_package.SHA256 seinen Hash speichert.
|
||||||
func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (Asset, error) {
|
func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (Asset, error) {
|
||||||
var a Asset
|
var a Asset
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO asset (submission_id, kind, path, sha256)
|
INSERT INTO asset (submission_id, kind, purpose, path, sha256)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, submission_id, kind, path, sha256, created_at
|
RETURNING id, submission_id, kind, purpose, path, sha256, created_at
|
||||||
`, submissionID, kind, path, sha256Hex).Scan(
|
`, submissionID, kind, purpose, path, sha256Hex).Scan(
|
||||||
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
|
&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Asset{}, fmt.Errorf("store: create asset: %w", err)
|
return Asset{}, fmt.Errorf("store: create asset: %w", err)
|
||||||
@@ -41,19 +44,23 @@ func (s *Store) CreateAsset(ctx context.Context, submissionID, kind, path, sha25
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset
|
// GetLatestAssetForSubmission liefert das zuletzt hochgeladene Asset
|
||||||
// eines Beitrags. Liefert ErrNotFound, wenn kein Asset hochgeladen wurde
|
// mit purpose="initial" eines Beitrags — bewusst ohne spätere
|
||||||
// — das ist der Normalfall (ein Standbild ist optional), kein Fehler,
|
// "insights"-Assets, damit ein erneutes Archivieren immer denselben
|
||||||
// den Aufrufer wie einen echten Datenbankfehler behandeln sollten.
|
// ursprünglichen Beweis referenziert, egal wie viele Insights-
|
||||||
|
// Screenshots danach noch hinzukommen. Liefert ErrNotFound, wenn keins
|
||||||
|
// hochgeladen wurde — das ist der Normalfall (ein Standbild ist
|
||||||
|
// optional), kein Fehler, den Aufrufer wie einen echten
|
||||||
|
// Datenbankfehler behandeln sollten.
|
||||||
func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) {
|
func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (Asset, error) {
|
||||||
var a Asset
|
var a Asset
|
||||||
err := s.Pool.QueryRow(ctx, `
|
err := s.Pool.QueryRow(ctx, `
|
||||||
SELECT id, submission_id, kind, path, sha256, created_at
|
SELECT id, submission_id, kind, purpose, path, sha256, created_at
|
||||||
FROM asset
|
FROM asset
|
||||||
WHERE submission_id = $1
|
WHERE submission_id = $1 AND purpose = 'initial'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, submissionID).Scan(
|
`, submissionID).Scan(
|
||||||
&a.ID, &a.SubmissionID, &a.Kind, &a.Path, &a.SHA256, &a.CreatedAt,
|
&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt,
|
||||||
)
|
)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Asset{}, ErrNotFound
|
return Asset{}, ErrNotFound
|
||||||
@@ -63,3 +70,29 @@ func (s *Store) GetLatestAssetForSubmission(ctx context.Context, submissionID st
|
|||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAssetsForSubmission liefert alle Assets eines Beitrags
|
||||||
|
// (initiales Standbild und alle Insights-Nachweise), älteste zuerst.
|
||||||
|
func (s *Store) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]Asset, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, submission_id, kind, purpose, path, sha256, created_at
|
||||||
|
FROM asset WHERE submission_id = $1 ORDER BY created_at
|
||||||
|
`, submissionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list assets for submission: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Asset
|
||||||
|
for rows.Next() {
|
||||||
|
var a Asset
|
||||||
|
if err := rows.Scan(&a.ID, &a.SubmissionID, &a.Kind, &a.Purpose, &a.Path, &a.SHA256, &a.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan asset: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list assets for submission: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ func TestAssetCreateAndGetLatest(t *testing.T) {
|
|||||||
t.Fatalf("CreateSubmission: %v", err)
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a, err := s.CreateAsset(ctx, sub.ID, "image", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
|
a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/var/lib/deklarix/assets/abc.jpg", "deadbeef")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAsset: %v", err)
|
t.Fatalf("CreateAsset: %v", err)
|
||||||
}
|
}
|
||||||
if a.SubmissionID != sub.ID || a.Kind != "image" {
|
if a.SubmissionID != sub.ID || a.Kind != "image" || a.Purpose != "initial" {
|
||||||
t.Fatalf("CreateAsset = %+v, unerwartete Werte", a)
|
t.Fatalf("CreateAsset = %+v, unerwartete Werte", a)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +58,10 @@ func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
|
|||||||
t.Fatalf("CreateSubmission: %v", err)
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/erstes.jpg", "erstehash"); err != nil {
|
if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/erstes.jpg", "erstehash"); err != nil {
|
||||||
t.Fatalf("CreateAsset (1): %v", err)
|
t.Fatalf("CreateAsset (1): %v", err)
|
||||||
}
|
}
|
||||||
second, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/zweites.jpg", "zweitehash")
|
second, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/zweites.jpg", "zweitehash")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAsset (2): %v", err)
|
t.Fatalf("CreateAsset (2): %v", err)
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,59 @@ func TestGetLatestAssetForSubmissionReturnsNewestWhenMultiple(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetLatestAssetForSubmissionIgnoresInsightsAssets(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initial, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "initialhash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAsset (initial): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "insightshash"); err != nil {
|
||||||
|
t.Fatalf("CreateAsset (insights): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetLatestAssetForSubmission(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestAssetForSubmission: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != initial.ID {
|
||||||
|
t.Fatalf("expected GetLatestAssetForSubmission to keep returning the initial asset even after an insights asset was added later, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAssetsForSubmissionReturnsAllPurposes(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
sub, err := s.CreateSubmission(ctx, accID, "instagram", "story", "...")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/initial.jpg", "h1"); err != nil {
|
||||||
|
t.Fatalf("CreateAsset (initial): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateAsset(ctx, sub.ID, "image", "insights", "/tmp/insights.jpg", "h2"); err != nil {
|
||||||
|
t.Fatalf("CreateAsset (insights): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListAssetsForSubmission(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAssetsForSubmission: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("expected 2 assets, got %d: %+v", len(list), list)
|
||||||
|
}
|
||||||
|
if list[0].Purpose != "initial" || list[1].Purpose != "insights" {
|
||||||
|
t.Fatalf("expected initial before insights (created_at order), got %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAssetIsAppendOnly(t *testing.T) {
|
func TestAssetIsAppendOnly(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -83,7 +136,7 @@ func TestAssetIsAppendOnly(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateSubmission: %v", err)
|
t.Fatalf("CreateSubmission: %v", err)
|
||||||
}
|
}
|
||||||
a, err := s.CreateAsset(ctx, sub.ID, "image", "/tmp/x.jpg", "hash")
|
a, err := s.CreateAsset(ctx, sub.ID, "image", "initial", "/tmp/x.jpg", "hash")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateAsset: %v", err)
|
t.Fatalf("CreateAsset: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE platform_connection;
|
||||||
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
21
internal/store/migrations/0006_platform_connection.up.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- Ein Kunde kann seinen eigenen Instagram- oder TikTok-Account per
|
||||||
|
-- OAuth verbinden (siehe internal/socialconnect), damit die
|
||||||
|
-- Beweissicherung einen veröffentlichten Beitrag künftig direkt
|
||||||
|
-- abrufen kann statt ihn manuell hochzuladen. Anders als
|
||||||
|
-- extraction/finding/asset ist das NICHT append-only: Tokens laufen ab
|
||||||
|
-- und werden erneuert, eine Verbindung kann getrennt und neu
|
||||||
|
-- hergestellt werden — das ist ein normaler Konfigurationszustand, kein
|
||||||
|
-- Beweis-Eintrag. Ein Account hat höchstens eine Verbindung pro
|
||||||
|
-- Plattform (erneutes Verbinden ersetzt die alte, siehe
|
||||||
|
-- ON CONFLICT in UpsertPlatformConnection).
|
||||||
|
CREATE TABLE platform_connection (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id UUID NOT NULL REFERENCES account (id),
|
||||||
|
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok')),
|
||||||
|
platform_user_id TEXT NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NOT NULL DEFAULT '',
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (account_id, platform)
|
||||||
|
);
|
||||||
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
1
internal/store/migrations/0007_asset_purpose.down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE asset DROP COLUMN purpose;
|
||||||
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
9
internal/store/migrations/0007_asset_purpose.up.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Ein Beitrag kann mehr als ein Standbild bekommen: das ursprüngliche
|
||||||
|
-- Beweisfoto beim Prüfen (purpose='initial') und später ein Nachweis
|
||||||
|
-- flüchtiger Kennzahlen (purpose='insights') — Instagram hält Story-
|
||||||
|
-- Insights nach eigener Aussage nur 24 Stunden vor, danach sind sie
|
||||||
|
-- auch über den offiziellen Datenexport nicht mehr zu bekommen.
|
||||||
|
-- Default 'initial' erhält die Bedeutung aller vor dieser Migration
|
||||||
|
-- angelegten Zeilen unverändert.
|
||||||
|
ALTER TABLE asset ADD COLUMN purpose TEXT NOT NULL DEFAULT 'initial'
|
||||||
|
CHECK (purpose IN ('initial', 'insights'));
|
||||||
108
internal/store/platformconnection.go
Normal file
108
internal/store/platformconnection.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlatformConnection ist die per OAuth hergestellte Verbindung eines
|
||||||
|
// Accounts zu seinem eigenen Instagram- oder TikTok-Account (siehe
|
||||||
|
// internal/socialconnect). Anders als asset/finding/extraction NICHT
|
||||||
|
// append-only — ein abgelaufenes oder erneuertes Token ersetzt das alte,
|
||||||
|
// eine getrennte Verbindung wird wirklich gelöscht.
|
||||||
|
type PlatformConnection struct {
|
||||||
|
ID string
|
||||||
|
AccountID string
|
||||||
|
Platform string
|
||||||
|
PlatformUserID string
|
||||||
|
AccessToken string
|
||||||
|
RefreshToken string
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
ConnectedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertPlatformConnection legt eine Verbindung an oder ersetzt die
|
||||||
|
// bestehende für dasselbe Account+Plattform-Paar (z. B. bei erneutem
|
||||||
|
// Verbinden nach Trennen, oder wenn refresh_token erneuert wurde).
|
||||||
|
func (s *Store) UpsertPlatformConnection(ctx context.Context, accountID, platform, platformUserID, accessToken, refreshToken string, expiresAt *time.Time) (PlatformConnection, error) {
|
||||||
|
var c PlatformConnection
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO platform_connection (account_id, platform, platform_user_id, access_token, refresh_token, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (account_id, platform) DO UPDATE SET
|
||||||
|
platform_user_id = EXCLUDED.platform_user_id,
|
||||||
|
access_token = EXCLUDED.access_token,
|
||||||
|
refresh_token = EXCLUDED.refresh_token,
|
||||||
|
expires_at = EXCLUDED.expires_at,
|
||||||
|
connected_at = now()
|
||||||
|
RETURNING id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||||
|
`, accountID, platform, platformUserID, accessToken, refreshToken, expiresAt).Scan(
|
||||||
|
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return PlatformConnection{}, fmt.Errorf("store: upsert platform connection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPlatformConnectionsForAccount liefert alle Plattform-Verbindungen
|
||||||
|
// eines Accounts.
|
||||||
|
func (s *Store) ListPlatformConnectionsForAccount(ctx context.Context, accountID string) ([]PlatformConnection, error) {
|
||||||
|
rows, err := s.Pool.Query(ctx, `
|
||||||
|
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||||
|
FROM platform_connection WHERE account_id = $1 ORDER BY platform
|
||||||
|
`, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list platform connections: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []PlatformConnection
|
||||||
|
for rows.Next() {
|
||||||
|
var c PlatformConnection
|
||||||
|
if err := rows.Scan(&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan platform connection: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list platform connections: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlatformConnection liest die Verbindung eines Accounts zu einer
|
||||||
|
// bestimmten Plattform. Liefert ErrNotFound, wenn keine Verbindung
|
||||||
|
// besteht — der Normalfall, solange der Kunde nichts verbunden hat.
|
||||||
|
func (s *Store) GetPlatformConnection(ctx context.Context, accountID, platform string) (PlatformConnection, error) {
|
||||||
|
var c PlatformConnection
|
||||||
|
err := s.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, account_id, platform, platform_user_id, access_token, refresh_token, expires_at, connected_at
|
||||||
|
FROM platform_connection WHERE account_id = $1 AND platform = $2
|
||||||
|
`, accountID, platform).Scan(
|
||||||
|
&c.ID, &c.AccountID, &c.Platform, &c.PlatformUserID, &c.AccessToken, &c.RefreshToken, &c.ExpiresAt, &c.ConnectedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return PlatformConnection{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PlatformConnection{}, fmt.Errorf("store: get platform connection: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePlatformConnection trennt eine Plattform-Verbindung.
|
||||||
|
func (s *Store) DeletePlatformConnection(ctx context.Context, accountID, platform string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM platform_connection WHERE account_id = $1 AND platform = $2`, accountID, platform)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete platform connection: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
114
internal/store/platformconnection_test.go
Normal file
114
internal/store/platformconnection_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlatformConnectionUpsertGetDelete(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
expires := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||||
|
c, err := s.UpsertPlatformConnection(ctx, accID, "instagram", "ig-user-1", "access-1", "", &expires)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertPlatformConnection: %v", err)
|
||||||
|
}
|
||||||
|
if c.Platform != "instagram" || c.PlatformUserID != "ig-user-1" {
|
||||||
|
t.Fatalf("UpsertPlatformConnection = %+v, unerwartete Werte", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetPlatformConnection(ctx, accID, "instagram")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPlatformConnection: %v", err)
|
||||||
|
}
|
||||||
|
if got.AccessToken != "access-1" {
|
||||||
|
t.Fatalf("AccessToken = %q, want access-1", got.AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 connection, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeletePlatformConnection(ctx, accID, "instagram"); err != nil {
|
||||||
|
t.Fatalf("DeletePlatformConnection: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetPlatformConnection(ctx, accID, "instagram"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err nach Delete = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformConnectionUpsertReplacesExisting(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
first, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-alt", "refresh-alt", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertPlatformConnection (1): %v", err)
|
||||||
|
}
|
||||||
|
second, err := s.UpsertPlatformConnection(ctx, accID, "tiktok", "tt-user-1", "access-neu", "refresh-neu", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertPlatformConnection (2): %v", err)
|
||||||
|
}
|
||||||
|
if second.ID != first.ID {
|
||||||
|
t.Fatalf("expected the same row to be updated (same account+platform), got a new ID")
|
||||||
|
}
|
||||||
|
if second.AccessToken != "access-neu" || second.RefreshToken != "refresh-neu" {
|
||||||
|
t.Fatalf("UpsertPlatformConnection (2) = %+v, tokens wurden nicht ersetzt", second)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListPlatformConnectionsForAccount(ctx, accID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPlatformConnectionsForAccount: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 connection after upsert-replace, got %d", len(list))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPlatformConnectionNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
_, err := s.GetPlatformConnection(ctx, accID, "instagram")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePlatformConnectionNotFound(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accID := testAccountID(t, s)
|
||||||
|
|
||||||
|
err := s.DeletePlatformConnection(ctx, accID, "tiktok")
|
||||||
|
if !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("err = %v, want store.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlatformConnectionIsolatedPerAccount(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
accA := testAccountID(t, s)
|
||||||
|
accB := testAccountID(t, s)
|
||||||
|
|
||||||
|
if _, err := s.UpsertPlatformConnection(ctx, accA, "instagram", "ig-a", "token-a", "", nil); err != nil {
|
||||||
|
t.Fatalf("UpsertPlatformConnection (A): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.GetPlatformConnection(ctx, accB, "instagram"); !errors.Is(err, store.ErrNotFound) {
|
||||||
|
t.Fatalf("Mandant B sollte keine Verbindung von Mandant A sehen, err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/netcell-it/deklarix/internal/store"
|
"github.com/netcell-it/deklarix/internal/store"
|
||||||
)
|
)
|
||||||
@@ -55,6 +56,11 @@ type participantView struct {
|
|||||||
ApprovedAt string // leer, wenn noch nicht freigegeben
|
ApprovedAt string // leer, wenn noch nicht freigegeben
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type insightsAssetView struct {
|
||||||
|
CreatedAt string
|
||||||
|
SHA256 string
|
||||||
|
}
|
||||||
|
|
||||||
type submissionDetailData struct {
|
type submissionDetailData struct {
|
||||||
Title string
|
Title string
|
||||||
Nav navData
|
Nav navData
|
||||||
@@ -69,6 +75,8 @@ type submissionDetailData struct {
|
|||||||
DossierURL string
|
DossierURL string
|
||||||
Findings []findingView
|
Findings []findingView
|
||||||
Participants []participantView
|
Participants []participantView
|
||||||
|
InsightsReminder insightsReminder
|
||||||
|
InsightsAssets []insightsAssetView
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
|
// loadOwnSubmission lädt eine Submission und prüft die Mandantenzugehörigkeit.
|
||||||
@@ -126,17 +134,76 @@ func (s *Server) handleSubmissionDetail(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assets, err := s.store.ListAssetsForSubmission(ctx, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Assets konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var insightsAssets []insightsAssetView
|
||||||
|
hasInsightsAsset := false
|
||||||
|
for _, a := range assets {
|
||||||
|
if a.Purpose == "insights" {
|
||||||
|
hasInsightsAsset = true
|
||||||
|
insightsAssets = append(insightsAssets, insightsAssetView{
|
||||||
|
CreatedAt: a.CreatedAt.Format("02.01.2006 15:04"), SHA256: a.SHA256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reminder insightsReminder
|
||||||
|
if sub.Status == "published" {
|
||||||
|
if pkg, err := s.store.GetLatestEvidencePackage(ctx, sub.ID); err == nil {
|
||||||
|
reminder = computeInsightsReminder(sub.PostType, pkg.CreatedAt, time.Now(), hasInsightsAsset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data := submissionDetailData{
|
data := submissionDetailData{
|
||||||
Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
|
Title: "Beitrag", Nav: navFor(r), SubmissionID: sub.ID, Platform: sub.Platform, PostType: sub.PostType,
|
||||||
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
|
Caption: sub.Caption, Status: sub.Status, CreatedAt: sub.CreatedAt.Format("02.01.2006 15:04"),
|
||||||
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
|
CanArchive: sub.Status == "checked", IsPublished: sub.Status == "published",
|
||||||
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
|
DossierURL: "/dossier/" + sub.ID, Findings: findings, Participants: toParticipantViews(participants),
|
||||||
|
InsightsReminder: reminder, InsightsAssets: insightsAssets,
|
||||||
}
|
}
|
||||||
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
|
if err := s.templates.ExecuteTemplate(w, "beitrag", data); err != nil {
|
||||||
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAddInsightsAsset speichert einen zusätzlichen Screenshot der
|
||||||
|
// Kennzahlen (Insights) eines bereits veröffentlichten Beitrags — siehe
|
||||||
|
// insightsReminder. Nutzt dieselbe Validierung wie das initiale
|
||||||
|
// Standbild beim Prüfen (readUploadedAsset/storeAsset), nur mit
|
||||||
|
// purpose="insights" statt "initial" und als eigener, jederzeit
|
||||||
|
// wiederholbarer Upload statt einmalig beim Anlegen der Submission.
|
||||||
|
func (s *Server) handleAddInsightsAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sub, err := s.loadOwnSubmission(r, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Beitrag nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxAssetSize+(1<<20))
|
||||||
|
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||||
|
http.Error(w, "ungültiges Formular (evtl. zu groß)", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asset, err := s.readUploadedAsset(r, "insights_standbild")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if asset == nil {
|
||||||
|
http.Error(w, "kein Standbild hochgeladen", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.storeAsset(r.Context(), sub.ID, "insights", asset); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/beitraege/"+sub.ID, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
type participantListData struct {
|
type participantListData struct {
|
||||||
SubmissionID string
|
SubmissionID string
|
||||||
Participants []participantView
|
Participants []participantView
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ type uploadedAsset struct {
|
|||||||
sha256Hex string
|
sha256Hex string
|
||||||
}
|
}
|
||||||
|
|
||||||
// readUploadedAsset liest das optionale "standbild"-Feld. Liefert
|
// readUploadedAsset liest das optionale Datei-Feld fieldName. Liefert
|
||||||
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
|
// (nil, nil), wenn kein Bild hochgeladen wurde — das ist der Normalfall,
|
||||||
// ein Standbild ist keine Pflichtangabe.
|
// ein Standbild ist keine Pflichtangabe.
|
||||||
func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
|
func (s *Server) readUploadedAsset(r *http.Request, fieldName string) (*uploadedAsset, error) {
|
||||||
file, header, err := r.FormFile("standbild")
|
file, header, err := r.FormFile(fieldName)
|
||||||
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
|
// ErrNotMultipart: die Anfrage war gar kein multipart/form-data (z. B.
|
||||||
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
|
// ältere Clients oder Tests mit urlencoded-Formular) — dann kann auch
|
||||||
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile.
|
// kein Standbild dabei sein, das ist derselbe Fall wie ErrMissingFile.
|
||||||
@@ -70,16 +70,22 @@ func (s *Server) readUploadedAsset(r *http.Request) (*uploadedAsset, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und
|
// storeAsset schreibt ein zuvor gelesenes Standbild auf die Platte und
|
||||||
// speichert die Asset-Zeile.
|
// speichert die Asset-Zeile. purpose ist "initial" (das Beweisfoto beim
|
||||||
func (s *Server) storeAsset(ctx context.Context, submissionID string, ua *uploadedAsset) error {
|
// Prüfen, ein Dateiname pro Submission reicht) oder "insights" (kann
|
||||||
|
// mehrfach vorkommen, braucht daher einen eindeutigen Dateinamen).
|
||||||
|
func (s *Server) storeAsset(ctx context.Context, submissionID, purpose string, ua *uploadedAsset) error {
|
||||||
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
|
if err := os.MkdirAll(s.assetDir, 0o750); err != nil {
|
||||||
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
|
return fmt.Errorf("Asset-Verzeichnis konnte nicht angelegt werden: %w", err)
|
||||||
}
|
}
|
||||||
path := filepath.Join(s.assetDir, submissionID+ua.extension)
|
filename := submissionID + ua.extension
|
||||||
|
if purpose != "initial" {
|
||||||
|
filename = fmt.Sprintf("%s-%s-%d%s", submissionID, purpose, time.Now().UnixNano(), ua.extension)
|
||||||
|
}
|
||||||
|
path := filepath.Join(s.assetDir, filename)
|
||||||
if err := os.WriteFile(path, ua.data, 0o640); err != nil {
|
if err := os.WriteFile(path, ua.data, 0o640); err != nil {
|
||||||
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err)
|
return fmt.Errorf("Standbild konnte nicht gespeichert werden: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := s.store.CreateAsset(ctx, submissionID, "image", path, ua.sha256Hex); err != nil {
|
if _, err := s.store.CreateAsset(ctx, submissionID, "image", purpose, path, ua.sha256Hex); err != nil {
|
||||||
return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
|
return fmt.Errorf("Asset konnte nicht gespeichert werden: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -145,7 +151,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
asset, err := s.readUploadedAsset(r)
|
asset, err := s.readUploadedAsset(r, "standbild")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -172,7 +178,7 @@ func (s *Server) handleCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if asset != nil {
|
if asset != nil {
|
||||||
if err := s.storeAsset(ctx, sub.ID, asset); err != nil {
|
if err := s.storeAsset(ctx, sub.ID, "initial", asset); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
132
internal/web/insights_handlers_test.go
Normal file
132
internal/web/insights_handlers_test.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/netcell-it/deklarix/internal/rules"
|
||||||
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postInsightsUpload lädt einen Insights-Screenshot für einen Beitrag hoch.
|
||||||
|
func postInsightsUpload(t *testing.T, s *web.Server, cookie *http.Cookie, submissionID string, imageBytes []byte) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&buf)
|
||||||
|
part, err := mw.CreatePart(map[string][]string{
|
||||||
|
"Content-Disposition": {`form-data; name="insights_standbild"; filename="insights.png"`},
|
||||||
|
"Content-Type": {"image/png"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePart: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(imageBytes); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
t.Fatalf("multipart Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/beitraege/"+submissionID+"/insights", &buf)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStorySubmissionShowsInsightsReminderAfterArchiving(t *testing.T) {
|
||||||
|
s, fs, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||||
|
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
|
||||||
|
|
||||||
|
subID := checkAndReturnSubID2(t, s, cookie, "story")
|
||||||
|
archiveResp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}})
|
||||||
|
if archiveResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("archive status = %d, body: %s", archiveResp.Code, archiveResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||||
|
if detailResp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
|
||||||
|
}
|
||||||
|
body := detailResp.Body.String()
|
||||||
|
if !strings.Contains(body, "Insights jetzt sichern") {
|
||||||
|
t.Errorf("expected an insights reminder for a freshly archived story, got: %s", body)
|
||||||
|
}
|
||||||
|
_ = fs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsightsUploadClearsReminder(t *testing.T) {
|
||||||
|
s, _, cookie := newAuthedTestServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||||
|
}, raw: []byte(`{"gegenleistung":"keine","kennzeichnung_vorhanden":false,"kennzeichnung_wortlaut":"","kennzeichnung_vor_kuerzung":false}`)})
|
||||||
|
|
||||||
|
subID := checkAndReturnSubID2(t, s, cookie, "story")
|
||||||
|
if resp := postForm(t, s, cookie, "/veroeffentlichen", url.Values{"submission_id": {subID}}); resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("archive status = %d, body: %s", resp.Code, resp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadResp := postInsightsUpload(t, s, cookie, subID, tinyPNG)
|
||||||
|
if uploadResp.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("insights upload status = %d, want 303, body: %s", uploadResp.Code, uploadResp.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
detailResp := getWithCookie(t, s, cookie, "/beitraege/"+subID)
|
||||||
|
body := detailResp.Body.String()
|
||||||
|
if strings.Contains(body, "Insights jetzt sichern") {
|
||||||
|
t.Errorf("expected the reminder to be gone after securing insights, got: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Gesicherte Insights-Nachweise") {
|
||||||
|
t.Errorf("expected the secured insights section, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInsightsUploadRejectsForeignSubmission(t *testing.T) {
|
||||||
|
fs := newFakeStore()
|
||||||
|
s := newServer(t, fakeExtractor{facts: rules.Facts{
|
||||||
|
Platform: "instagram", Jurisdiction: "DE", Consideration: rules.ConsiderationNone,
|
||||||
|
}}, fs)
|
||||||
|
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
|
||||||
|
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
|
||||||
|
|
||||||
|
subID := checkAndReturnSubID2(t, s, cookieA, "story")
|
||||||
|
|
||||||
|
resp := postInsightsUpload(t, s, cookieB, subID, tinyPNG)
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cross-tenant insights upload status = %d, want 404", resp.Code)
|
||||||
|
}
|
||||||
|
if _, err := fs.GetLatestAssetForSubmission(context.Background(), subID); err == nil {
|
||||||
|
t.Fatal("expected no asset from a rejected cross-tenant upload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAndReturnSubID2 ist checkAndReturnSubID mit ueberschreibbarem post_type.
|
||||||
|
func checkAndReturnSubID2(t *testing.T, s interface {
|
||||||
|
ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||||
|
}, cookie *http.Cookie, postType string) string {
|
||||||
|
t.Helper()
|
||||||
|
form := checkForm("post_type", postType)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/pruefen", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check status = %d, body: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
const marker = `name="submission_id" value="`
|
||||||
|
idx := strings.Index(body, marker)
|
||||||
|
if idx == -1 {
|
||||||
|
t.Fatalf("expected a submission_id field in the result, got: %s", body)
|
||||||
|
}
|
||||||
|
rest := body[idx+len(marker):]
|
||||||
|
return rest[:strings.Index(rest, `"`)]
|
||||||
|
}
|
||||||
47
internal/web/insights_reminder.go
Normal file
47
internal/web/insights_reminder.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// storyInsightsWindow ist das Zeitfenster, in dem Instagram nach
|
||||||
|
// eigener Aussage Story-Insights überhaupt vorhält — danach sind sie
|
||||||
|
// auch über den offiziellen Datenexport nicht mehr zu bekommen. Reine
|
||||||
|
// Produktentscheidung (Erinnerungs-Timing), keine Rechtsnorm — bewusst
|
||||||
|
// hier als Konstante und nicht in rules/*.yaml, das ist ausschließlich
|
||||||
|
// für die Kennzeichnungsprüfung reserviert (siehe CLAUDE.md).
|
||||||
|
const storyInsightsWindow = 24 * time.Hour
|
||||||
|
|
||||||
|
// insightsReminder beschreibt, ob und wie dringend eine Erinnerung
|
||||||
|
// angezeigt werden soll, die Kennzahlen (Insights) eines veröffentlichten
|
||||||
|
// Beitrags per Screenshot zu sichern, bevor sie unwiederbringlich
|
||||||
|
// verschwinden.
|
||||||
|
type insightsReminder struct {
|
||||||
|
Show bool
|
||||||
|
Urgent bool // Fenster läuft noch
|
||||||
|
Expired bool // Fenster ist wahrscheinlich schon vorbei
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeInsightsReminder berechnet den Erinnerungsstatus. Nur für
|
||||||
|
// "story"-Beiträge relevant (siehe storyInsightsWindow); alle anderen
|
||||||
|
// Beitragstypen bekommen keine Erinnerung, da ihre Kennzahlen nicht auf
|
||||||
|
// dieselbe Art flüchtig sind.
|
||||||
|
func computeInsightsReminder(postType string, publishedAt time.Time, now time.Time, hasInsightsAsset bool) insightsReminder {
|
||||||
|
if postType != "story" || hasInsightsAsset || publishedAt.IsZero() {
|
||||||
|
return insightsReminder{}
|
||||||
|
}
|
||||||
|
elapsed := now.Sub(publishedAt)
|
||||||
|
if elapsed >= storyInsightsWindow {
|
||||||
|
return insightsReminder{
|
||||||
|
Show: true, Expired: true,
|
||||||
|
Message: "Das Zeitfenster für Story-Insights ist bei Instagram nach eigener Aussage abgelaufen (24 Stunden) — die Kennzahlen sind wahrscheinlich nicht mehr abrufbar.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remainingHours := int((storyInsightsWindow - elapsed).Hours()) + 1
|
||||||
|
return insightsReminder{
|
||||||
|
Show: true, Urgent: true,
|
||||||
|
Message: fmt.Sprintf("Noch ca. %d Stunde(n), um die Story-Insights zu sichern, bevor sie bei Instagram verschwinden.", remainingHours),
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/web/insights_reminder_test.go
Normal file
45
internal/web/insights_reminder_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputeInsightsReminderOnlyForStory(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
r := computeInsightsReminder("feed", now.Add(-time.Hour), now, false)
|
||||||
|
if r.Show {
|
||||||
|
t.Errorf("expected no reminder for a non-story post type, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeInsightsReminderSkippedWhenAlreadySecured(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
r := computeInsightsReminder("story", now.Add(-time.Hour), now, true)
|
||||||
|
if r.Show {
|
||||||
|
t.Errorf("expected no reminder once an insights asset already exists, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeInsightsReminderUrgentWithinWindow(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
r := computeInsightsReminder("story", now.Add(-2*time.Hour), now, false)
|
||||||
|
if !r.Show || !r.Urgent || r.Expired {
|
||||||
|
t.Fatalf("expected an urgent, non-expired reminder, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeInsightsReminderExpiredAfterWindow(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
r := computeInsightsReminder("story", now.Add(-25*time.Hour), now, false)
|
||||||
|
if !r.Show || !r.Expired || r.Urgent {
|
||||||
|
t.Fatalf("expected an expired reminder, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeInsightsReminderSkippedWithoutPublishTime(t *testing.T) {
|
||||||
|
r := computeInsightsReminder("story", time.Time{}, time.Now(), false)
|
||||||
|
if r.Show {
|
||||||
|
t.Errorf("expected no reminder without a known publish time, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
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/evidence"
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"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/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,8 +71,13 @@ type Store interface {
|
|||||||
DeleteSession(ctx context.Context, token string) error
|
DeleteSession(ctx context.Context, token string) error
|
||||||
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
CreateAuditEntry(ctx context.Context, actorUserID, action, targetType, targetID, details string) (store.AuditEntry, error)
|
||||||
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
ListAuditLog(ctx context.Context, limit int) ([]store.AuditEntry, error)
|
||||||
CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error)
|
CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error)
|
||||||
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
|
GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error)
|
||||||
|
ListAssetsForSubmission(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.
|
// Server bündelt Routing und Abhängigkeiten der Web-Schicht.
|
||||||
@@ -83,14 +89,19 @@ type Server struct {
|
|||||||
timestamper evidence.Timestamper
|
timestamper evidence.Timestamper
|
||||||
dossierDir string
|
dossierDir string
|
||||||
assetDir string
|
assetDir string
|
||||||
|
connectors map[string]socialconnect.Connector
|
||||||
templates *template.Template
|
templates *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
// NewServer erstellt den Server. ruleSet kommt von rules.Load und wird
|
||||||
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
// einmal beim Start geladen, nicht pro Request. dossierDir ist das
|
||||||
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
|
// Verzeichnis, in das erzeugte Nachweis-Dossiers geschrieben werden;
|
||||||
// assetDir das Verzeichnis für hochgeladene Standbilder.
|
// assetDir das Verzeichnis für hochgeladene Standbilder. connectors
|
||||||
func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper evidence.Timestamper, dossierDir, assetDir string) (*Server, error) {
|
// 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")
|
tmpl, err := template.ParseFS(templatesFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
return nil, fmt.Errorf("web: templates parsen: %w", err)
|
||||||
@@ -103,6 +114,7 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
|
|||||||
timestamper: timestamper,
|
timestamper: timestamper,
|
||||||
dossierDir: dossierDir,
|
dossierDir: dossierDir,
|
||||||
assetDir: assetDir,
|
assetDir: assetDir,
|
||||||
|
connectors: connectors,
|
||||||
templates: tmpl,
|
templates: tmpl,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +134,12 @@ func NewServer(extractor Extractor, ruleSet []rules.Rule, st Store, timestamper
|
|||||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
|
mux.HandleFunc("POST /beitraege/{id}/beteiligte", s.requireAPI(s.handleAddParticipant))
|
||||||
mux.HandleFunc("POST /beitraege/{id}/beteiligte/{pid}/aktualisieren", s.requireAPI(s.handleUpdateParticipant))
|
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("POST /beitraege/{id}/beteiligte/{pid}/loeschen", s.requireAPI(s.handleDeleteParticipant))
|
||||||
|
mux.HandleFunc("POST /beitraege/{id}/insights", s.requireAPI(s.handleAddInsightsAsset))
|
||||||
mux.HandleFunc("GET /kanzleien", s.handlePublicKanzleiList)
|
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", s.requireAdmin(s.handleAdminDashboard))
|
||||||
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
|
mux.HandleFunc("GET /admin/accounts", s.requireAdmin(s.handleAdminAccountList))
|
||||||
mux.HandleFunc("GET /admin/accounts/{id}", s.requireAdmin(s.handleAdminAccountDetail))
|
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/auth"
|
||||||
"github.com/netcell-it/deklarix/internal/extract"
|
"github.com/netcell-it/deklarix/internal/extract"
|
||||||
"github.com/netcell-it/deklarix/internal/rules"
|
"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/store"
|
||||||
"github.com/netcell-it/deklarix/internal/web"
|
"github.com/netcell-it/deklarix/internal/web"
|
||||||
)
|
)
|
||||||
@@ -68,7 +69,11 @@ type fakeStore struct {
|
|||||||
evidencePkgs map[string]store.EvidencePackage
|
evidencePkgs map[string]store.EvidencePackage
|
||||||
participants map[string]store.Participant
|
participants map[string]store.Participant
|
||||||
auditLog []store.AuditEntry
|
auditLog []store.AuditEntry
|
||||||
assets map[string]store.Asset // submissionID -> zuletzt hochgeladenes Asset
|
assets map[string][]store.Asset // submissionID -> alle Assets, aeltestes zuerst
|
||||||
|
|
||||||
|
// 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 {
|
func newFakeStore() *fakeStore {
|
||||||
@@ -82,7 +87,8 @@ func newFakeStore() *fakeStore {
|
|||||||
findings: map[string][]store.Finding{},
|
findings: map[string][]store.Finding{},
|
||||||
evidencePkgs: map[string]store.EvidencePackage{},
|
evidencePkgs: map[string]store.EvidencePackage{},
|
||||||
participants: map[string]store.Participant{},
|
participants: map[string]store.Participant{},
|
||||||
assets: map[string]store.Asset{},
|
assets: map[string][]store.Asset{},
|
||||||
|
platformConnections: map[string]map[string]store.PlatformConnection{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,24 +350,93 @@ func (f *fakeStore) GetLatestEvidencePackage(ctx context.Context, submissionID s
|
|||||||
return pkg, nil
|
return pkg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, path, sha256Hex string) (store.Asset, error) {
|
func (f *fakeStore) CreateAsset(ctx context.Context, submissionID, kind, purpose, path, sha256Hex string) (store.Asset, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
a := store.Asset{
|
a := store.Asset{
|
||||||
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
|
ID: f.newID(), SubmissionID: submissionID, Kind: kind, Purpose: purpose,
|
||||||
|
Path: path, SHA256: sha256Hex, CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
f.assets[submissionID] = a
|
f.assets[submissionID] = append(f.assets[submissionID], a)
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLatestAssetForSubmission liefert wie die echte Store-Implementierung
|
||||||
|
// nur das zuletzt hochgeladene Asset mit purpose="initial".
|
||||||
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
|
func (f *fakeStore) GetLatestAssetForSubmission(ctx context.Context, submissionID string) (store.Asset, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
a, ok := f.assets[submissionID]
|
var latest store.Asset
|
||||||
if !ok {
|
found := false
|
||||||
|
for _, a := range f.assets[submissionID] {
|
||||||
|
if a.Purpose == "initial" {
|
||||||
|
latest = a
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
return store.Asset{}, store.ErrNotFound
|
return store.Asset{}, store.ErrNotFound
|
||||||
}
|
}
|
||||||
return a, nil
|
return latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) ListAssetsForSubmission(ctx context.Context, submissionID string) ([]store.Asset, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.assets[submissionID], 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) {
|
func (f *fakeStore) ListSubmissionsForAccount(ctx context.Context, accountID string) ([]store.SubmissionSummary, error) {
|
||||||
@@ -522,7 +597,12 @@ func loadRealRules(t *testing.T) []rules.Rule {
|
|||||||
|
|
||||||
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
|
func newServer(t *testing.T, ex web.Extractor, fs *fakeStore) *web.Server {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("NewServer: %v", err)
|
t.Fatalf("NewServer: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,28 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if .IsPublished}}
|
{{if .IsPublished}}
|
||||||
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
|
<p><a href="{{.DossierURL}}">Nachweis-Dossier (PDF) herunterladen</a></p>
|
||||||
|
|
||||||
|
{{if .InsightsReminder.Show}}
|
||||||
|
<div class="{{if .InsightsReminder.Expired}}fehler{{else}}rueckfrage{{end}}">
|
||||||
|
<p>{{.InsightsReminder.Message}}</p>
|
||||||
|
<form method="post" action="/beitraege/{{.SubmissionID}}/insights" enctype="multipart/form-data">
|
||||||
|
<label for="insights_standbild">Insights-Screenshot</label>
|
||||||
|
<input type="file" id="insights_standbild" name="insights_standbild" accept="image/*" required>
|
||||||
|
<button type="submit">Insights jetzt sichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .InsightsAssets}}
|
||||||
|
<h2>Gesicherte Insights-Nachweise</h2>
|
||||||
|
<ul class="beteiligte">
|
||||||
|
{{range .InsightsAssets}}
|
||||||
|
<li class="beteiligter">
|
||||||
|
<div class="beteiligter-kopf">{{.CreatedAt}} — SHA-256: {{.SHA256}}</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<h2>Verantwortungsmatrix</h2>
|
<h2>Verantwortungsmatrix</h2>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">Prüfen</a>
|
<a href="/">Prüfen</a>
|
||||||
<a href="/beitraege">Beiträge</a>
|
<a href="/beitraege">Beiträge</a>
|
||||||
|
<a href="/verbindungen">Verbindungen</a>
|
||||||
{{if .IsAdmin}}<a href="/admin">Admin</a>{{end}}
|
{{if .IsAdmin}}<a href="/admin">Admin</a>{{end}}
|
||||||
<form method="post" action="/logout" style="display:inline">
|
<form method="post" action="/logout" style="display:inline">
|
||||||
<button type="submit">Abmelden</button>
|
<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}}
|
||||||
@@ -22,6 +22,16 @@ ASSET_DIR=/var/lib/deklarix/assets
|
|||||||
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
|
# Kundeneinsatz auf einen eIDAS-qualifizierten Dienst umstellen.
|
||||||
#TSA_URL=https://freetsa.org/tsr
|
#TSA_URL=https://freetsa.org/tsr
|
||||||
|
|
||||||
|
# Optionale Plattform-Verbindung (OAuth, siehe CLAUDE.md "Plattform-
|
||||||
|
# Verbindung (OAuth)"). Ohne PUBLIC_BASE_URL und die Credentials der
|
||||||
|
# jeweiligen Plattform zeigt /verbindungen nur "nicht konfiguriert" —
|
||||||
|
# kein Absturz, keine dieser vier Variablen ist Pflicht.
|
||||||
|
#PUBLIC_BASE_URL=https://app.deklarix.de
|
||||||
|
#INSTAGRAM_CLIENT_ID=
|
||||||
|
#INSTAGRAM_CLIENT_SECRET=
|
||||||
|
#TIKTOK_CLIENT_KEY=
|
||||||
|
#TIKTOK_CLIENT_SECRET=
|
||||||
|
|
||||||
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
|
# Pflicht — der Dienst startet nicht ohne gültige DATABASE_URL.
|
||||||
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
|
# Auskommentiert lassen, bis ein echter Wert eingetragen ist: postinst
|
||||||
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
# prüft genau diese Zeile, um den Dienst nicht blind in eine Restart-
|
||||||
|
|||||||
Reference in New Issue
Block a user