Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
9.5 KiB
Go
308 lines
9.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"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),
|
|
})
|
|
}
|