259 lines
7.9 KiB
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)
|
|
}
|