package main import ( "errors" "net/http" "net/url" "strings" ) const adminSettingsID = "default" func (a *app) settingsAdminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "admin") { writeError(w, http.StatusForbidden, "insufficient role for admin settings") return } next.ServeHTTP(w, r) }) } func (a *app) getAdminSettings(w http.ResponseWriter, r *http.Request) { doc := defaultAdminSettingsDocument() if a.db != nil { stored, err := a.getByID("platform_settings", adminSettingsID) if err == nil { doc = mergeAdminSettingsDocument(doc, stored) } else if !errors.Is(err, ErrNotFound) { writeError(w, http.StatusInternalServerError, "failed to load admin settings") return } } writeJSON(w, http.StatusOK, Doc{ "integrations": adminIntegrationStatus(a.cfg), "settings": adminSettingsView(doc), "environment": Doc{ "notificationProvider": a.cfg.NotificationProvider, "keycloakAuthDisabled": a.cfg.AuthDisabled, "keycloakThemeDeploy": a.cfg.KeycloakThemeDeployEnabled, "databaseConnected": a.db != nil, }, }) } func (a *app) patchAdminSettings(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) requestMetadata := map[string]any{"operation": "patch_admin_settings"} if a.db == nil { a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ "reason": "database_not_configured", }) writeError(w, http.StatusServiceUnavailable, "database not configured") return } var payload map[string]any if err := decodeJSON(r, &payload); err != nil { a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ "reason": "invalid_patch_payload", }) writeError(w, http.StatusBadRequest, "invalid patch payload") return } requestMetadata["payloadSections"] = []string{} if _, ok := payload["platformIdentity"]; ok { requestMetadata["payloadSections"] = append(requestMetadata["payloadSections"].([]string), "platformIdentity") } if _, ok := payload["seoDefaults"]; ok { requestMetadata["payloadSections"] = append(requestMetadata["payloadSections"].([]string), "seoDefaults") } if _, ok := payload["consentLinks"]; ok { requestMetadata["payloadSections"] = append(requestMetadata["payloadSections"].([]string), "consentLinks") } patchDoc, err := parseAdminSettingsPayload(payload) if err != nil { a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ "reason": "validation_error", "message": err.Error(), }) writeError(w, http.StatusBadRequest, err.Error()) return } if len(patchDoc) == 0 { a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ "reason": "no_changes", }) writeError(w, http.StatusBadRequest, "no settings changes provided") return } existing, err := a.getByID("platform_settings", adminSettingsID) if err != nil { if errors.Is(err, ErrNotFound) { base := defaultAdminSettingsDocument() merged := mergeAdminSettingsDocument(base, patchDoc) merged["id"] = adminSettingsID if _, createErr := a.create("platform_settings", merged); createErr != nil { a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ "reason": "create_failed", }) writeError(w, http.StatusInternalServerError, "failed to persist admin settings") return } a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultSuccess, requestMetadata) writeJSON(w, http.StatusOK, Doc{"settings": adminSettingsView(merged)}) return } a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ "reason": "load_failed", }) writeError(w, http.StatusInternalServerError, "failed to persist admin settings") return } merged := mergeAdminSettingsDocument(existing, patchDoc) updated, err := a.patch("platform_settings", adminSettingsID, Doc{ "platformIdentity": merged["platformIdentity"], "seoDefaults": merged["seoDefaults"], "consentLinks": merged["consentLinks"], }) if err != nil { a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultFailed, map[string]any{ "reason": "patch_failed", }) writeError(w, http.StatusInternalServerError, "failed to persist admin settings") return } a.recordAuditEvent(r.Context(), claims, "settings.patch", "platform_settings", auditResultSuccess, requestMetadata) writeJSON(w, http.StatusOK, Doc{"settings": adminSettingsView(updated)}) } func parseAdminSettingsPayload(payload map[string]any) (Doc, error) { out := Doc{} if section, ok := payload["platformIdentity"]; ok { normalized, err := parseAdminSettingsStringSection(section, map[string]bool{ "siteName": false, "supportEmail": false, }) if err != nil { return nil, err } out["platformIdentity"] = normalized } if section, ok := payload["seoDefaults"]; ok { normalized, err := parseAdminSettingsStringSection(section, map[string]bool{ "defaultTitle": false, "defaultDescription": false, "canonicalBaseUrl": true, }) if err != nil { return nil, err } out["seoDefaults"] = normalized } if section, ok := payload["consentLinks"]; ok { normalized, err := parseAdminSettingsStringSection(section, map[string]bool{ "privacyPolicyUrl": true, "termsOfServiceUrl": true, "cookiePolicyUrl": true, }) if err != nil { return nil, err } out["consentLinks"] = normalized } return out, nil } func parseAdminSettingsStringSection(raw any, fields map[string]bool) (Doc, error) { section, ok := raw.(map[string]any) if !ok { return nil, errors.New("settings sections must be objects") } out := Doc{} for key, enforceURL := range fields { value, exists := section[key] if !exists { continue } stringValue, ok := value.(string) if !ok { return nil, errors.New(key + " must be a string") } normalized := strings.TrimSpace(stringValue) if enforceURL { valid, err := normalizeOptionalHTTPURL(normalized) if err != nil { return nil, errors.New(key + " must be an absolute http(s) URL") } normalized = valid } out[key] = normalized } return out, nil } func normalizeOptionalHTTPURL(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "", nil } parsed, err := url.Parse(trimmed) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", errors.New("invalid URL") } if parsed.Scheme != "http" && parsed.Scheme != "https" { return "", errors.New("invalid scheme") } return parsed.String(), nil } func defaultAdminSettingsDocument() Doc { return Doc{ "id": adminSettingsID, "platformIdentity": Doc{ "siteName": "", "supportEmail": "", }, "seoDefaults": Doc{ "defaultTitle": "", "defaultDescription": "", "canonicalBaseUrl": "", }, "consentLinks": Doc{ "privacyPolicyUrl": "", "termsOfServiceUrl": "", "cookiePolicyUrl": "", }, } } func mergeAdminSettingsDocument(base, incoming Doc) Doc { out := defaultAdminSettingsDocument() for _, key := range []string{"platformIdentity", "seoDefaults", "consentLinks"} { out[key] = cloneStringMap(out[key]) } applyAdminSettingsSection(out, base) applyAdminSettingsSection(out, incoming) return out } func applyAdminSettingsSection(target, source Doc) { for _, key := range []string{"platformIdentity", "seoDefaults", "consentLinks"} { incoming, ok := source[key] if !ok { continue } incomingMap, ok := incoming.(Doc) if !ok { if genericMap, okAny := incoming.(map[string]any); okAny { incomingMap = Doc(genericMap) } else { continue } } current := cloneStringMap(target[key]) for field, value := range incomingMap { strValue, ok := value.(string) if ok { current[field] = strValue } } target[key] = current } } func cloneStringMap(raw any) Doc { if raw == nil { return Doc{} } if value, ok := raw.(Doc); ok { copy := Doc{} for key, item := range value { if stringItem, ok := item.(string); ok { copy[key] = stringItem } } return copy } if value, ok := raw.(map[string]any); ok { copy := Doc{} for key, item := range value { if stringItem, ok := item.(string); ok { copy[key] = stringItem } } return copy } return Doc{} } func adminSettingsView(doc Doc) Doc { identity := cloneStringMap(doc["platformIdentity"]) seo := cloneStringMap(doc["seoDefaults"]) consent := cloneStringMap(doc["consentLinks"]) return Doc{ "platformIdentity": identity, "seoDefaults": seo, "consentLinks": consent, } } func adminIntegrationStatus(cfg config) Doc { sendgridConfigured := cfg.NotificationProvider == "sendgrid" && strings.TrimSpace(cfg.SendGridAPIKey) != "" && strings.TrimSpace(cfg.SendGridFromEmail) != "" stripeConfigured := strings.TrimSpace(cfg.StripeSecretKey) != "" && strings.TrimSpace(cfg.StripeWebhookSecret) != "" minioConfigured := strings.TrimSpace(cfg.MinIOEndpoint) != "" && strings.TrimSpace(cfg.MinIOBucket) != "" && strings.TrimSpace(cfg.MinIOAccessKey) != "" && strings.TrimSpace(cfg.MinIOSecretKey) != "" keycloakConfigured := strings.TrimSpace(cfg.KeycloakIssuer) != "" && strings.TrimSpace(cfg.KeycloakAudience) != "" return Doc{ "sendgrid": Doc{ "configured": sendgridConfigured, "provider": cfg.NotificationProvider, "fromEmailSet": strings.TrimSpace(cfg.SendGridFromEmail) != "", "apiBaseURL": cfg.SendGridAPIBaseURL, "setupInstruction": "Set NOTIFICATION_PROVIDER=sendgrid and configure SENDGRID_API_KEY and SENDGRID_FROM_EMAIL.", }, "stripe": Doc{ "configured": stripeConfigured, "apiBaseURL": cfg.StripeAPIBaseURL, "priceIDsConfigured": len(cfg.StripeAllowedPriceIDs), "setupInstruction": "Configure STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET, then set STRIPE_ALLOWED_PRICE_IDS.", }, "minio": Doc{ "configured": minioConfigured, "endpoint": cfg.MinIOEndpoint, "bucket": cfg.MinIOBucket, "setupInstruction": "Configure MINIO_ENDPOINT, MINIO_BUCKET, MINIO_ACCESS_KEY, and MINIO_SECRET_KEY.", }, "keycloak": Doc{ "configured": keycloakConfigured, "issuer": cfg.KeycloakIssuer, "audience": cfg.KeycloakAudience, "setupInstruction": "Set KEYCLOAK_ISSUER and KEYCLOAK_AUDIENCE, and disable AUTH_DISABLED in secured environments.", }, "assetEncryption": Doc{ "configured": cfg.AssetEncryptionEnabled, "enabled": cfg.AssetEncryptionEnabled, "setupInstruction": "Set ASSET_ENCRYPTION_ENABLED=true to enforce encrypted asset handling.", }, } }