358 lines
11 KiB
Go
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.",
|
|
},
|
|
}
|
|
}
|