358 lines
11 KiB
Go

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.",
},
}
}