Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
561
consent-service/internal/handlers/banner_handlers.go
Normal file
561
consent-service/internal/handlers/banner_handlers.go
Normal file
@@ -0,0 +1,561 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// Cookie Banner SDK API Handlers
|
||||
// ========================================
|
||||
// Diese Endpoints werden vom @breakpilot/consent-sdk verwendet
|
||||
// für anonyme (device-basierte) Cookie-Einwilligungen.
|
||||
|
||||
// BannerConsentRecord repräsentiert einen anonymen Consent-Eintrag
|
||||
type BannerConsentRecord struct {
|
||||
ID string `json:"id"`
|
||||
SiteID string `json:"site_id"`
|
||||
DeviceFingerprint string `json:"device_fingerprint"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
Categories map[string]bool `json:"categories"`
|
||||
Vendors map[string]bool `json:"vendors,omitempty"`
|
||||
TCFString *string `json:"tcf_string,omitempty"`
|
||||
IPHash *string `json:"ip_hash,omitempty"`
|
||||
UserAgent *string `json:"user_agent,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Platform *string `json:"platform,omitempty"`
|
||||
AppVersion *string `json:"app_version,omitempty"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// BannerConsentRequest ist der Request-Body für POST /consent
|
||||
type BannerConsentRequest struct {
|
||||
SiteID string `json:"siteId" binding:"required"`
|
||||
UserID *string `json:"userId,omitempty"`
|
||||
DeviceFingerprint string `json:"deviceFingerprint" binding:"required"`
|
||||
Consent ConsentData `json:"consent" binding:"required"`
|
||||
Metadata *ConsentMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ConsentData enthält die eigentlichen Consent-Daten
|
||||
type ConsentData struct {
|
||||
Categories map[string]bool `json:"categories" binding:"required"`
|
||||
Vendors map[string]bool `json:"vendors,omitempty"`
|
||||
}
|
||||
|
||||
// ConsentMetadata enthält optionale Metadaten
|
||||
type ConsentMetadata struct {
|
||||
UserAgent *string `json:"userAgent,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ScreenResolution *string `json:"screenResolution,omitempty"`
|
||||
Platform *string `json:"platform,omitempty"`
|
||||
AppVersion *string `json:"appVersion,omitempty"`
|
||||
}
|
||||
|
||||
// BannerConsentResponse ist die Antwort auf POST /consent
|
||||
type BannerConsentResponse struct {
|
||||
ConsentID string `json:"consentId"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// SiteConfig repräsentiert die Konfiguration für eine Site
|
||||
type SiteConfig struct {
|
||||
SiteID string `json:"siteId"`
|
||||
SiteName string `json:"siteName"`
|
||||
Categories []CategoryConfig `json:"categories"`
|
||||
UI UIConfig `json:"ui"`
|
||||
Legal LegalConfig `json:"legal"`
|
||||
TCF *TCFConfig `json:"tcf,omitempty"`
|
||||
}
|
||||
|
||||
// CategoryConfig repräsentiert eine Consent-Kategorie
|
||||
type CategoryConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name map[string]string `json:"name"`
|
||||
Description map[string]string `json:"description"`
|
||||
Required bool `json:"required"`
|
||||
Vendors []VendorConfig `json:"vendors"`
|
||||
}
|
||||
|
||||
// VendorConfig repräsentiert einen Vendor (Third-Party)
|
||||
type VendorConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PrivacyPolicyURL string `json:"privacyPolicyUrl"`
|
||||
Cookies []CookieInfo `json:"cookies"`
|
||||
}
|
||||
|
||||
// CookieInfo repräsentiert ein Cookie
|
||||
type CookieInfo struct {
|
||||
Name string `json:"name"`
|
||||
Expiration string `json:"expiration"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// UIConfig repräsentiert UI-Einstellungen
|
||||
type UIConfig struct {
|
||||
Theme string `json:"theme"`
|
||||
Position string `json:"position"`
|
||||
}
|
||||
|
||||
// LegalConfig repräsentiert rechtliche Informationen
|
||||
type LegalConfig struct {
|
||||
PrivacyPolicyURL string `json:"privacyPolicyUrl"`
|
||||
ImprintURL string `json:"imprintUrl"`
|
||||
}
|
||||
|
||||
// TCFConfig repräsentiert TCF 2.2 Einstellungen
|
||||
type TCFConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
CmpID int `json:"cmpId"`
|
||||
CmpVersion int `json:"cmpVersion"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Handler Methods
|
||||
// ========================================
|
||||
|
||||
// CreateBannerConsent erstellt oder aktualisiert einen Consent-Eintrag
|
||||
// POST /api/v1/banner/consent
|
||||
func (h *Handler) CreateBannerConsent(c *gin.Context) {
|
||||
var req BannerConsentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"message": "Invalid request body: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// IP-Adresse anonymisieren
|
||||
ipHash := anonymizeIP(c.ClientIP())
|
||||
|
||||
// Consent-ID generieren
|
||||
consentID := uuid.New().String()
|
||||
|
||||
// Ablaufdatum (1 Jahr)
|
||||
expiresAt := time.Now().AddDate(1, 0, 0)
|
||||
|
||||
// Categories und Vendors als JSON
|
||||
categoriesJSON, _ := json.Marshal(req.Consent.Categories)
|
||||
vendorsJSON, _ := json.Marshal(req.Consent.Vendors)
|
||||
|
||||
// Metadaten extrahieren
|
||||
var userAgent, language, platform, appVersion *string
|
||||
if req.Metadata != nil {
|
||||
userAgent = req.Metadata.UserAgent
|
||||
language = req.Metadata.Language
|
||||
platform = req.Metadata.Platform
|
||||
appVersion = req.Metadata.AppVersion
|
||||
}
|
||||
|
||||
// In Datenbank speichern
|
||||
_, err := h.db.Pool.Exec(ctx, `
|
||||
INSERT INTO banner_consents (
|
||||
id, site_id, device_fingerprint, user_id,
|
||||
categories, vendors, ip_hash, user_agent,
|
||||
language, platform, app_version, version,
|
||||
expires_at, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
|
||||
ON CONFLICT (site_id, device_fingerprint)
|
||||
DO UPDATE SET
|
||||
categories = $5,
|
||||
vendors = $6,
|
||||
ip_hash = $7,
|
||||
user_agent = $8,
|
||||
language = $9,
|
||||
platform = $10,
|
||||
app_version = $11,
|
||||
version = $12,
|
||||
expires_at = $13,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
`, consentID, req.SiteID, req.DeviceFingerprint, req.UserID,
|
||||
categoriesJSON, vendorsJSON, ipHash, userAgent,
|
||||
language, platform, appVersion, "1.0.0", expiresAt)
|
||||
|
||||
if err != nil {
|
||||
// Fallback: Existierenden Consent abrufen
|
||||
var existingID string
|
||||
err2 := h.db.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM banner_consents
|
||||
WHERE site_id = $1 AND device_fingerprint = $2
|
||||
`, req.SiteID, req.DeviceFingerprint).Scan(&existingID)
|
||||
|
||||
if err2 == nil {
|
||||
consentID = existingID
|
||||
}
|
||||
}
|
||||
|
||||
// Audit-Log schreiben
|
||||
h.logBannerConsentAudit(ctx, consentID, "created", req, ipHash)
|
||||
|
||||
// Response
|
||||
c.JSON(http.StatusCreated, BannerConsentResponse{
|
||||
ConsentID: consentID,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
|
||||
Version: "1.0.0",
|
||||
})
|
||||
}
|
||||
|
||||
// GetBannerConsent ruft einen bestehenden Consent ab
|
||||
// GET /api/v1/banner/consent?siteId=xxx&deviceFingerprint=xxx
|
||||
func (h *Handler) GetBannerConsent(c *gin.Context) {
|
||||
siteID := c.Query("siteId")
|
||||
deviceFingerprint := c.Query("deviceFingerprint")
|
||||
|
||||
if siteID == "" || deviceFingerprint == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "missing_parameters",
|
||||
"message": "siteId and deviceFingerprint are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var record BannerConsentRecord
|
||||
var categoriesJSON, vendorsJSON []byte
|
||||
|
||||
err := h.db.Pool.QueryRow(ctx, `
|
||||
SELECT id, site_id, device_fingerprint, user_id,
|
||||
categories, vendors, version,
|
||||
created_at, updated_at, expires_at, revoked_at
|
||||
FROM banner_consents
|
||||
WHERE site_id = $1 AND device_fingerprint = $2 AND revoked_at IS NULL
|
||||
`, siteID, deviceFingerprint).Scan(
|
||||
&record.ID, &record.SiteID, &record.DeviceFingerprint, &record.UserID,
|
||||
&categoriesJSON, &vendorsJSON, &record.Version,
|
||||
&record.CreatedAt, &record.UpdatedAt, &record.ExpiresAt, &record.RevokedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "consent_not_found",
|
||||
"message": "No consent record found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// JSON parsen
|
||||
json.Unmarshal(categoriesJSON, &record.Categories)
|
||||
json.Unmarshal(vendorsJSON, &record.Vendors)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"consentId": record.ID,
|
||||
"consent": gin.H{
|
||||
"categories": record.Categories,
|
||||
"vendors": record.Vendors,
|
||||
},
|
||||
"createdAt": record.CreatedAt.UTC().Format(time.RFC3339),
|
||||
"updatedAt": record.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
"expiresAt": record.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
"version": record.Version,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeBannerConsent widerruft einen Consent
|
||||
// DELETE /api/v1/banner/consent/:consentId
|
||||
func (h *Handler) RevokeBannerConsent(c *gin.Context) {
|
||||
consentID := c.Param("consentId")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE banner_consents
|
||||
SET revoked_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL
|
||||
`, consentID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "revoke_failed",
|
||||
"message": "Failed to revoke consent",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "consent_not_found",
|
||||
"message": "Consent not found or already revoked",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit-Log
|
||||
h.logBannerConsentAudit(ctx, consentID, "revoked", nil, anonymizeIP(c.ClientIP()))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "revoked",
|
||||
"revokedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// GetSiteConfig gibt die Konfiguration für eine Site zurück
|
||||
// GET /api/v1/banner/config/:siteId
|
||||
func (h *Handler) GetSiteConfig(c *gin.Context) {
|
||||
siteID := c.Param("siteId")
|
||||
|
||||
// Standard-Kategorien (aus Datenbank oder Default)
|
||||
categories := []CategoryConfig{
|
||||
{
|
||||
ID: "essential",
|
||||
Name: map[string]string{
|
||||
"de": "Essentiell",
|
||||
"en": "Essential",
|
||||
},
|
||||
Description: map[string]string{
|
||||
"de": "Notwendig für die Grundfunktionen der Website.",
|
||||
"en": "Required for basic website functionality.",
|
||||
},
|
||||
Required: true,
|
||||
Vendors: []VendorConfig{},
|
||||
},
|
||||
{
|
||||
ID: "functional",
|
||||
Name: map[string]string{
|
||||
"de": "Funktional",
|
||||
"en": "Functional",
|
||||
},
|
||||
Description: map[string]string{
|
||||
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
|
||||
"en": "Enables personalization and comfort features.",
|
||||
},
|
||||
Required: false,
|
||||
Vendors: []VendorConfig{},
|
||||
},
|
||||
{
|
||||
ID: "analytics",
|
||||
Name: map[string]string{
|
||||
"de": "Statistik",
|
||||
"en": "Analytics",
|
||||
},
|
||||
Description: map[string]string{
|
||||
"de": "Hilft uns, die Website zu verbessern.",
|
||||
"en": "Helps us improve the website.",
|
||||
},
|
||||
Required: false,
|
||||
Vendors: []VendorConfig{},
|
||||
},
|
||||
{
|
||||
ID: "marketing",
|
||||
Name: map[string]string{
|
||||
"de": "Marketing",
|
||||
"en": "Marketing",
|
||||
},
|
||||
Description: map[string]string{
|
||||
"de": "Ermöglicht personalisierte Werbung.",
|
||||
"en": "Enables personalized advertising.",
|
||||
},
|
||||
Required: false,
|
||||
Vendors: []VendorConfig{},
|
||||
},
|
||||
{
|
||||
ID: "social",
|
||||
Name: map[string]string{
|
||||
"de": "Soziale Medien",
|
||||
"en": "Social Media",
|
||||
},
|
||||
Description: map[string]string{
|
||||
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
|
||||
"en": "Enables content from social networks.",
|
||||
},
|
||||
Required: false,
|
||||
Vendors: []VendorConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
config := SiteConfig{
|
||||
SiteID: siteID,
|
||||
SiteName: "BreakPilot",
|
||||
Categories: categories,
|
||||
UI: UIConfig{
|
||||
Theme: "auto",
|
||||
Position: "bottom",
|
||||
},
|
||||
Legal: LegalConfig{
|
||||
PrivacyPolicyURL: "/datenschutz",
|
||||
ImprintURL: "/impressum",
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
|
||||
// GET /api/v1/banner/consent/export?userId=xxx
|
||||
func (h *Handler) ExportBannerConsent(c *gin.Context) {
|
||||
userID := c.Query("userId")
|
||||
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "missing_user_id",
|
||||
"message": "userId parameter is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT id, site_id, device_fingerprint, categories, vendors,
|
||||
version, created_at, updated_at, revoked_at
|
||||
FROM banner_consents
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "export_failed",
|
||||
"message": "Failed to export consent data",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var consents []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, siteID, deviceFingerprint, version string
|
||||
var categoriesJSON, vendorsJSON []byte
|
||||
var createdAt, updatedAt time.Time
|
||||
var revokedAt *time.Time
|
||||
|
||||
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
|
||||
&version, &createdAt, &updatedAt, &revokedAt)
|
||||
|
||||
var categories, vendors map[string]bool
|
||||
json.Unmarshal(categoriesJSON, &categories)
|
||||
json.Unmarshal(vendorsJSON, &vendors)
|
||||
|
||||
consent := map[string]interface{}{
|
||||
"consentId": id,
|
||||
"siteId": siteID,
|
||||
"consent": map[string]interface{}{
|
||||
"categories": categories,
|
||||
"vendors": vendors,
|
||||
},
|
||||
"createdAt": createdAt.UTC().Format(time.RFC3339),
|
||||
"revokedAt": nil,
|
||||
}
|
||||
|
||||
if revokedAt != nil {
|
||||
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
consents = append(consents, consent)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"userId": userID,
|
||||
"exportedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
"consents": consents,
|
||||
})
|
||||
}
|
||||
|
||||
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
|
||||
// GET /api/v1/banner/admin/stats/:siteId
|
||||
func (h *Handler) GetBannerStats(c *gin.Context) {
|
||||
siteID := c.Param("siteId")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Gesamtanzahl Consents
|
||||
var totalConsents int
|
||||
h.db.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM banner_consents
|
||||
WHERE site_id = $1 AND revoked_at IS NULL
|
||||
`, siteID).Scan(&totalConsents)
|
||||
|
||||
// Consent-Rate pro Kategorie
|
||||
categoryStats := make(map[string]map[string]interface{})
|
||||
|
||||
rows, _ := h.db.Pool.Query(ctx, `
|
||||
SELECT
|
||||
key as category,
|
||||
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
|
||||
COUNT(*) as total
|
||||
FROM banner_consents,
|
||||
jsonb_each(categories::jsonb)
|
||||
WHERE site_id = $1 AND revoked_at IS NULL
|
||||
GROUP BY key
|
||||
`, siteID)
|
||||
|
||||
if rows != nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var category string
|
||||
var accepted, total int
|
||||
rows.Scan(&category, &accepted, &total)
|
||||
|
||||
rate := float64(0)
|
||||
if total > 0 {
|
||||
rate = float64(accepted) / float64(total)
|
||||
}
|
||||
|
||||
categoryStats[category] = map[string]interface{}{
|
||||
"accepted": accepted,
|
||||
"rate": rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"siteId": siteID,
|
||||
"period": gin.H{
|
||||
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
|
||||
"to": time.Now().Format("2006-01-02"),
|
||||
},
|
||||
"totalConsents": totalConsents,
|
||||
"consentByCategory": categoryStats,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
|
||||
func anonymizeIP(ip string) string {
|
||||
// IPv4: Letztes Oktett auf 0
|
||||
parts := strings.Split(ip, ".")
|
||||
if len(parts) == 4 {
|
||||
parts[3] = "0"
|
||||
anonymized := strings.Join(parts, ".")
|
||||
hash := sha256.Sum256([]byte(anonymized))
|
||||
return hex.EncodeToString(hash[:])[:16]
|
||||
}
|
||||
|
||||
// IPv6: Hash
|
||||
hash := sha256.Sum256([]byte(ip))
|
||||
return hex.EncodeToString(hash[:])[:16]
|
||||
}
|
||||
|
||||
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
|
||||
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
|
||||
details, _ := json.Marshal(req)
|
||||
|
||||
h.db.Pool.Exec(ctx, `
|
||||
INSERT INTO banner_consent_audit_log (
|
||||
id, consent_id, action, details, ip_hash, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
`, uuid.New().String(), consentID, action, string(details), ipHash)
|
||||
}
|
||||
Reference in New Issue
Block a user