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) }