package main import ( "crypto/sha256" "encoding/hex" "fmt" "net/http" "sort" "strings" "github.com/go-chi/chi/v5" ) func (a *app) previewKeycloakTheme(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if !hasPermission(claims, permKeycloakThemesPreview) { writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.preview") return } themeID := strings.TrimSpace(chi.URLParam(r, "id")) if themeID == "" { writeError(w, http.StatusBadRequest, "id is required") return } existing, err := a.getByID("keycloak_themes", themeID) if err != nil { writeError(w, http.StatusNotFound, "resource not found") return } var payload struct { Metadata map[string]any `json:"metadata"` } if r.ContentLength > 0 { if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } } merged := mergeThemeMetadata(existing, payload.Metadata) preview := buildThemePreview(existing, merged) writeJSON(w, http.StatusOK, preview) } func (a *app) exportKeycloakTheme(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if !hasPermission(claims, permKeycloakThemesPreview) { writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.preview") return } themeID := strings.TrimSpace(chi.URLParam(r, "id")) if themeID == "" { writeError(w, http.StatusBadRequest, "id is required") return } existing, err := a.getByID("keycloak_themes", themeID) if err != nil { writeError(w, http.StatusNotFound, "resource not found") return } var payload struct { Metadata map[string]any `json:"metadata"` } if r.ContentLength > 0 { if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } } merged := mergeThemeMetadata(existing, payload.Metadata) if err := validateKeycloakThemeFormValues(merged); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } policyPatch := Doc{ "metadata": merged, } policyPatch["exportRequested"] = true merged["exportRequested"] = true if err := validateKeycloakThemePolicy(existing, policyPatch, claims); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } exportArtifact := generateThemeExportArtifact(existing, merged) created, err := a.create("keycloak_theme_exports", exportArtifact) if err != nil { writeError(w, http.StatusInternalServerError, "failed to store theme export artifact") return } writeJSON(w, http.StatusCreated, created) } func mergeThemeMetadata(existing Doc, override map[string]any) Doc { metadata := resolveThemeMetadata(existing, nil) if metadata == nil { metadata = Doc{} } if override == nil { return sanitizeThemeMetadataForPreviewExport(metadata) } for key, value := range override { metadata[key] = value } return sanitizeThemeMetadataForPreviewExport(metadata) } func sanitizeThemeMetadataForPreviewExport(metadata Doc) Doc { clean := Doc{} for key, value := range metadata { trimmed := strings.TrimSpace(key) if !isKeycloakThemeSchemaField(trimmed) { continue } if isSensitiveThemeField(trimmed) { continue } if hasUnsafeThemeValue(value) { continue } clean[trimmed] = value } return clean } func buildThemePreview(themeDoc, metadata Doc) Doc { typeValue, _ := themeDoc["type"].(string) if strings.TrimSpace(typeValue) == "" { typeValue = keycloakThemeTypeLogin } return Doc{ "themeType": typeValue, "login": Doc{ "title": firstThemeString(metadata, "loginHeading", "registrationHeading", "Welcome back"), "subtitle": firstThemeString(metadata, "loginSubheading", "loginHelpText", "Sign in with your organization account."), "buttonLabel": "Sign in", "style": previewStyle(metadata), }, "account": Doc{ "title": firstThemeString(metadata, "accountConsoleHeading", "loginHeading", "Account settings"), "subtitle": firstThemeString(metadata, "securityText", "footerText", "Manage your account securely."), "buttonLabel": "Save changes", "style": previewStyle(metadata), }, } } func previewStyle(metadata Doc) Doc { return Doc{ "primaryColor": firstThemeString(metadata, "primaryColor", "#4f46e5"), "backgroundColor": firstThemeString(metadata, "backgroundColor", "#ffffff"), "textColor": firstThemeString(metadata, "textColor", "#0f172a"), "fontFamily": firstThemeString(metadata, "fontFamily", "Inter"), "buttonRadius": firstThemeNumber(metadata, "buttonRadius", 8), "footerText": firstThemeString(metadata, "footerText", "Powered by CMS Platform"), "securityText": firstThemeString(metadata, "securityText", "Never share your password with anyone."), } } func firstThemeString(metadata Doc, keys ...string) string { for idx, key := range keys { if idx == len(keys)-1 { return strings.TrimSpace(key) } if value, ok := metadata[key].(string); ok && strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func firstThemeNumber(metadata Doc, key string, fallback float64) float64 { if value, ok := metadata[key]; ok { if parsed, valid := normalizeThemeNumber(value); valid { return parsed } } return fallback } func generateThemeExportArtifact(themeDoc, metadata Doc) Doc { slug, _ := themeDoc["slug"].(string) version, _ := themeDoc["version"].(string) themeType, _ := themeDoc["type"].(string) themeID, _ := themeDoc["id"].(string) baseDir := fmt.Sprintf("keycloak-theme/themes/%s-%s", slug, version) css := generateThemeCSS(metadata) themeProperties := fmt.Sprintf("parent=base\nstyles=css/theme.css\n") files := []Doc{ {"path": fmt.Sprintf("%s/login/theme.properties", baseDir), "contentType": "text/plain", "content": themeProperties}, {"path": fmt.Sprintf("%s/login/resources/css/theme.css", baseDir), "contentType": "text/css", "content": css}, {"path": fmt.Sprintf("%s/account/theme.properties", baseDir), "contentType": "text/plain", "content": themeProperties}, {"path": fmt.Sprintf("%s/account/resources/css/theme.css", baseDir), "contentType": "text/css", "content": css}, } if themeType == keycloakThemeTypeEmail { files = append(files, Doc{"path": fmt.Sprintf("%s/email/theme.properties", baseDir), "contentType": "text/plain", "content": "parent=base\n"}) } if asset := firstThemeString(metadata, "logoAssetId"); asset != "" { files = append(files, Doc{"path": fmt.Sprintf("%s/login/resources/img/logo.ref", baseDir), "contentType": "text/plain", "content": asset}) } if asset := firstThemeString(metadata, "faviconAssetId"); asset != "" { files = append(files, Doc{"path": fmt.Sprintf("%s/login/resources/img/favicon.ref", baseDir), "contentType": "text/plain", "content": asset}) } checksum := checksumThemeFiles(files) return Doc{ "themeId": themeID, "themeSlug": slug, "version": version, "checksum": checksum, "format": "keycloak-theme-directory", "baseDir": baseDir, "fileCount": len(files), "files": files, "previewOnly": false, } } func checksumThemeFiles(files []Doc) string { lines := make([]string, 0, len(files)) for _, file := range files { path, _ := file["path"].(string) content, _ := file["content"].(string) lines = append(lines, path+"\n"+content) } sort.Strings(lines) hasher := sha256.New() for _, line := range lines { _, _ = hasher.Write([]byte(line)) } return hex.EncodeToString(hasher.Sum(nil)) } func generateThemeCSS(metadata Doc) string { primary := firstThemeString(metadata, "primaryColor", "#4f46e5") background := firstThemeString(metadata, "backgroundColor", "#ffffff") text := firstThemeString(metadata, "textColor", "#0f172a") radius := firstThemeNumber(metadata, "buttonRadius", 8) font := firstThemeString(metadata, "fontFamily", "Inter") return fmt.Sprintf(":root {\n --kc-primary: %s;\n --kc-background: %s;\n --kc-text: %s;\n --kc-radius: %.0fpx;\n --kc-font-family: '%s';\n}\n", primary, background, text, radius, font) }