learn-platform-deploy/Backend/cmd/api/keycloak_theme_preview_export.go

259 lines
7.9 KiB
Go

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)
}