mirror: import production source 1/3

This commit is contained in:
Admin 2026-09-27 16:58:36 +00:00
parent 0a29c1e463
commit 1b587d88e6
100 changed files with 29202 additions and 0 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
.git
.gitignore
.github
**/node_modules
**/dist
**/.cache
**/.vite
**/coverage
**/playwright-report
**/test-results
**/.env
**/.env.*
!**/.env.example
*.log

View File

@ -0,0 +1,357 @@
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.",
},
}
}

View File

@ -0,0 +1,75 @@
package main
import (
"encoding/json"
)
type dynamicObjectRequest struct {
Fields map[string]any
}
func (r *dynamicObjectRequest) UnmarshalJSON(data []byte) error {
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
r.Fields = payload
return nil
}
func (r dynamicObjectRequest) Doc() Doc {
if r.Fields == nil {
return Doc{}
}
return Doc(r.Fields)
}
type formUpsertRequest struct {
dynamicObjectRequest
}
type liveCourseUpsertRequest struct {
dynamicObjectRequest
}
type liveCourseCohortUpsertRequest struct {
dynamicObjectRequest
}
type liveCourseSessionUpsertRequest struct {
dynamicObjectRequest
}
type liveCourseEnrollmentUpsertRequest struct {
dynamicObjectRequest
}
type liveCourseAttendanceUpsertRequest struct {
dynamicObjectRequest
}
type publicFormResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Schema any `json:"schema"`
}
type publicFormSubmissionCreateResponse struct {
ID string `json:"id"`
FormID string `json:"formId"`
SubmittedAt int64 `json:"submittedAt"`
}
type learnerLiveCourseJoinResponse struct {
Allowed bool `json:"allowed"`
CourseID string `json:"courseId"`
SessionID string `json:"sessionId"`
JoinURL string `json:"joinUrl"`
}
type billingWebhookResponse struct {
Status string `json:"status"`
EventID string `json:"eventId"`
Message string `json:"message"`
}

59
Backend/cmd/api/app.go Normal file
View File

@ -0,0 +1,59 @@
package main
import (
"context"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
type app struct {
cfg config
db *pgDatabase
oidcVerifier *oidc.IDTokenVerifier
notificationProvider notificationProvider
stripeService stripeService
billingCustomerEnsurer func(string, string) (Doc, error)
stripeWebhookVerifier func([]byte, string) (stripeWebhookEvent, error)
pageSlugInUse func(string, string) (bool, error)
}
func newApp(cfg config) (*app, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
db, err := openPostgres(ctx, cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("connect postgres: %w", err)
}
var verifier *oidc.IDTokenVerifier
if !cfg.AuthDisabled {
oidcCtx, oidcCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer oidcCancel()
keySet := oidc.NewRemoteKeySet(oidcCtx, strings.TrimRight(cfg.KeycloakIssuerInternal, "/")+"/protocol/openid-connect/certs")
verifier = oidc.NewVerifier(cfg.KeycloakIssuer, keySet, &oidc.Config{
// Keycloak access tokens commonly identify the requesting public client
// through azp while aud contains resource servers. Verify issuer/signature/
// expiry here, then enforce the configured client/audience in middleware.
SkipClientIDCheck: true,
})
}
return &app{
cfg: cfg,
db: db,
oidcVerifier: verifier,
notificationProvider: newNotificationProvider(cfg),
stripeService: newStripeService(cfg),
}, nil
}
func (a *app) close() {
if a.db != nil {
a.db.Close()
}
}

398
Backend/cmd/api/assets.go Normal file
View File

@ -0,0 +1,398 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
var (
allowedAssetStatuses = map[string]struct{}{
"active": {},
"archived": {},
"quarantined": {},
"disabled": {},
"deleted": {},
}
allowedAssetVisibility = map[string]struct{}{
"public": {},
"authenticated": {},
"enrolled": {},
"entitled": {},
"instructor": {},
"admin": {},
"private": {},
}
allowedAssetProtectionLevels = map[string]struct{}{
"none": {},
"standard": {},
"encrypted": {},
"watermarked": {},
"restricted": {},
}
)
func (a *app) assetsAdminMiddleware(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 asset management")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listAssets(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
filter := Doc{}
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
filter["status"] = status
}
if visibility := strings.TrimSpace(r.URL.Query().Get("visibility")); visibility != "" {
filter["visibility"] = visibility
}
docs, err := a.list("assets", filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list assets")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createAsset(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareAssetPayload(Doc(payload), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("assets", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create resource")
return
}
a.recordAuditEvent(r.Context(), claims, "assets.create", "assets", auditResultSuccess, map[string]any{
"assetId": doc["id"],
"status": doc["status"],
})
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getAsset(w http.ResponseWriter, r *http.Request) {
a.getCollection("assets", w, r)
}
func (a *app) patchAsset(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := chi.URLParam(r, "id")
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
patch, err := prepareAssetPayload(Doc(payload), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(patch) == 0 {
writeError(w, http.StatusBadRequest, "no asset changes provided")
return
}
doc, err := a.patch("assets", id, patch)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update resource")
return
}
a.recordAuditEvent(r.Context(), claims, "assets.patch", "assets", auditResultSuccess, map[string]any{
"assetId": id,
})
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteAsset(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("assets", w, r)
}
func (a *app) listAssetAccessEvents(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := chi.URLParam(r, "id")
docs, err := a.list("asset_access_events", Doc{"assetId": id}, Sort{{Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list access events")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) listAssetClaims(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := chi.URLParam(r, "id")
docs, err := a.list("copyright_claims", Doc{"assetId": id}, Sort{{Key: "submittedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list claims")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) quarantineAsset(w http.ResponseWriter, r *http.Request) {
a.updateAssetStatusAction(w, r, "quarantined", "assets.quarantine")
}
func (a *app) disableAsset(w http.ResponseWriter, r *http.Request) {
a.updateAssetStatusAction(w, r, "disabled", "assets.disable")
}
func (a *app) restoreAsset(w http.ResponseWriter, r *http.Request) {
a.updateAssetStatusAction(w, r, "active", "assets.restore")
}
func (a *app) encryptAsset(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if !a.cfg.AssetEncryptionEnabled {
writeError(w, http.StatusConflict, "asset encryption is not enabled")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := chi.URLParam(r, "id")
metadata := assetEncryptionMetadata(time.Now().UnixMilli())
doc, err := a.patch("assets", id, Doc{
"protectionLevel": "encrypted",
"encryption": metadata,
})
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to encrypt asset")
return
}
a.recordAuditEvent(r.Context(), claims, "assets.encrypt", "assets", auditResultSuccess, map[string]any{
"assetId": id,
"protectionLevel": "encrypted",
"keyVersion": metadata["keyVersion"],
})
writeJSON(w, http.StatusOK, doc)
}
func (a *app) updateAssetStatusAction(w http.ResponseWriter, r *http.Request, targetStatus, action string) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := chi.URLParam(r, "id")
doc, err := a.patch("assets", id, Doc{"status": targetStatus})
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update asset status")
return
}
a.recordAuditEvent(r.Context(), claims, action, "assets", auditResultSuccess, map[string]any{
"assetId": id,
"status": targetStatus,
})
writeJSON(w, http.StatusOK, doc)
}
func assetEncryptionMetadata(now int64) Doc {
return Doc{
"status": "encrypted",
"algorithm": "AES-256-GCM",
"keyVersion": "v1",
"encryptedAt": now,
}
}
func prepareAssetPayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
for _, field := range []string{"description", "ownerUserId", "ownerName", "relatedResourceType", "relatedResourceId", "entitlementKey"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && (!hasStatus || status == "") {
status = "active"
hasStatus = true
}
if hasStatus {
if !isAllowedEnumValue(allowedAssetStatuses, status) {
return nil, errors.New("status must be one of active, archived, quarantined, disabled, or deleted")
}
payload["status"] = status
}
visibility, hasVisibility, err := getOptionalString(payload, "visibility")
if err != nil {
return nil, errors.New("visibility must be a string")
}
if !partial && (!hasVisibility || visibility == "") {
visibility = "private"
hasVisibility = true
}
if hasVisibility {
if !isAllowedEnumValue(allowedAssetVisibility, visibility) {
return nil, errors.New("visibility must be one of public, authenticated, enrolled, entitled, instructor, admin, or private")
}
payload["visibility"] = visibility
}
protectionLevel, hasProtectionLevel, err := getOptionalString(payload, "protectionLevel")
if err != nil {
return nil, errors.New("protectionLevel must be a string")
}
if !partial && (!hasProtectionLevel || protectionLevel == "") {
protectionLevel = "standard"
hasProtectionLevel = true
}
if hasProtectionLevel {
if !isAllowedEnumValue(allowedAssetProtectionLevels, protectionLevel) {
return nil, errors.New("protectionLevel must be one of none, standard, encrypted, watermarked, or restricted")
}
payload["protectionLevel"] = protectionLevel
}
if value, ok := payload["copyright"]; ok {
normalized, err := normalizeAssetCopyright(value)
if err != nil {
return nil, err
}
payload["copyright"] = normalized
}
return payload, nil
}
func normalizeAssetCopyright(raw any) (Doc, error) {
mapValue, ok := raw.(map[string]any)
if !ok {
if bsonValue, docOK := raw.(Doc); docOK {
mapValue = map[string]any(bsonValue)
} else {
return nil, errors.New("copyright must be an object")
}
}
normalized := Doc{}
for _, field := range []string{"ownerName", "ownerUserId", "sourceUrl", "attributionText", "claimStatus"} {
value, has, err := getOptionalString(mapValue, field)
if err != nil {
return nil, errors.New("copyright." + field + " must be a string")
}
if has {
normalized[field] = value
}
}
licence, hasLicence, err := getOptionalString(mapValue, "licence")
if err != nil {
return nil, errors.New("copyright.licence must be a string")
}
if hasLicence {
normalized["licence"] = licence
} else {
license, hasLicense, err := getOptionalString(mapValue, "license")
if err != nil {
return nil, errors.New("copyright.license must be a string")
}
if hasLicense {
normalized["licence"] = license
}
}
for _, field := range []string{"requiresAttribution", "allowDownload", "allowReuse", "allowModification", "allowCommercialUse"} {
value, has, err := getOptionalBool(mapValue, field)
if err != nil {
return nil, errors.New("copyright." + field + " must be a boolean")
}
if has {
normalized[field] = value
}
}
return normalized, nil
}
func isAllowedEnumValue(allowed map[string]struct{}, value string) bool {
_, ok := allowed[strings.TrimSpace(strings.ToLower(value))]
return ok
}

276
Backend/cmd/api/audit.go Normal file
View File

@ -0,0 +1,276 @@
package main
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
auditResultSuccess = "success"
auditResultFailed = "failed"
)
var sensitiveAuditMetadataTokens = []string{
"password",
"secret",
"token",
"credential",
"authorization",
"apikey",
"api_key",
"accesskey",
"access_key",
"secretkey",
"secret_key",
"email",
"userid",
"user_id",
"score",
"grade",
"progress",
}
func (a *app) auditAdminMiddleware(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 audit access")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listAdminAuditEvents(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
filter, limit, err := buildAuditQuery(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
cursor, err := a.db.Collection("audit_events").Find(
ctx,
filter,
NewFindOptions().SetSort(Sort{{Key: "timestamp", Value: -1}, {Key: "createdAt", Value: -1}}).SetLimit(int64(limit)),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list audit events")
return
}
defer cursor.Close(ctx)
var docs []Doc
if err := cursor.All(ctx, &docs); err != nil {
writeError(w, http.StatusInternalServerError, "failed to list audit events")
return
}
if docs == nil {
docs = []Doc{}
}
for idx := range docs {
normalizeDocument(docs[idx])
docs[idx] = redactAuditEventDocument(docs[idx])
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) getAdminAuditEvent(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if strings.TrimSpace(id) == "" {
writeError(w, http.StatusBadRequest, "id is required")
return
}
doc, err := a.getByID("audit_events", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load audit event")
return
}
writeJSON(w, http.StatusOK, redactAuditEventDocument(doc))
}
func buildAuditQuery(r *http.Request) (Doc, int, error) {
query := r.URL.Query()
filter := Doc{}
if action := strings.TrimSpace(query.Get("action")); action != "" {
filter["action"] = action
}
if resource := strings.TrimSpace(query.Get("resource")); resource != "" {
filter["resource"] = resource
}
if result := strings.TrimSpace(query.Get("result")); result != "" {
filter["result"] = result
}
if actor := strings.TrimSpace(query.Get("actor")); actor != "" {
filter["actor"] = actor
}
timeFilter := Doc{}
if sinceRaw := strings.TrimSpace(query.Get("since")); sinceRaw != "" {
since, err := parseAuditTime(sinceRaw)
if err != nil {
return nil, 0, errors.New("since must be RFC3339 or unix milliseconds")
}
timeFilter["$gte"] = since
}
if untilRaw := strings.TrimSpace(query.Get("until")); untilRaw != "" {
until, err := parseAuditTime(untilRaw)
if err != nil {
return nil, 0, errors.New("until must be RFC3339 or unix milliseconds")
}
timeFilter["$lte"] = until
}
if len(timeFilter) > 0 {
filter["timestamp"] = timeFilter
}
limit := 50
if limitRaw := strings.TrimSpace(query.Get("limit")); limitRaw != "" {
parsed, err := strconv.Atoi(limitRaw)
if err != nil || parsed <= 0 {
return nil, 0, errors.New("limit must be a positive integer")
}
if parsed > 200 {
parsed = 200
}
limit = parsed
}
return filter, limit, nil
}
func parseAuditTime(raw string) (time.Time, error) {
if millis, err := strconv.ParseInt(raw, 10, 64); err == nil {
return time.UnixMilli(millis).UTC(), nil
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, err
}
return parsed.UTC(), nil
}
func (a *app) recordAuditEvent(ctx context.Context, claims *accessClaims, action, resource, result string, metadata map[string]any) {
if a.db == nil {
return
}
action = strings.TrimSpace(action)
resource = strings.TrimSpace(resource)
result = strings.TrimSpace(result)
if action == "" || resource == "" || result == "" {
return
}
if metadata == nil {
metadata = map[string]any{}
}
doc := Doc{
"actor": resolveAuditActor(claims),
"action": action,
"resource": resource,
"result": result,
"timestamp": time.Now().UTC(),
"metadata": Doc(metadata),
}
_, _ = a.create("audit_events", doc)
}
func resolveAuditActor(claims *accessClaims) string {
if claims == nil {
return "system"
}
if value := strings.TrimSpace(claims.PreferredUsername); value != "" {
return value
}
if value := strings.TrimSpace(claims.Email); value != "" {
return value
}
if value := strings.TrimSpace(claims.Sub); value != "" {
return value
}
return "system"
}
func redactAuditEventDocument(doc Doc) Doc {
copy := Doc{}
for key, value := range doc {
if key == "metadata" {
copy[key] = redactAuditMetadata("metadata", value)
continue
}
copy[key] = value
}
return copy
}
func redactAuditMetadata(path string, raw any) any {
switch typed := raw.(type) {
case Doc:
out := Doc{}
for key, value := range typed {
nextPath := path + "." + key
if isSensitiveAuditKey(key) {
out[key] = "[REDACTED]"
continue
}
out[key] = redactAuditMetadata(nextPath, value)
}
return out
case map[string]any:
out := map[string]any{}
for key, value := range typed {
nextPath := path + "." + key
if isSensitiveAuditKey(key) {
out[key] = "[REDACTED]"
continue
}
out[key] = redactAuditMetadata(nextPath, value)
}
return out
case []any:
out := make([]any, len(typed))
for idx, value := range typed {
out[idx] = redactAuditMetadata(path, value)
}
return out
default:
return raw
}
}
func isSensitiveAuditKey(key string) bool {
normalized := strings.ToLower(strings.TrimSpace(key))
for _, token := range sensitiveAuditMetadataTokens {
if strings.Contains(normalized, token) {
return true
}
}
return false
}

View File

@ -0,0 +1,473 @@
package main
import (
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
)
var themeHexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
var requiredThemeColors = []string{
"background",
"foreground",
"primary",
"primaryForeground",
"muted",
"mutedForeground",
"border",
"accent",
}
type authoringIdentity struct {
UserID string
TenantID string
}
type themeValidationResult struct {
Valid bool `json:"valid" bson:"valid"`
Errors []string `json:"errors" bson:"errors"`
Warnings []string `json:"warnings" bson:"warnings"`
}
func (a *app) createAuthoringRun(w http.ResponseWriter, r *http.Request) {
var payload struct {
Brief map[string]any `json:"brief"`
}
if err := decodeJSON(r, &payload); err != nil || len(payload.Brief) == 0 {
writeError(w, http.StatusBadRequest, "brief is required")
return
}
run, err := a.startAuthoringRun(r, Doc(payload.Brief))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusCreated, run)
}
func (a *app) getAuthoringRun(w http.ResponseWriter, r *http.Request) {
run, err := a.getOwnedAuthoringRun(r, chi.URLParam(r, "id"))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, run)
}
func (a *app) proposeAuthoringThemeHTTP(w http.ResponseWriter, r *http.Request) {
var payload struct {
IdempotencyKey string `json:"idempotencyKey"`
Theme map[string]any `json:"theme"`
}
if err := decodeJSON(r, &payload); err != nil || strings.TrimSpace(payload.IdempotencyKey) == "" || len(payload.Theme) == 0 {
writeError(w, http.StatusBadRequest, "idempotencyKey and theme are required")
return
}
run, err := a.proposeAuthoringTheme(r, chi.URLParam(r, "id"), strings.TrimSpace(payload.IdempotencyKey), Doc(payload.Theme))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, run)
}
func (a *app) validateAuthoringThemeHTTP(w http.ResponseWriter, r *http.Request) {
result, err := a.validateAuthoringTheme(r, chi.URLParam(r, "id"))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) deleteAuthoringRun(w http.ResponseWriter, r *http.Request) {
result, err := a.discardAuthoringRun(r, chi.URLParam(r, "id"))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func writeAuthoringError(w http.ResponseWriter, err error) {
switch err.Error() {
case "authentication required":
writeError(w, http.StatusUnauthorized, err.Error())
case "tenant claim required":
writeError(w, http.StatusForbidden, err.Error())
case "authoring store unavailable":
writeError(w, http.StatusServiceUnavailable, err.Error())
case "authoring run not found":
writeError(w, http.StatusNotFound, err.Error())
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
func themeSchemaResource() map[string]any {
properties := map[string]any{}
for _, field := range requiredThemeColors {
properties[field] = map[string]any{"type": "string", "pattern": `^#[0-9a-fA-F]{6}$`}
}
properties["radius"] = map[string]any{"type": "number", "minimum": 0, "maximum": 2}
properties["fontFamily"] = map[string]any{"type": "string", "minLength": 1, "maxLength": 200}
return map[string]any{
"type": "object",
"properties": properties,
"required": append(requiredThemeColors, "radius", "fontFamily"),
"additionalProperties": false,
"constraints": map[string]any{
"minimumTextContrast": 4.5,
"arbitraryCSS": false,
},
}
}
func (a *app) callExternalAuthoringTool(r *http.Request, name string, arguments map[string]any) (any, error) {
switch name {
case "authoring_start":
brief, ok := asDocument(arguments["brief"])
if !ok || len(brief) == 0 {
return nil, errors.New("brief is required")
}
return a.startAuthoringRun(r, brief)
case "authoring_get_status":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.getOwnedAuthoringRun(r, runID)
case "theme_propose":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey")
if err != nil {
return nil, err
}
theme, ok := asDocument(arguments["theme"])
if !ok {
return nil, errors.New("theme is required")
}
return a.proposeAuthoringTheme(r, runID, idempotencyKey, theme)
case "theme_validate":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.validateAuthoringTheme(r, runID)
case "site_propose_structure":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey")
if err != nil {
return nil, err
}
site, ok := asDocument(arguments["site"])
if !ok {
return nil, errors.New("site is required")
}
return a.proposeAuthoringSiteStructure(r, runID, idempotencyKey, site)
case "site_upsert_page_proposal":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
idempotencyKey, err := requiredArgumentString(arguments, "idempotencyKey")
if err != nil {
return nil, err
}
page, ok := asDocument(arguments["page"])
if !ok {
return nil, errors.New("page is required")
}
return a.upsertAuthoringSitePage(r, runID, idempotencyKey, page)
case "site_validate":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.validateAuthoringSite(r, runID)
case "authoring_get_review_link":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.authoringReviewLink(r, runID)
case "authoring_discard":
runID, err := requiredArgumentString(arguments, "runId")
if err != nil {
return nil, err
}
return a.discardAuthoringRun(r, runID)
default:
return nil, errors.New("unknown or unavailable tool")
}
}
func (a *app) startAuthoringRun(r *http.Request, brief Doc) (Doc, error) {
identity, err := a.resolveAuthoringIdentity(r)
if err != nil {
return nil, err
}
if a.db == nil {
return nil, errors.New("authoring store unavailable")
}
return a.create("authoring_runs", Doc{
"tenantId": identity.TenantID,
"userId": identity.UserID,
"status": "draft",
"brief": brief,
"proposal": Doc{},
"proposalRevision": 0,
"validation": Doc{},
"idempotencyKeys": Doc{},
"source": "mcp",
})
}
func (a *app) proposeAuthoringTheme(r *http.Request, runID, idempotencyKey string, theme Doc) (Doc, error) {
run, identity, err := a.getMutableAuthoringRun(r, runID)
if err != nil {
return nil, err
}
keys, _ := asDocument(run["idempotencyKeys"])
if keys != nil && anyToString(keys["theme_propose"]) == idempotencyKey {
return run, nil
}
if keys == nil {
keys = Doc{}
}
keys["theme_propose"] = idempotencyKey
proposal, _ := asDocument(run["proposal"])
if proposal == nil {
proposal = Doc{}
}
proposal["theme"] = theme
return a.updateAuthoringProposal(runID, identity, run, proposal, keys)
}
func (a *app) validateAuthoringTheme(r *http.Request, runID string) (Doc, error) {
run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, err
}
proposal, _ := asDocument(run["proposal"])
theme, ok := asDocument(proposal["theme"])
if !ok {
return nil, errors.New("theme proposal not found")
}
validation := validateThemeProposal(theme)
validationDocument, _ := asDocument(run["validation"])
if validationDocument == nil {
validationDocument = Doc{}
}
validationDocument["theme"] = validation
status := "proposed"
if validation.Valid && allProposalValidationsPass(proposal, validationDocument) {
status = "ready_for_review"
}
updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{
"validation": validationDocument,
"status": status,
}, nil, identity.TenantID, true)
if err != nil {
return nil, err
}
return Doc{
"runId": runID,
"status": status,
"proposalRevision": updated["proposalRevision"],
"validation": validation,
"requiresCMSApproval": true,
}, nil
}
func (a *app) authoringReviewLink(r *http.Request, runID string) (Doc, error) {
run, _, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, err
}
if anyToString(run["status"]) != "ready_for_review" {
return nil, errors.New("run must pass validation before review")
}
return Doc{
"runId": runID,
"status": "ready_for_review",
"reviewUrl": a.cfg.AppBaseURL + "/admin/authoring/runs/" + runID,
}, nil
}
func (a *app) discardAuthoringRun(r *http.Request, runID string) (Doc, error) {
run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, err
}
if status := anyToString(run["status"]); status == "applied" || status == "published" {
return nil, errors.New("applied runs cannot be discarded")
}
updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{"status": "cancelled"}, nil, identity.TenantID, true)
if err != nil {
return nil, err
}
return Doc{"runId": runID, "status": updated["status"]}, nil
}
func (a *app) getOwnedAuthoringRun(r *http.Request, runID string) (Doc, error) {
run, _, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
return run, err
}
func (a *app) getOwnedAuthoringRunWithIdentity(r *http.Request, runID string) (Doc, authoringIdentity, error) {
identity, err := a.resolveAuthoringIdentity(r)
if err != nil {
return nil, authoringIdentity{}, err
}
if a.db == nil {
return nil, authoringIdentity{}, errors.New("authoring store unavailable")
}
run, err := a.getByIDScoped("authoring_runs", runID, identity.TenantID, true)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, authoringIdentity{}, errors.New("authoring run not found")
}
return nil, authoringIdentity{}, errors.New("failed to load authoring run")
}
if anyToString(run["userId"]) != identity.UserID && !a.isAuthoringAdmin(r) {
return nil, authoringIdentity{}, errors.New("authoring run not found")
}
return run, identity, nil
}
func (a *app) resolveAuthoringIdentity(r *http.Request) (authoringIdentity, error) {
if a.cfg.AuthDisabled {
return authoringIdentity{UserID: "development-user", TenantID: "development"}, nil
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
tenant, _ := r.Context().Value(tenantContextKey).(tenantContext)
userID := resolveClaimsUserID(claims)
if userID == "" {
return authoringIdentity{}, errors.New("authentication required")
}
if strings.TrimSpace(tenant.ID) == "" {
return authoringIdentity{}, errors.New("tenant claim required")
}
return authoringIdentity{UserID: userID, TenantID: strings.TrimSpace(tenant.ID)}, nil
}
func (a *app) isAuthoringAdmin(r *http.Request) bool {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasAnyRole(claims, "admin", "platform_owner", "platform_admin")
}
func validateThemeProposal(theme Doc) themeValidationResult {
result := themeValidationResult{Valid: true, Errors: []string{}, Warnings: []string{}}
for _, field := range requiredThemeColors {
value := strings.TrimSpace(anyToString(theme[field]))
if !themeHexColorPattern.MatchString(value) {
result.Errors = append(result.Errors, field+" must be a six-digit hex color")
}
}
fontFamily := strings.TrimSpace(anyToString(theme["fontFamily"]))
if fontFamily == "" || len(fontFamily) > 200 {
result.Errors = append(result.Errors, "fontFamily is required and must be at most 200 characters")
}
radius, ok := numericValue(theme["radius"])
if !ok || radius < 0 || radius > 2 {
result.Errors = append(result.Errors, "radius must be between 0 and 2")
}
if len(result.Errors) == 0 {
for _, pair := range [][3]string{
{"foreground", "background", "foreground/background"},
{"primaryForeground", "primary", "primary foreground/primary"},
{"mutedForeground", "muted", "muted foreground/muted"},
} {
ratio := contrastRatio(anyToString(theme[pair[0]]), anyToString(theme[pair[1]]))
if ratio < 4.5 {
result.Errors = append(result.Errors, fmt.Sprintf("%s contrast %.2f is below 4.5", pair[2], ratio))
}
}
}
result.Valid = len(result.Errors) == 0
return result
}
func contrastRatio(first, second string) float64 {
firstLuminance := relativeLuminance(first)
secondLuminance := relativeLuminance(second)
lighter := math.Max(firstLuminance, secondLuminance)
darker := math.Min(firstLuminance, secondLuminance)
return (lighter + 0.05) / (darker + 0.05)
}
func relativeLuminance(color string) float64 {
if !themeHexColorPattern.MatchString(color) {
return 0
}
channels := make([]float64, 0, 3)
for index := 1; index < 7; index += 2 {
value, _ := strconv.ParseInt(color[index:index+2], 16, 64)
channel := float64(value) / 255
if channel <= 0.04045 {
channel /= 12.92
} else {
channel = math.Pow((channel+0.055)/1.055, 2.4)
}
channels = append(channels, channel)
}
return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2]
}
func requiredArgumentString(arguments map[string]any, key string) (string, error) {
value := strings.TrimSpace(anyToString(arguments[key]))
if value == "" {
return "", fmt.Errorf("%s is required", key)
}
return value, nil
}
func asDocument(value any) (Doc, bool) {
switch typed := value.(type) {
case Doc:
return typed, true
case map[string]any:
return Doc(typed), true
default:
return nil, false
}
}
func int64Number(value any) int64 {
number, _ := numericValue(value)
return int64(number)
}
func numericValue(value any) (float64, bool) {
switch typed := value.(type) {
case int:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case float32:
return float64(typed), true
case float64:
return typed, true
default:
return 0, false
}
}

View File

@ -0,0 +1,352 @@
package main
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
type siteValidationResult struct {
Valid bool `json:"valid" bson:"valid"`
Errors []string `json:"errors" bson:"errors"`
Warnings []string `json:"warnings" bson:"warnings"`
}
func (a *app) proposeAuthoringSiteStructureHTTP(w http.ResponseWriter, r *http.Request) {
var payload struct {
IdempotencyKey string `json:"idempotencyKey"`
Site map[string]any `json:"site"`
}
if err := decodeJSON(r, &payload); err != nil || strings.TrimSpace(payload.IdempotencyKey) == "" || len(payload.Site) == 0 {
writeError(w, http.StatusBadRequest, "idempotencyKey and site are required")
return
}
run, err := a.proposeAuthoringSiteStructure(r, chi.URLParam(r, "id"), strings.TrimSpace(payload.IdempotencyKey), Doc(payload.Site))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, run)
}
func (a *app) upsertAuthoringSitePageHTTP(w http.ResponseWriter, r *http.Request) {
var payload struct {
IdempotencyKey string `json:"idempotencyKey"`
Page map[string]any `json:"page"`
}
if err := decodeJSON(r, &payload); err != nil || strings.TrimSpace(payload.IdempotencyKey) == "" || len(payload.Page) == 0 {
writeError(w, http.StatusBadRequest, "idempotencyKey and page are required")
return
}
run, err := a.upsertAuthoringSitePage(r, chi.URLParam(r, "id"), strings.TrimSpace(payload.IdempotencyKey), Doc(payload.Page))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, run)
}
func (a *app) validateAuthoringSiteHTTP(w http.ResponseWriter, r *http.Request) {
result, err := a.validateAuthoringSite(r, chi.URLParam(r, "id"))
if err != nil {
writeAuthoringError(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func siteSchemaResource() map[string]any {
return map[string]any{
"structure": map[string]any{
"type": "object", "required": []string{"title", "navigation"},
"properties": map[string]any{
"title": map[string]any{"type": "string", "minLength": 1, "maxLength": 200},
"description": map[string]any{"type": "string", "maxLength": 1000},
"navigation": map[string]any{"type": "array", "items": map[string]any{
"type": "object", "required": []string{"label", "pageId"},
"properties": map[string]any{"label": map[string]any{"type": "string"}, "pageId": map[string]any{"type": "string"}},
"additionalProperties": false,
}},
},
"additionalProperties": false,
},
"page": map[string]any{
"type": "object", "required": []string{"id", "name", "slug", "blocks"},
"properties": map[string]any{
"id": map[string]any{"type": "string", "minLength": 1, "maxLength": 100},
"name": map[string]any{"type": "string", "minLength": 1, "maxLength": 200},
"slug": map[string]any{"type": "string", "pattern": `^[a-z0-9]+(?:-[a-z0-9]+)*$`},
"seo": map[string]any{"type": "object"},
"blocks": map[string]any{"type": "array"},
"customBlocks": map[string]any{"type": "array"},
},
"additionalProperties": false,
},
"constraints": map[string]any{"proposalOnly": true, "uniquePageAndBlockIds": true, "navigationTargetsMustExist": true},
}
}
func (a *app) getMutableAuthoringRun(r *http.Request, runID string) (Doc, authoringIdentity, error) {
run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, authoringIdentity{}, err
}
if status := anyToString(run["status"]); status == "cancelled" || status == "applied" || status == "published" {
return nil, authoringIdentity{}, fmt.Errorf("run cannot be changed while status is %s", status)
}
return run, identity, nil
}
func (a *app) updateAuthoringProposal(runID string, identity authoringIdentity, run, proposal, keys Doc) (Doc, error) {
return a.patchWithUnsetScoped("authoring_runs", runID, Doc{
"proposal": proposal, "proposalRevision": int64Number(run["proposalRevision"]) + 1,
"status": "proposed", "validation": Doc{}, "idempotencyKeys": keys,
}, nil, identity.TenantID, true)
}
func allProposalValidationsPass(proposal, validation Doc) bool {
for _, area := range []string{"theme", "site"} {
if _, proposed := proposal[area]; !proposed {
continue
}
result, ok := asDocument(validation[area])
if !ok || result["valid"] != true {
return false
}
}
return true
}
func (a *app) proposeAuthoringSiteStructure(r *http.Request, runID, idempotencyKey string, structure Doc) (Doc, error) {
run, identity, err := a.getMutableAuthoringRun(r, runID)
if err != nil {
return nil, err
}
if validationErrors := validateSiteStructure(structure); len(validationErrors) > 0 {
return nil, fmt.Errorf("invalid site structure: %s", strings.Join(validationErrors, "; "))
}
keys, _ := asDocument(run["idempotencyKeys"])
if keys != nil && anyToString(keys["site_propose_structure"]) == idempotencyKey {
return run, nil
}
if keys == nil {
keys = Doc{}
}
keys["site_propose_structure"] = idempotencyKey
proposal, _ := asDocument(run["proposal"])
if proposal == nil {
proposal = Doc{}
}
site, _ := asDocument(proposal["site"])
if site == nil {
site = Doc{"pages": Doc{}}
}
site["structure"] = structure
proposal["site"] = site
return a.updateAuthoringProposal(runID, identity, run, proposal, keys)
}
func (a *app) upsertAuthoringSitePage(r *http.Request, runID, idempotencyKey string, page Doc) (Doc, error) {
run, identity, err := a.getMutableAuthoringRun(r, runID)
if err != nil {
return nil, err
}
pageID := strings.TrimSpace(anyToString(page["id"]))
if validationErrors := validateSitePage(page); len(validationErrors) > 0 {
return nil, fmt.Errorf("invalid site page: %s", strings.Join(validationErrors, "; "))
}
keys, _ := asDocument(run["idempotencyKeys"])
keyName := "site_upsert_page_proposal:" + pageID
if keys != nil && anyToString(keys[keyName]) == idempotencyKey {
return run, nil
}
if keys == nil {
keys = Doc{}
}
keys[keyName] = idempotencyKey
proposal, _ := asDocument(run["proposal"])
if proposal == nil {
proposal = Doc{}
}
site, _ := asDocument(proposal["site"])
if site == nil {
site = Doc{}
}
pages, _ := asDocument(site["pages"])
if pages == nil {
pages = Doc{}
}
pages[pageID] = page
site["pages"] = pages
proposal["site"] = site
return a.updateAuthoringProposal(runID, identity, run, proposal, keys)
}
func (a *app) validateAuthoringSite(r *http.Request, runID string) (Doc, error) {
run, identity, err := a.getOwnedAuthoringRunWithIdentity(r, runID)
if err != nil {
return nil, err
}
proposal, _ := asDocument(run["proposal"])
site, ok := asDocument(proposal["site"])
if !ok {
return nil, errors.New("site proposal not found")
}
validation := validateSiteProposal(site)
validationDocument, _ := asDocument(run["validation"])
if validationDocument == nil {
validationDocument = Doc{}
}
validationDocument["site"] = validation
status := "proposed"
if validation.Valid && allProposalValidationsPass(proposal, validationDocument) {
status = "ready_for_review"
}
updated, err := a.patchWithUnsetScoped("authoring_runs", runID, Doc{"validation": validationDocument, "status": status}, nil, identity.TenantID, true)
if err != nil {
return nil, err
}
return Doc{"runId": runID, "status": status, "proposalRevision": updated["proposalRevision"], "validation": validation, "requiresCMSApproval": true}, nil
}
func validateSiteProposal(site Doc) siteValidationResult {
result := siteValidationResult{Valid: true, Errors: []string{}, Warnings: []string{}}
structure, ok := asDocument(site["structure"])
if !ok {
result.Errors = append(result.Errors, "site structure is required")
} else {
result.Errors = append(result.Errors, validateSiteStructure(structure)...)
}
pages, ok := asDocument(site["pages"])
if !ok || len(pages) == 0 {
result.Errors = append(result.Errors, "at least one site page is required")
pages = Doc{}
}
slugs := map[string]string{}
for pageID, rawPage := range pages {
page, pageOK := asDocument(rawPage)
if !pageOK {
result.Errors = append(result.Errors, fmt.Sprintf("page %s must be an object", pageID))
continue
}
result.Errors = append(result.Errors, validateSitePage(page)...)
if anyToString(page["id"]) != pageID {
result.Errors = append(result.Errors, fmt.Sprintf("page %s id must match its proposal key", pageID))
}
slug := strings.TrimSpace(anyToString(page["slug"]))
if previousID, exists := slugs[slug]; slug != "" && exists {
result.Errors = append(result.Errors, fmt.Sprintf("pages %s and %s use duplicate slug %s", previousID, pageID, slug))
} else if slug != "" {
slugs[slug] = pageID
}
}
if structure != nil {
navigation, _ := toDocumentArray(structure["navigation"])
for index, item := range navigation {
pageID := strings.TrimSpace(anyToString(item["pageId"]))
if _, exists := pages[pageID]; pageID != "" && !exists {
result.Errors = append(result.Errors, fmt.Sprintf("navigation item %d targets unknown page %s", index+1, pageID))
}
}
}
result.Valid = len(result.Errors) == 0
return result
}
func validateSiteStructure(structure Doc) []string {
validationErrors := []string{}
title := strings.TrimSpace(anyToString(structure["title"]))
if title == "" || len(title) > 200 {
validationErrors = append(validationErrors, "site title is required and must be at most 200 characters")
}
if description := anyToString(structure["description"]); len(description) > 1000 {
validationErrors = append(validationErrors, "site description must be at most 1000 characters")
}
navigation, ok := toDocumentArray(structure["navigation"])
if !ok {
return append(validationErrors, "navigation must be an array")
}
for index, item := range navigation {
if strings.TrimSpace(anyToString(item["label"])) == "" {
validationErrors = append(validationErrors, fmt.Sprintf("navigation item %d label is required", index+1))
}
if strings.TrimSpace(anyToString(item["pageId"])) == "" {
validationErrors = append(validationErrors, fmt.Sprintf("navigation item %d pageId is required", index+1))
}
}
return validationErrors
}
func validateSitePage(page Doc) []string {
validationErrors := []string{}
pageID := strings.TrimSpace(anyToString(page["id"]))
name := strings.TrimSpace(anyToString(page["name"]))
slug := strings.TrimSpace(anyToString(page["slug"]))
if pageID == "" || len(pageID) > 100 {
validationErrors = append(validationErrors, "page id is required and must be at most 100 characters")
}
if name == "" || len(name) > 200 {
validationErrors = append(validationErrors, fmt.Sprintf("page %s name is required and must be at most 200 characters", pageID))
}
if !slugPattern.MatchString(slug) {
validationErrors = append(validationErrors, fmt.Sprintf("page %s slug must contain lowercase letters, numbers, and hyphens only", pageID))
}
if _, _, err := normalizeSEOMetadata(page["seo"]); err != nil {
validationErrors = append(validationErrors, fmt.Sprintf("page %s %s", pageID, err.Error()))
}
blocks, ok := toDocumentArray(page["blocks"])
if !ok {
return append(validationErrors, fmt.Sprintf("page %s blocks must be an array", pageID))
}
return append(validationErrors, validateSiteBlocks(pageID, blocks, map[string]struct{}{})...)
}
func validateSiteBlocks(pageID string, blocks []Doc, seen map[string]struct{}) []string {
validationErrors := []string{}
for _, block := range blocks {
blockID := strings.TrimSpace(anyToString(block["id"]))
if blockID == "" {
validationErrors = append(validationErrors, fmt.Sprintf("page %s contains a block without an id", pageID))
} else if _, exists := seen[blockID]; exists {
validationErrors = append(validationErrors, fmt.Sprintf("page %s contains duplicate block id %s", pageID, blockID))
} else {
seen[blockID] = struct{}{}
}
if strings.TrimSpace(anyToString(block["type"])) == "" {
validationErrors = append(validationErrors, fmt.Sprintf("page %s block %s type is required", pageID, blockID))
}
if rawChildren, exists := block["children"]; exists {
children, ok := toDocumentArray(rawChildren)
if !ok {
validationErrors = append(validationErrors, fmt.Sprintf("page %s block %s children must be an array", pageID, blockID))
} else {
validationErrors = append(validationErrors, validateSiteBlocks(pageID, children, seen)...)
}
}
}
return validationErrors
}
func toDocumentArray(value any) ([]Doc, bool) {
var values []any
switch typed := value.(type) {
case List:
values = []any(typed)
case []any:
values = typed
default:
return nil, false
}
result := make([]Doc, 0, len(values))
for _, value := range values {
document, ok := asDocument(value)
if !ok {
return nil, false
}
result = append(result, document)
}
return result, true
}

1363
Backend/cmd/api/billing.go Normal file

File diff suppressed because it is too large Load Diff

507
Backend/cmd/api/blog.go Normal file
View File

@ -0,0 +1,507 @@
package main
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
blogStatusDraft = "draft"
blogStatusScheduled = "scheduled"
blogStatusPublished = "published"
)
var blogPostPublishingPolicy = blogPublishingPolicy{}
type blogPublishingPolicy struct{}
func (a *app) blogEditorMiddleware(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") && !hasRole(claims, "editor") {
writeError(w, http.StatusForbidden, "insufficient role for blog management")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listBlogPosts(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_posts", w)
}
func (a *app) createBlogPost(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareBlogPostPayload(Doc(payload), false, nil)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("blog_posts", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create resource")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getBlogPost(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_posts", w, r)
}
func (a *app) patchBlogPost(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var patch map[string]any
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
existing, err := a.getByID("blog_posts", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load resource")
return
}
doc, err := prepareBlogPostPayload(Doc(patch), true, existing)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.patch("blog_posts", id, doc)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update resource")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteBlogPost(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_posts", w, r)
}
func (a *app) listBlogCategories(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_categories", w)
}
func (a *app) createBlogCategory(w http.ResponseWriter, r *http.Request) {
a.createCollection("blog_categories", w, r)
}
func (a *app) getBlogCategory(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_categories", w, r)
}
func (a *app) patchBlogCategory(w http.ResponseWriter, r *http.Request) {
a.patchCollection("blog_categories", w, r)
}
func (a *app) deleteBlogCategory(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_categories", w, r)
}
func (a *app) listBlogTags(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_tags", w)
}
func (a *app) createBlogTag(w http.ResponseWriter, r *http.Request) {
a.createCollection("blog_tags", w, r)
}
func (a *app) getBlogTag(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_tags", w, r)
}
func (a *app) patchBlogTag(w http.ResponseWriter, r *http.Request) {
a.patchCollection("blog_tags", w, r)
}
func (a *app) deleteBlogTag(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_tags", w, r)
}
func (a *app) listBlogAuthors(w http.ResponseWriter, r *http.Request) {
a.listCollection("blog_authors", w)
}
func (a *app) createBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.createCollection("blog_authors", w, r)
}
func (a *app) getBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.getCollection("blog_authors", w, r)
}
func (a *app) patchBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.patchCollection("blog_authors", w, r)
}
func (a *app) deleteBlogAuthor(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("blog_authors", w, r)
}
func (a *app) listPublicBlogPosts(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
docs, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list blog posts")
return
}
now := time.Now().UTC()
posts := make([]Doc, 0, len(docs))
for _, doc := range docs {
if isPublicBlogPost(doc, now) {
posts = append(posts, doc)
}
}
writeJSON(w, http.StatusOK, posts)
}
func (a *app) getPublicBlogPostBySlug(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
slug := strings.TrimSpace(chi.URLParam(r, "slug"))
if slug == "" {
writeError(w, http.StatusBadRequest, "slug is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
var doc Doc
err := a.db.Collection("blog_posts").FindOne(ctx, Doc{"slug": slug}).Decode(&doc)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "blog post not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load blog post")
return
}
normalizeDocument(doc)
if !isPublicBlogPost(doc, time.Now().UTC()) {
writeError(w, http.StatusNotFound, "blog post not found")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) listPublicBlogCategories(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
posts, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load blog posts")
return
}
allowed := collectPublicBlogTaxonomyValues(posts, "categories", time.Now().UTC())
docs, err := a.list("blog_categories", Doc{}, Sort{{Key: "name", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list blog categories")
return
}
writeJSON(w, http.StatusOK, filterBlogTaxonomyDocuments(docs, allowed))
}
func (a *app) listPublicBlogTags(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
posts, err := a.list("blog_posts", Doc{}, Sort{{Key: "publishedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load blog posts")
return
}
allowed := collectPublicBlogTaxonomyValues(posts, "tags", time.Now().UTC())
docs, err := a.list("blog_tags", Doc{}, Sort{{Key: "name", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list blog tags")
return
}
writeJSON(w, http.StatusOK, filterBlogTaxonomyDocuments(docs, allowed))
}
func collectPublicBlogTaxonomyValues(posts []Doc, field string, now time.Time) map[string]struct{} {
allowed := make(map[string]struct{})
for _, post := range posts {
if !isPublicBlogPost(post, now) {
continue
}
values, hasValues, err := normalizeStringSlice(post[field])
if err != nil || !hasValues {
continue
}
for _, value := range values {
normalized := normalizeTaxonomyValue(value)
if normalized == "" {
continue
}
allowed[normalized] = struct{}{}
}
}
return allowed
}
func filterBlogTaxonomyDocuments(docs []Doc, allowed map[string]struct{}) []Doc {
if len(allowed) == 0 {
return []Doc{}
}
filtered := make([]Doc, 0, len(docs))
for _, doc := range docs {
if taxonomyDocumentAllowed(doc, allowed) {
filtered = append(filtered, doc)
}
}
return filtered
}
func taxonomyDocumentAllowed(doc Doc, allowed map[string]struct{}) bool {
for _, field := range []string{"id", "slug", "name"} {
value, ok := asString(doc[field])
if !ok {
continue
}
if _, exists := allowed[normalizeTaxonomyValue(value)]; exists {
return true
}
}
return false
}
func normalizeTaxonomyValue(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func prepareBlogPostPayload(payload Doc, partial bool, existing Doc) (Doc, error) {
if payload == nil {
payload = Doc{}
}
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
if err := applyPublishingPolicy(payload, partial, existing, blogPostPublishingPolicy); err != nil {
return nil, err
}
if slug, hasSlug, err := getOptionalString(payload, "slug"); err != nil {
return nil, errors.New("slug must be a string")
} else if hasSlug {
payload["slug"] = normalizeSlug(slug)
}
for _, field := range []string{"excerpt", "content", "authorId"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
if categories, has, err := normalizeStringSlice(payload["categories"]); err != nil {
return nil, errors.New("categories must be an array of strings")
} else if has {
payload["categories"] = categories
}
if tags, has, err := normalizeStringSlice(payload["tags"]); err != nil {
return nil, errors.New("tags must be an array of strings")
} else if has {
payload["tags"] = tags
}
if publishedAt, has, err := parseOptionalTime(payload["publishedAt"]); err != nil {
return nil, errors.New("publishedAt must be a unix milliseconds number or RFC3339 string")
} else if has {
payload["publishedAt"] = publishedAt.UnixMilli()
}
return payload, nil
}
func (blogPublishingPolicy) defaultStatus() string {
return blogStatusDraft
}
func (blogPublishingPolicy) isAllowedStatus(status string) bool {
return isValidBlogStatus(status)
}
func (blogPublishingPolicy) invalidStatusMessage() string {
return "status must be one of: draft, scheduled, published"
}
func (blogPublishingPolicy) validate(payload Doc, existing Doc) error {
if status, ok := mergedPublishingString(payload, existing, "status"); ok && status == blogStatusScheduled {
if _, has := mergedPublishingValue(payload, existing, "publishedAt"); !has {
return errors.New("publishedAt is required when status is scheduled")
}
}
if status, ok := mergedPublishingString(payload, existing, "status"); ok && status == blogStatusPublished {
if _, has := mergedPublishingString(payload, existing, "slug"); !has {
return errors.New("slug is required when status is published")
}
}
return nil
}
func isPublicBlogPost(post Doc, now time.Time) bool {
status, ok := post["status"].(string)
normalizedStatus := strings.TrimSpace(strings.ToLower(status))
if !ok || (normalizedStatus != blogStatusPublished && normalizedStatus != blogStatusScheduled) {
return false
}
slug, ok := asString(post["slug"])
if !ok || slug == "" {
return false
}
if private, ok := asBool(post["private"]); ok && private {
return false
}
if visibility, ok := asString(post["visibility"]); ok && strings.EqualFold(visibility, "private") {
return false
}
publishedAt, hasPublishedAt, err := parseOptionalTime(post["publishedAt"])
if err == nil && hasPublishedAt {
return !publishedAt.After(now)
}
if normalizedStatus == blogStatusScheduled {
return false
}
return true
}
func isValidBlogStatus(status string) bool {
switch strings.TrimSpace(strings.ToLower(status)) {
case blogStatusDraft, blogStatusScheduled, blogStatusPublished:
return true
default:
return false
}
}
func normalizeStringSlice(raw any) ([]string, bool, error) {
if raw == nil {
return nil, false, nil
}
switch values := raw.(type) {
case []string:
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out, true, nil
case List:
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil, false, errors.New("array must contain strings")
}
trimmed := strings.TrimSpace(text)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out, true, nil
case []any:
out := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
return nil, false, errors.New("array must contain strings")
}
trimmed := strings.TrimSpace(text)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out, true, nil
default:
return nil, false, errors.New("not an array")
}
}
func parseOptionalTime(raw any) (time.Time, bool, error) {
if raw == nil {
return time.Time{}, false, nil
}
if parsed, ok := asTime(raw); ok {
return parsed.UTC(), true, nil
}
return time.Time{}, false, errors.New("invalid time")
}

View File

@ -0,0 +1,841 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
communityStatusActive = "active"
communityStatusArchived = "archived"
communityVisibilityPublic = "public"
communityVisibilityPrivate = "private"
communityAccessOpen = "open"
communityAccessRole = "role"
communityAccessEntitlement = "entitlement"
communityAccessMembership = "membership"
)
func (a *app) communitiesAdminMiddleware(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 community management")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) communityLearnerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if !hasRole(claims, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") {
writeError(w, http.StatusForbidden, "insufficient role for community access")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listCommunities(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
filter := Doc{}
if status := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); status != "" {
filter["status"] = status
}
docs, err := a.list("communities", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list communities")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createCommunity(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareCommunityPayload(Doc(payload), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("communities", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create community")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getCommunity(w http.ResponseWriter, r *http.Request) {
a.getCollection("communities", w, r)
}
func (a *app) patchCommunity(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeError(w, http.StatusBadRequest, "community id is required")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
patch, err := prepareCommunityPayload(Doc(payload), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err := a.patch("communities", id, patch)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update community")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteCommunity(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("communities", w, r)
}
func (a *app) listCommunityChannels(w http.ResponseWriter, r *http.Request) {
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
if communityID == "" {
writeError(w, http.StatusBadRequest, "community id is required")
return
}
if _, err := a.getByID("communities", communityID); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "community not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load community")
return
}
docs, err := a.list("community_channels", Doc{"communityId": communityID}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list community channels")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createCommunityChannel(w http.ResponseWriter, r *http.Request) {
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
if communityID == "" {
writeError(w, http.StatusBadRequest, "community id is required")
return
}
if _, err := a.getByID("communities", communityID); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "community not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load community")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareCommunityChannelPayload(communityID, Doc(payload), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("community_channels", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create channel")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getCommunityChannel(w http.ResponseWriter, r *http.Request) {
channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId"))
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load channel")
return
}
writeJSON(w, http.StatusOK, channel)
}
func (a *app) patchCommunityChannel(w http.ResponseWriter, r *http.Request) {
channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId"))
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load channel")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
patch, err := prepareCommunityChannelPayload(stringValue(channel["communityId"]), Doc(payload), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err := a.patch("community_channels", stringValue(channel["id"]), patch)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update channel")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteCommunityChannel(w http.ResponseWriter, r *http.Request) {
channel, err := a.getCommunityChannelByID(chi.URLParam(r, "id"), chi.URLParam(r, "channelId"))
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load channel")
return
}
if err := a.deleteByID("community_channels", stringValue(channel["id"])); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete channel")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *app) listCommunityMemberships(w http.ResponseWriter, r *http.Request) {
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
if communityID == "" {
writeError(w, http.StatusBadRequest, "community id is required")
return
}
docs, err := a.list("community_memberships", Doc{"communityId": communityID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list memberships")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createCommunityMembership(w http.ResponseWriter, r *http.Request) {
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
if communityID == "" {
writeError(w, http.StatusBadRequest, "community id is required")
return
}
if _, err := a.getByID("communities", communityID); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "community not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load community")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareCommunityMembershipPayload(communityID, Doc(payload), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("community_memberships", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create membership")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchCommunityMembership(w http.ResponseWriter, r *http.Request) {
membership, err := a.getCommunityMembershipByID(chi.URLParam(r, "id"), chi.URLParam(r, "membershipId"))
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load membership")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
patch, err := prepareCommunityMembershipPayload(stringValue(membership["communityId"]), Doc(payload), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err := a.patch("community_memberships", stringValue(membership["id"]), patch)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update membership")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) listLearnerCommunities(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
communities, err := a.list("communities", Doc{"status": communityStatusActive}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list communities")
return
}
out := make([]Doc, 0, len(communities))
for _, community := range communities {
allowed, reason, err := a.canAccessCommunityForClaims(claims, community)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate community access")
return
}
if !allowed {
continue
}
channels, err := a.list("community_channels", Doc{"communityId": stringValue(community["id"])}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list channels")
return
}
membership, _ := a.findCommunityMembership(stringValue(community["id"]), claims.Sub)
item := Doc{
"community": community,
"channels": channels,
"access": Doc{
"allowed": allowed,
"reason": reason,
},
}
if membership != nil {
item["membership"] = membership
}
out = append(out, item)
}
writeJSON(w, http.StatusOK, out)
}
func (a *app) listLearnerCommunityChannels(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
if communityID == "" {
writeError(w, http.StatusBadRequest, "community id is required")
return
}
community, err := a.getByID("communities", communityID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "community not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load community")
return
}
allowed, _, err := a.canAccessCommunityForClaims(claims, community)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate community access")
return
}
if !allowed {
writeError(w, http.StatusForbidden, "community access denied")
return
}
docs, err := a.list("community_channels", Doc{"communityId": communityID}, Sort{{Key: "createdAt", Value: 1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list channels")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) joinLearnerCommunity(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
communityID := strings.TrimSpace(chi.URLParam(r, "id"))
if communityID == "" {
writeError(w, http.StatusBadRequest, "community id is required")
return
}
community, err := a.getByID("communities", communityID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "community not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load community")
return
}
allowed, _, err := a.canAccessCommunityForClaims(claims, community)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate community access")
return
}
if !allowed {
writeError(w, http.StatusForbidden, "community access denied")
return
}
existing, _ := a.findCommunityMembership(communityID, claims.Sub)
if existing != nil {
writeJSON(w, http.StatusOK, existing)
return
}
doc, err := prepareCommunityMembershipPayload(communityID, Doc{
"userId": claims.Sub,
"role": "member",
"status": "active",
"source": a.resolveCommunityJoinSource(claims, community),
}, false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("community_memberships", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to join community")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) resolveCommunityJoinSource(claims *accessClaims, community Doc) string {
if hasRole(claims, "admin") {
return "admin_override"
}
requiredEntitlement, _ := asString(community["requiredEntitlementKey"])
if strings.TrimSpace(requiredEntitlement) != "" {
return "entitlement"
}
roles := communityAllowedRoles(community)
for _, role := range roles {
if hasRole(claims, role) {
return "role"
}
}
return "self_join"
}
func (a *app) canAccessCommunityForClaims(claims *accessClaims, community Doc) (bool, string, error) {
if claims == nil || strings.TrimSpace(claims.Sub) == "" {
return false, "missing_user", nil
}
if hasRole(claims, "admin") {
return true, "admin", nil
}
status, _ := asString(community["status"])
if status == communityStatusArchived {
return false, "archived", nil
}
visibility, _ := asString(community["visibility"])
if strings.TrimSpace(visibility) == "" {
visibility = communityVisibilityPrivate
}
if visibility == communityVisibilityPublic {
return true, "public", nil
}
if membership, _ := a.findCommunityMembership(stringValue(community["id"]), claims.Sub); membership != nil {
if strings.EqualFold(stringValue(membership["status"]), "active") {
return true, "membership", nil
}
}
for _, role := range communityAllowedRoles(community) {
if hasRole(claims, role) {
return true, "role", nil
}
}
requiredEntitlement, _ := asString(community["requiredEntitlementKey"])
requiredEntitlement = strings.TrimSpace(requiredEntitlement)
if requiredEntitlement != "" {
allowed, err := a.hasActiveEntitlement(claims.Sub, requiredEntitlement, time.Now().UTC())
if err != nil {
return false, "entitlement_error", err
}
if allowed {
return true, "entitlement", nil
}
}
accessPolicy, _ := asString(community["accessPolicy"])
if accessPolicy == communityAccessOpen {
return true, "open", nil
}
return false, "membership_required", nil
}
func communityAllowedRoles(community Doc) []string {
raw, exists := community["allowedRoles"]
if !exists || raw == nil {
return []string{}
}
values, _, err := normalizeStringSlice(raw)
if err != nil {
return []string{}
}
out := make([]string, 0, len(values))
for _, value := range values {
if strings.TrimSpace(value) == "" {
continue
}
out = append(out, strings.ToLower(strings.TrimSpace(value)))
}
return out
}
func (a *app) findCommunityMembership(communityID, userID string) (Doc, error) {
docs, err := a.list("community_memberships", Doc{"communityId": strings.TrimSpace(communityID), "userId": strings.TrimSpace(userID)}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
return nil, err
}
if len(docs) == 0 {
return nil, nil
}
return docs[0], nil
}
func (a *app) getCommunityChannelByID(communityID, channelID string) (Doc, error) {
channel, err := a.getByID("community_channels", strings.TrimSpace(channelID))
if err != nil {
return nil, err
}
if strings.TrimSpace(stringValue(channel["communityId"])) != strings.TrimSpace(communityID) {
return nil, ErrNotFound
}
return channel, nil
}
func (a *app) getCommunityMembershipByID(communityID, membershipID string) (Doc, error) {
membership, err := a.getByID("community_memberships", strings.TrimSpace(membershipID))
if err != nil {
return nil, err
}
if strings.TrimSpace(stringValue(membership["communityId"])) != strings.TrimSpace(communityID) {
return nil, ErrNotFound
}
return membership, nil
}
func prepareCommunityPayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
name, hasName, err := getOptionalString(payload, "name")
if err != nil {
return nil, errors.New("name must be a string")
}
if !partial && (!hasName || name == "") {
return nil, errors.New("name is required")
}
if hasName {
payload["name"] = name
}
slug, hasSlug, err := getOptionalString(payload, "slug")
if err != nil {
return nil, errors.New("slug must be a string")
}
if !hasSlug && hasName {
slug = slugify(name)
hasSlug = true
}
if hasSlug {
payload["slug"] = normalizeSlug(slug)
}
if description, has, err := getOptionalString(payload, "description"); err != nil {
return nil, errors.New("description must be a string")
} else if has {
payload["description"] = description
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = communityStatusActive
hasStatus = true
}
if hasStatus {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case communityStatusActive, communityStatusArchived:
payload["status"] = status
default:
return nil, errors.New("status must be one of: active, archived")
}
}
visibility, hasVisibility, err := getOptionalString(payload, "visibility")
if err != nil {
return nil, errors.New("visibility must be a string")
}
if !partial && !hasVisibility {
visibility = communityVisibilityPrivate
hasVisibility = true
}
if hasVisibility {
visibility = strings.ToLower(strings.TrimSpace(visibility))
switch visibility {
case communityVisibilityPublic, communityVisibilityPrivate:
payload["visibility"] = visibility
default:
return nil, errors.New("visibility must be one of: public, private")
}
}
accessPolicy, hasPolicy, err := getOptionalString(payload, "accessPolicy")
if err != nil {
return nil, errors.New("accessPolicy must be a string")
}
if !partial && !hasPolicy {
accessPolicy = communityAccessMembership
hasPolicy = true
}
if hasPolicy {
accessPolicy = strings.ToLower(strings.TrimSpace(accessPolicy))
switch accessPolicy {
case communityAccessOpen, communityAccessRole, communityAccessEntitlement, communityAccessMembership:
payload["accessPolicy"] = accessPolicy
default:
return nil, errors.New("accessPolicy must be one of: open, role, entitlement, membership")
}
}
if allowedRoles, has, err := normalizeStringSlice(payload["allowedRoles"]); err != nil {
return nil, errors.New("allowedRoles must be an array of strings")
} else if has {
roles := make([]string, 0, len(allowedRoles))
for _, role := range allowedRoles {
normalized := strings.ToLower(strings.TrimSpace(role))
if normalized != "" {
roles = append(roles, normalized)
}
}
payload["allowedRoles"] = roles
}
requiredEntitlementKey, hasEntitlement, err := getOptionalString(payload, "requiredEntitlementKey")
if err != nil {
return nil, errors.New("requiredEntitlementKey must be a string")
}
if hasEntitlement {
payload["requiredEntitlementKey"] = requiredEntitlementKey
}
if metadata, exists := payload["metadata"]; exists && metadata != nil {
value, ok := asMap(metadata)
if !ok {
return nil, errors.New("metadata must be an object")
}
payload["metadata"] = Doc(value)
}
currentPolicy, _ := asString(payload["accessPolicy"])
if strings.TrimSpace(currentPolicy) == communityAccessRole {
roles, _, _ := normalizeStringSlice(payload["allowedRoles"])
if len(roles) == 0 {
return nil, errors.New("allowedRoles is required when accessPolicy=role")
}
}
if strings.TrimSpace(currentPolicy) == communityAccessEntitlement {
required, _ := asString(payload["requiredEntitlementKey"])
if strings.TrimSpace(required) == "" {
return nil, errors.New("requiredEntitlementKey is required when accessPolicy=entitlement")
}
}
return payload, nil
}
func prepareCommunityChannelPayload(communityID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
payload["communityId"] = strings.TrimSpace(communityID)
name, hasName, err := getOptionalString(payload, "name")
if err != nil {
return nil, errors.New("name must be a string")
}
if !partial && (!hasName || name == "") {
return nil, errors.New("name is required")
}
if hasName {
payload["name"] = name
}
slug, hasSlug, err := getOptionalString(payload, "slug")
if err != nil {
return nil, errors.New("slug must be a string")
}
if !hasSlug && hasName {
slug = slugify(name)
hasSlug = true
}
if hasSlug {
payload["slug"] = normalizeSlug(slug)
}
if topic, has, err := getOptionalString(payload, "topic"); err != nil {
return nil, errors.New("topic must be a string")
} else if has {
payload["topic"] = topic
}
if isPrivate, has, err := getOptionalBool(payload, "isPrivate"); err != nil {
return nil, errors.New("isPrivate must be a boolean")
} else if has {
payload["isPrivate"] = isPrivate
} else if !partial {
payload["isPrivate"] = false
}
return payload, nil
}
func prepareCommunityMembershipPayload(communityID string, payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
payload["communityId"] = strings.TrimSpace(communityID)
userID, hasUserID, err := getOptionalString(payload, "userId")
if err != nil {
return nil, errors.New("userId must be a string")
}
if !partial && (!hasUserID || userID == "") {
return nil, errors.New("userId is required")
}
if hasUserID {
payload["userId"] = userID
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "active"
hasStatus = true
}
if hasStatus {
normalized := strings.ToLower(strings.TrimSpace(status))
switch normalized {
case "active", "pending", "banned", "left":
payload["status"] = normalized
default:
return nil, errors.New("status must be one of: active, pending, banned, left")
}
}
role, hasRoleValue, err := getOptionalString(payload, "role")
if err != nil {
return nil, errors.New("role must be a string")
}
if !partial && !hasRoleValue {
role = "member"
hasRoleValue = true
}
if hasRoleValue {
normalized := strings.ToLower(strings.TrimSpace(role))
switch normalized {
case "member", "moderator", "owner":
payload["role"] = normalized
default:
return nil, errors.New("role must be one of: member, moderator, owner")
}
}
source, hasSource, err := getOptionalString(payload, "source")
if err != nil {
return nil, errors.New("source must be a string")
}
if !partial && !hasSource {
source = "manual"
hasSource = true
}
if hasSource {
normalized := strings.ToLower(strings.TrimSpace(source))
switch normalized {
case "manual", "self_join", "role", "entitlement", "admin_override":
payload["source"] = normalized
default:
return nil, errors.New("source must be one of: manual, self_join, role, entitlement, admin_override")
}
}
if metadata, exists := payload["metadata"]; exists && metadata != nil {
value, ok := asMap(metadata)
if !ok {
return nil, errors.New("metadata must be an object")
}
payload["metadata"] = Doc(value)
}
return payload, nil
}

View File

@ -0,0 +1,529 @@
package main
import (
"context"
"net/http"
"strings"
"time"
)
const (
dataSubjectRequestTypeCorrection = "correction"
dataSubjectRequestTypeRestriction = "restriction"
dataSubjectRequestTypeErasure = "erasure"
dataSubjectRequestStatusPending = "pending"
dataSubjectRequestStatusCompleted = "completed"
)
func (a *app) educationalRecordsMiddleware(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 !hasAnyRole(claims, "admin", "instructor", "platform_owner", "platform_admin") {
writeError(w, http.StatusForbidden, "insufficient role for educational records")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) educationalComplianceReport(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
filter := Doc{}
if enforceTenant {
filter["tenantId"] = tenantID
}
users, err := a.list("users", filter, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load users for compliance report")
return
}
enrollments, err := a.list("enrollments", filter, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load enrollments for compliance report")
return
}
auditEvents, err := a.list("audit_events", filter, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load audit events for compliance report")
return
}
writeJSON(w, http.StatusOK, Doc{
"scope": "GDPR + FERPA-style educational records controls",
"tenantId": tenantID,
"tenantScoped": enforceTenant,
"classification": Doc{
"identityData": Doc{
"collection": "users",
"fields": []string{"id", "email", "name", "username", "roles", "tenantId"},
"count": len(users),
},
"educationalRecords": Doc{
"collection": "enrollments",
"fields": []string{"userId", "courseId", "status", "progress", "score", "completedAt"},
"count": len(enrollments),
},
"operationalAudit": Doc{
"collection": "audit_events",
"fields": []string{"actor", "action", "resource", "result", "timestamp", "metadata"},
"count": len(auditEvents),
},
},
"governance": Doc{
"purposeLimitation": "Educational records are used for training delivery, progress tracking, certification, and support.",
"minimumNecessary": "Endpoints should only return fields required for authorized operational use.",
"retentionPolicy": Doc{
"educationalRecordRetentionDays": a.cfg.EducationalRecordRetentionDays,
},
},
})
}
func (a *app) applyEducationalRetentionPolicy(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
now := time.Now().UTC()
cutoff := now.Add(-time.Duration(a.cfg.EducationalRecordRetentionDays) * 24 * time.Hour).UnixMilli()
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
enrollmentCollection, err := a.resolveCollection("enrollments")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve enrollments collection")
return
}
requestCollection, err := a.resolveCollection("data_subject_requests")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve data subject requests collection")
return
}
enrollmentResult, err := enrollmentCollection.UpdateMany(ctx, Doc{
"updatedAt": Doc{"$lt": cutoff},
"status": Doc{"$in": List{"completed", "failed"}},
"archivedAt": Doc{"$exists": false},
}, Doc{
"$set": Doc{
"archivedAt": now.UnixMilli(),
"archivedReason": "educational_retention_policy",
"dataMinimized": true,
},
"$unset": Doc{
"score": "",
"progress": "",
"timeSpentMin": "",
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to apply educational retention policy")
return
}
requestResult, err := requestCollection.UpdateMany(ctx, Doc{
"updatedAt": Doc{"$lt": cutoff},
"status": dataSubjectRequestStatusCompleted,
"archivedAt": Doc{"$exists": false},
}, Doc{"$set": Doc{
"archivedAt": now.UnixMilli(),
"archivedReason": "educational_retention_policy",
}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to apply data subject request retention policy")
return
}
writeJSON(w, http.StatusOK, Doc{
"enrollmentsArchived": enrollmentResult.ModifiedCount,
"dataSubjectRequestsArchived": requestResult.ModifiedCount,
"cutoff": cutoff,
})
}
func (a *app) exportMyDataSubjectPackage(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
userID := strings.TrimSpace(claims.Sub)
if userID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
filter := Doc{"userId": userID}
if enforceTenant {
filter["tenantId"] = tenantID
}
users, err := a.list("users", Doc{"id": userID}, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export user profile")
return
}
enrollments, err := a.list("enrollments", filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export educational records")
return
}
consentReceipts, err := a.list("consent_receipts", filter, Sort{{Key: "decidedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export consent receipts")
return
}
requests, err := a.list("data_subject_requests", filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to export data subject requests")
return
}
a.recordAuditEvent(r.Context(), claims, "compliance.data_subject.export", "data_subject", auditResultSuccess, map[string]any{
"tenantScoped": enforceTenant,
"recordTypes": []string{"users", "enrollments", "consent_receipts", "data_subject_requests"},
})
writeJSON(w, http.StatusOK, Doc{
"subject": userID,
"scope": "GDPR + FERPA-style educational records controls",
"exportedAt": time.Now().UTC().Format(time.RFC3339),
"data": Doc{
"identity": users,
"educationalRecords": enrollments,
"consentReceipts": consentReceipts,
"requests": requests,
},
})
}
func (a *app) submitMyDataCorrectionRequest(w http.ResponseWriter, r *http.Request) {
a.submitMyDataSubjectRequest(w, r, dataSubjectRequestTypeCorrection)
}
func (a *app) submitMyDataRestrictionRequest(w http.ResponseWriter, r *http.Request) {
a.submitMyDataSubjectRequest(w, r, dataSubjectRequestTypeRestriction)
}
func (a *app) submitMyDataSubjectRequest(w http.ResponseWriter, r *http.Request, requestType string) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid request payload")
return
}
subjectID := strings.TrimSpace(claims.Sub)
if subjectID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
tenantID, _ := entitlementTenantScopeFromRequest(r)
requestDoc := Doc{
"userId": subjectID,
"tenantId": tenantID,
"type": requestType,
"status": dataSubjectRequestStatusPending,
"requestedAt": time.Now().UTC().UnixMilli(),
"payload": sanitizeDataSubjectPayload(payload),
}
created, err := a.create("data_subject_requests", requestDoc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to record data subject request")
return
}
a.recordAuditEvent(r.Context(), claims, "compliance.data_subject."+requestType, "data_subject", auditResultSuccess, map[string]any{
"requestId": created["id"],
})
writeJSON(w, http.StatusAccepted, Doc{
"request": created,
})
}
func (a *app) eraseMyDataSubjectRecord(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload struct {
Mode string `json:"mode"`
Reason string `json:"reason"`
}
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid request payload")
return
}
subjectID := strings.TrimSpace(claims.Sub)
if subjectID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
mode := strings.TrimSpace(strings.ToLower(payload.Mode))
if mode == "" {
mode = "anonymize"
}
if mode != "anonymize" {
writeError(w, http.StatusBadRequest, "mode must be anonymize")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
now := time.Now().UTC().UnixMilli()
userPatch := Doc{
"email": "deleted+" + subjectID + "@example.invalid",
"name": "Deleted User",
"username": "deleted-" + subjectID,
"preferred_username": "deleted-" + subjectID,
"status": "inactive",
"gdprErasedAt": now,
}
user, err := a.patch("users", subjectID, userPatch)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to anonymize user identity")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
enrollmentCollection, err := a.resolveCollection("enrollments")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve enrollments collection")
return
}
enrollmentFilter := Doc{"userId": subjectID}
if enforceTenant {
enrollmentFilter["tenantId"] = tenantID
}
enrollmentResult, err := enrollmentCollection.UpdateMany(ctx, enrollmentFilter, Doc{
"$set": Doc{
"userId": "erased:" + subjectID,
"erasedUserId": subjectID,
"gdprErasedAt": now,
"updatedAt": now,
"archivedReason": "data_subject_erasure",
},
"$unset": Doc{
"score": "",
"progress": "",
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to anonymize educational records")
return
}
requestDoc := Doc{
"userId": subjectID,
"tenantId": tenantID,
"type": dataSubjectRequestTypeErasure,
"status": dataSubjectRequestStatusCompleted,
"requestedAt": now,
"completedAt": now,
"mode": mode,
"reason": strings.TrimSpace(payload.Reason),
}
_, _ = a.create("data_subject_requests", requestDoc)
a.recordAuditEvent(r.Context(), claims, "compliance.data_subject.erase", "data_subject", auditResultSuccess, map[string]any{
"mode": mode,
"tenantScoped": enforceTenant,
"enrollmentsAnonymized": enrollmentResult.ModifiedCount,
})
writeJSON(w, http.StatusOK, Doc{
"status": "completed",
"userId": user["id"],
"enrollmentsAnonymized": enrollmentResult.ModifiedCount,
"completedAt": now,
})
}
func (a *app) getMyConsentReceipt(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
userID := strings.TrimSpace(claims.Sub)
if userID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
filter := Doc{"userId": userID, "revokedAt": Doc{"$exists": false}}
if enforceTenant {
filter["tenantId"] = tenantID
}
receipts, err := a.list("consent_receipts", filter, Sort{{Key: "decidedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load consent receipt")
return
}
if len(receipts) == 0 {
writeJSON(w, http.StatusOK, Doc{"receipt": nil})
return
}
writeJSON(w, http.StatusOK, Doc{"receipt": receipts[0]})
}
func (a *app) upsertMyConsentReceipt(w http.ResponseWriter, r *http.Request) {
claims, ok := claimsFromRequest(r)
if !ok {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload struct {
PolicyVersion string `json:"policyVersion"`
PolicyURL string `json:"policyUrl"`
Analytics bool `json:"analytics"`
Marketing bool `json:"marketing"`
Preferences bool `json:"preferences"`
}
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid consent payload")
return
}
userID := strings.TrimSpace(claims.Sub)
if userID == "" {
writeError(w, http.StatusBadRequest, "authenticated subject is required")
return
}
policyVersion := strings.TrimSpace(payload.PolicyVersion)
if policyVersion == "" {
writeError(w, http.StatusBadRequest, "policyVersion is required")
return
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
now := time.Now().UTC().UnixMilli()
filter := Doc{"userId": userID, "revokedAt": Doc{"$exists": false}}
if enforceTenant {
filter["tenantId"] = tenantID
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
collection, err := a.resolveCollection("consent_receipts")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to resolve consent receipts collection")
return
}
_, _ = collection.UpdateMany(ctx, filter, Doc{"$set": Doc{
"revokedAt": now,
"revokedReason": "superseded",
"updatedAt": now,
}})
doc := Doc{
"userId": userID,
"tenantId": tenantID,
"policyVersion": policyVersion,
"policyUrl": strings.TrimSpace(payload.PolicyURL),
"categories": Doc{
"necessary": true,
"analytics": payload.Analytics,
"marketing": payload.Marketing,
"preferences": payload.Preferences,
},
"decidedAt": now,
}
created, err := a.create("consent_receipts", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to store consent receipt")
return
}
a.recordAuditEvent(r.Context(), claims, "consent.receipt.upsert", "consent_receipts", auditResultSuccess, map[string]any{
"policyVersion": policyVersion,
})
writeJSON(w, http.StatusOK, Doc{"receipt": created})
}
func claimsFromRequest(r *http.Request) (*accessClaims, bool) {
if r == nil {
return nil, false
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
return nil, false
}
return claims, true
}
func sanitizeDataSubjectPayload(payload map[string]any) Doc {
if payload == nil {
return Doc{}
}
out := Doc{}
if reason, ok := payload["reason"].(string); ok {
out["reason"] = strings.TrimSpace(reason)
}
if fields, ok := payload["fields"].(map[string]any); ok {
allowed := Doc{}
for key, raw := range fields {
value, isString := raw.(string)
if !isString {
continue
}
switch strings.TrimSpace(key) {
case "name", "displayName", "email":
allowed[key] = strings.TrimSpace(value)
}
}
if len(allowed) > 0 {
out["fields"] = allowed
}
}
return out
}

143
Backend/cmd/api/config.go Normal file
View File

@ -0,0 +1,143 @@
package main
import (
"os"
"strconv"
"strings"
"time"
)
type config struct {
Port string
WebDir string
AppBaseURL string
MCPResourceURL string
CorsOrigins []string
DatabaseURL string
MinIOEndpoint string
MinIOBucket string
MinIOAccessKey string
MinIOSecretKey string
AssetEncryptionEnabled bool
AuthDisabled bool
KeycloakIssuer string
KeycloakIssuerInternal string
KeycloakAudience string
StripeSecretKey string
StripeAPIBaseURL string
StripeWebhookSecret string
StripeWebhookTolerance time.Duration
StripeAllowedPriceIDs []string
StripeCheckoutSuccessURL string
StripeCheckoutCancelURL string
BillingEventRetentionDays int
EntitlementHistoryRetentionDays int
EducationalRecordRetentionDays int
DefaultTrialDays int
DefaultFreeDays int
KeycloakThemeDeployEnabled bool
NotificationProvider string
SendGridAPIKey string
SendGridFromEmail string
SendGridAPIBaseURL string
ShutdownTimeout time.Duration
}
func loadConfig() config {
return config{
Port: getEnv("PORT", "8080"),
WebDir: strings.TrimSpace(getEnv("WEB_DIR", "")),
AppBaseURL: strings.TrimRight(getEnv("APP_BASE_URL", "http://localhost:5173"), "/"),
MCPResourceURL: strings.TrimRight(getEnv("MCP_RESOURCE_URL", "http://localhost:8080/api/mcp"), "/"),
CorsOrigins: parseList(getEnv("CORS_ALLOWED_ORIGINS", "*")),
DatabaseURL: getEnv("DATABASE_URL", "postgres://cms:cms@postgres:5432/cms?sslmode=disable"),
MinIOEndpoint: getEnv("MINIO_ENDPOINT", ""),
MinIOBucket: getEnv("MINIO_BUCKET", ""),
MinIOAccessKey: getEnv("MINIO_ACCESS_KEY", ""),
MinIOSecretKey: getEnv("MINIO_SECRET_KEY", ""),
AssetEncryptionEnabled: getEnvBool("ASSET_ENCRYPTION_ENABLED", false),
AuthDisabled: getEnvBool("AUTH_DISABLED", false),
KeycloakIssuer: getEnv("KEYCLOAK_ISSUER", "http://localhost:8081/realms/cms"),
KeycloakIssuerInternal: getEnv("KEYCLOAK_ISSUER_INTERNAL", "http://keycloak:8080/realms/cms"),
KeycloakAudience: getEnv("KEYCLOAK_AUDIENCE", "cms-backend"),
StripeSecretKey: getEnv("STRIPE_SECRET_KEY", ""),
StripeAPIBaseURL: getEnv("STRIPE_API_BASE_URL", "https://api.stripe.com"),
StripeWebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
StripeWebhookTolerance: time.Duration(getEnvInt("STRIPE_WEBHOOK_TOLERANCE_SECONDS", 300)) * time.Second,
StripeAllowedPriceIDs: parseOptionalList(getEnv("STRIPE_ALLOWED_PRICE_IDS", "")),
StripeCheckoutSuccessURL: getEnv("STRIPE_CHECKOUT_SUCCESS_URL", "http://localhost:5173/learn/billing?checkout=success"),
StripeCheckoutCancelURL: getEnv("STRIPE_CHECKOUT_CANCEL_URL", "http://localhost:5173/learn/billing?checkout=cancel"),
BillingEventRetentionDays: getEnvInt("BILLING_EVENT_RETENTION_DAYS", 365),
EntitlementHistoryRetentionDays: getEnvInt("ENTITLEMENT_HISTORY_RETENTION_DAYS", 730),
EducationalRecordRetentionDays: getEnvInt("EDUCATIONAL_RECORD_RETENTION_DAYS", 2555),
DefaultTrialDays: getEnvInt("DEFAULT_TRIAL_DAYS", 14),
DefaultFreeDays: getEnvInt("DEFAULT_FREE_ACCESS_DAYS", 3650),
KeycloakThemeDeployEnabled: getEnvBool("KEYCLOAK_THEME_DEPLOY_ENABLED", true),
NotificationProvider: strings.ToLower(getEnv("NOTIFICATION_PROVIDER", "noop")),
SendGridAPIKey: getEnv("SENDGRID_API_KEY", ""),
SendGridFromEmail: getEnv("SENDGRID_FROM_EMAIL", ""),
SendGridAPIBaseURL: getEnv("SENDGRID_API_BASE_URL", "https://api.sendgrid.com"),
ShutdownTimeout: time.Duration(getEnvInt("SHUTDOWN_TIMEOUT_SECONDS", 10)) * time.Second,
}
}
func getEnv(key, fallback string) string {
value, ok := os.LookupEnv(key)
if !ok || strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func getEnvInt(key string, fallback int) int {
raw := strings.TrimSpace(getEnv(key, ""))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return value
}
func getEnvBool(key string, fallback bool) bool {
raw := strings.TrimSpace(strings.ToLower(getEnv(key, "")))
if raw == "" {
return fallback
}
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
}
func parseList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return []string{"*"}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
out = append(out, item)
}
}
if len(out) == 0 {
return []string{"*"}
}
return out
}
func parseOptionalList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return []string{}
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
item := strings.TrimSpace(part)
if item != "" {
out = append(out, item)
}
}
return out
}

View File

@ -0,0 +1,571 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
entitlementStatusActive = "active"
entitlementStatusTrial = "trial"
entitlementStatusGrace = "grace"
entitlementStatusRevoked = "revoked"
entitlementStatusExpired = "expired"
entitlementScopeUser = "user"
entitlementScopeTenant = "tenant"
entitlementScopePlatform = "platform"
)
var entitlementActiveStatuses = map[string]struct{}{
entitlementStatusActive: {},
entitlementStatusTrial: {},
entitlementStatusGrace: {},
}
func (a *app) entitlementsAdminMiddleware(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 entitlement management")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listEntitlements(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
filter := Doc{}
if tenantID, enforceTenant := entitlementTenantScopeFromRequest(r); enforceTenant {
filter["tenantId"] = tenantID
}
if userID := strings.TrimSpace(r.URL.Query().Get("userId")); userID != "" {
filter["userId"] = userID
}
if key := strings.TrimSpace(r.URL.Query().Get("key")); key != "" {
filter["key"] = key
}
if status := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); status != "" {
filter["status"] = status
}
docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list entitlements")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createEntitlement(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareEntitlementPayload(Doc(payload), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc = applyEntitlementScopeDefaults(doc, entitlementScopeFromClaims(r))
doc, err = a.create("entitlements", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create entitlement")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getEntitlement(w http.ResponseWriter, r *http.Request) {
a.getCollection("entitlements", w, r)
}
func (a *app) patchEntitlement(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeError(w, http.StatusBadRequest, "entitlement id is required")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
patch, err := prepareEntitlementPayload(Doc(payload), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if tenantID, enforceTenant := entitlementTenantScopeFromRequest(r); enforceTenant {
patch["tenantId"] = tenantID
patch["scope"] = entitlementScopeTenant
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims != nil {
patch["lastUpdatedByUserId"] = strings.TrimSpace(claims.Sub)
}
doc, err := a.patch("entitlements", id, patch)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update entitlement")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteEntitlement(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("entitlements", w, r)
}
func (a *app) revokeEntitlement(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeError(w, http.StatusBadRequest, "entitlement id is required")
return
}
doc, err := a.patch("entitlements", id, Doc{
"status": entitlementStatusRevoked,
"revokedAt": time.Now().UnixMilli(),
"reason": "manual_revoke",
})
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to revoke entitlement")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) checkMyEntitlement(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
subject, ok := a.learnerSubjectFromContext(r)
if claims == nil || !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "entitlement key is required")
return
}
tenantID := strings.TrimSpace(resolveClaimsTenantID(claims))
allowed, err := a.hasActiveEntitlementScoped(subject, key, time.Now().UTC(), tenantID, tenantID != "")
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check entitlement")
return
}
result := Doc{
"key": key,
"allowed": allowed,
"scope": entitlementScopeFromClaims(r),
"tenantId": tenantID,
}
if !allowed {
result["reason"] = "missing_entitlement"
result["message"] = "Your entitlement is missing or inactive for this resource."
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) activateMyTrialEntitlement(w http.ResponseWriter, r *http.Request) {
a.activateMyPromotionalEntitlement(w, r, entitlementStatusTrial, "trial")
}
func (a *app) activateMyFreeEntitlement(w http.ResponseWriter, r *http.Request) {
a.activateMyPromotionalEntitlement(w, r, entitlementStatusActive, "free_account")
}
func (a *app) activateMyPromotionalEntitlement(w http.ResponseWriter, r *http.Request, status, source string) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
subject, ok := a.learnerSubjectFromContext(r)
if claims == nil || !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
key := strings.TrimSpace(chi.URLParam(r, "key"))
if key == "" {
writeError(w, http.StatusBadRequest, "entitlement key is required")
return
}
days := a.cfg.DefaultTrialDays
if source == "free_account" {
days = a.cfg.DefaultFreeDays
}
if days <= 0 {
days = 1
}
now := time.Now().UTC()
scope := entitlementScopeFromClaims(r)
tenantID := strings.TrimSpace(resolveClaimsTenantID(claims))
filter := Doc{"userId": subject, "key": key}
if scope == entitlementScopeTenant && tenantID != "" {
filter["tenantId"] = tenantID
}
existing, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate existing entitlement")
return
}
if len(existing) > 0 && isEntitlementActive(existing[0], now) {
writeJSON(w, http.StatusOK, existing[0])
return
}
doc, err := prepareEntitlementPayload(Doc{
"userId": subject,
"key": key,
"status": status,
"source": source,
"startsAt": now.UnixMilli(),
"endsAt": now.Add(time.Duration(days) * 24 * time.Hour).UnixMilli(),
"initiatedByUserId": subject,
"reason": source + "_activation",
}, false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc = applyEntitlementScopeDefaults(doc, scope, tenantID)
if source == "free_account" && len(existing) > 0 {
previousStatus, _ := asString(existing[0]["status"])
if strings.EqualFold(previousStatus, entitlementStatusTrial) {
doc["convertedFromTrialAt"] = now.UnixMilli()
doc["convertedFromEntitlementId"] = stringValue(existing[0]["id"])
}
}
created, err := a.create("entitlements", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to activate entitlement")
return
}
writeJSON(w, http.StatusCreated, created)
}
func prepareEntitlementPayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
userID, hasUserID, err := getOptionalString(payload, "userId")
if err != nil {
return nil, errors.New("userId must be a string")
}
if !partial && (!hasUserID || userID == "") {
return nil, errors.New("userId is required")
}
if hasUserID {
payload["userId"] = userID
}
key, hasKey, err := getOptionalString(payload, "key")
if err != nil {
return nil, errors.New("key must be a string")
}
if !partial && (!hasKey || key == "") {
return nil, errors.New("key is required")
}
if hasKey {
payload["key"] = key
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = entitlementStatusActive
hasStatus = true
}
if hasStatus {
normalizedStatus := strings.ToLower(strings.TrimSpace(status))
switch normalizedStatus {
case entitlementStatusActive, entitlementStatusTrial, entitlementStatusGrace, entitlementStatusRevoked, entitlementStatusExpired:
payload["status"] = normalizedStatus
default:
return nil, errors.New("status must be one of: active, trial, grace, revoked, expired")
}
}
source, hasSource, err := getOptionalString(payload, "source")
if err != nil {
return nil, errors.New("source must be a string")
}
if !partial && !hasSource {
source = "manual_override"
hasSource = true
}
if hasSource {
payload["source"] = strings.ToLower(strings.TrimSpace(source))
}
scope, hasScope, err := getOptionalString(payload, "scope")
if err != nil {
return nil, errors.New("scope must be a string")
}
if hasScope {
normalizedScope := strings.ToLower(strings.TrimSpace(scope))
switch normalizedScope {
case entitlementScopeUser, entitlementScopeTenant, entitlementScopePlatform:
payload["scope"] = normalizedScope
default:
return nil, errors.New("scope must be one of: user, tenant, platform")
}
}
tenantID, hasTenantID, err := getOptionalString(payload, "tenantId")
if err != nil {
return nil, errors.New("tenantId must be a string")
}
if hasTenantID {
payload["tenantId"] = strings.TrimSpace(tenantID)
}
platformAccountID, hasPlatformAccountID, err := getOptionalString(payload, "platformAccountId")
if err != nil {
return nil, errors.New("platformAccountId must be a string")
}
if hasPlatformAccountID {
payload["platformAccountId"] = strings.TrimSpace(platformAccountID)
}
startsAt, hasStartsAt, err := parseOptionalTime(payload["startsAt"])
if err != nil {
return nil, errors.New("startsAt must be a unix milliseconds number or RFC3339 string")
}
if hasStartsAt {
payload["startsAt"] = startsAt.UnixMilli()
}
endsAt, hasEndsAt, err := parseOptionalTime(payload["endsAt"])
if err != nil {
return nil, errors.New("endsAt must be a unix milliseconds number or RFC3339 string")
}
if hasEndsAt {
payload["endsAt"] = endsAt.UnixMilli()
}
if hasStartsAt && hasEndsAt && endsAt.Before(startsAt) {
return nil, errors.New("endsAt must be greater than or equal to startsAt")
}
revokedAt, hasRevokedAt, err := parseOptionalTime(payload["revokedAt"])
if err != nil {
return nil, errors.New("revokedAt must be a unix milliseconds number or RFC3339 string")
}
if hasRevokedAt {
payload["revokedAt"] = revokedAt.UnixMilli()
}
if metadata, exists := payload["metadata"]; exists && metadata != nil {
value, ok := asMap(metadata)
if !ok {
return nil, errors.New("metadata must be an object")
}
payload["metadata"] = Doc(value)
}
if statusRaw, has := payload["status"]; has {
statusValue, _ := statusRaw.(string)
if statusValue == entitlementStatusRevoked && !hasRevokedAt {
payload["revokedAt"] = time.Now().UnixMilli()
}
}
scopeValue, _ := asString(payload["scope"])
switch strings.TrimSpace(scopeValue) {
case entitlementScopeTenant:
if strings.TrimSpace(anyToString(payload["tenantId"])) == "" {
return nil, errors.New("tenantId is required when scope is tenant")
}
case entitlementScopePlatform:
if strings.TrimSpace(anyToString(payload["platformAccountId"])) == "" {
payload["platformAccountId"] = "platform"
}
}
return payload, nil
}
func isEntitlementActive(doc Doc, now time.Time) bool {
status, _ := asString(doc["status"])
if _, ok := entitlementActiveStatuses[strings.ToLower(strings.TrimSpace(status))]; !ok {
return false
}
if startsAt, ok := toTime(doc["startsAt"]); ok && now.Before(startsAt.UTC()) {
return false
}
if endsAt, ok := toTime(doc["endsAt"]); ok && now.After(endsAt.UTC()) {
return false
}
if revokedAt, ok := toTime(doc["revokedAt"]); ok && !revokedAt.IsZero() {
return false
}
return true
}
func (a *app) hasActiveEntitlement(userID, key string, now time.Time) (bool, error) {
return a.hasActiveEntitlementScoped(userID, key, now, "", false)
}
func (a *app) hasActiveEntitlementScoped(userID, key string, now time.Time, tenantID string, enforceTenant bool) (bool, error) {
if a.db == nil {
return false, errors.New("database not configured")
}
if strings.TrimSpace(userID) == "" || strings.TrimSpace(key) == "" {
return false, nil
}
filter := Doc{"userId": strings.TrimSpace(userID), "key": strings.TrimSpace(key)}
if enforceTenant {
filter["tenantId"] = strings.TrimSpace(tenantID)
}
docs, err := a.list("entitlements", filter, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
return false, err
}
now = now.UTC()
for _, doc := range docs {
if isEntitlementActive(doc, now) {
return true, nil
}
}
return false, nil
}
func (a *app) hasRequiredEntitlement(userID, requiredKey string, enrollment Doc) (bool, error) {
required := strings.TrimSpace(requiredKey)
if required == "" {
return true, nil
}
return a.hasActiveEntitlement(userID, required, time.Now().UTC())
}
func (a *app) learnerEntitlementDecision(r *http.Request, userID, requiredKey string) (bool, string, string, error) {
required := strings.TrimSpace(requiredKey)
if required == "" {
return true, "", "", nil
}
tenantID, enforceTenant := entitlementTenantScopeFromRequest(r)
allowed, err := a.hasActiveEntitlementScoped(userID, required, time.Now().UTC(), tenantID, enforceTenant)
if err != nil {
return false, "entitlement_check_failed", "Failed to verify entitlement for this resource.", err
}
if allowed {
return true, "", "", nil
}
return false, "missing_entitlement", "Your entitlement is missing or inactive for this resource.", nil
}
func entitlementTenantScopeFromRequest(r *http.Request) (string, bool) {
if r == nil {
return "", false
}
tenantCtx, _ := r.Context().Value(tenantContextKey).(tenantContext)
if tenantCtx.BypassScoping && !tenantCtx.RequestedByOps {
return "", false
}
tenantID := strings.TrimSpace(tenantCtx.ID)
if tenantID == "" {
return "", false
}
return tenantID, true
}
func entitlementScopeFromClaims(r *http.Request) string {
if r == nil {
return entitlementScopeUser
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
return entitlementScopeUser
}
tenantID := strings.TrimSpace(resolveClaimsTenantID(claims))
if tenantID != "" {
return entitlementScopeTenant
}
return entitlementScopeUser
}
func applyEntitlementScopeDefaults(doc Doc, scope string, tenantID ...string) Doc {
if doc == nil {
doc = Doc{}
}
selectedScope := strings.TrimSpace(strings.ToLower(scope))
if selectedScope == "" {
selectedScope = entitlementScopeUser
}
doc["scope"] = selectedScope
tenant := ""
if len(tenantID) > 0 {
tenant = strings.TrimSpace(tenantID[0])
}
if selectedScope == entitlementScopeTenant {
if tenant == "" {
tenant = strings.TrimSpace(anyToString(doc["tenantId"]))
}
doc["tenantId"] = tenant
}
if selectedScope == entitlementScopePlatform {
doc["platformAccountId"] = "platform"
}
return doc
}

561
Backend/cmd/api/forms.go Normal file
View File

@ -0,0 +1,561 @@
package main
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
formStatusDraft = "draft"
formStatusPublished = "published"
)
var formPublishingPolicyInstance = formPublishingPolicy{}
type formPublishingPolicy struct{}
var allowedFormFieldTypes = map[string]struct{}{
"text": {},
"textarea": {},
"email": {},
"number": {},
"select": {},
"checkbox": {},
}
func (a *app) formsAdminMiddleware(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 form management")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listForms(w http.ResponseWriter, r *http.Request) {
a.listCollection("forms", w)
}
func (a *app) createForm(w http.ResponseWriter, r *http.Request) {
var payload formUpsertRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareFormPayload(payload.Doc(), false, nil)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("forms", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create resource")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getForm(w http.ResponseWriter, r *http.Request) {
a.getCollection("forms", w, r)
}
func (a *app) patchForm(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var payload formUpsertRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
existing, err := a.getByID("forms", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load resource")
return
}
patch, err := prepareFormPayload(payload.Doc(), true, existing)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err := a.patch("forms", id, patch)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update resource")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteForm(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("forms", w, r)
}
func (a *app) listFormSubmissions(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
formID := strings.TrimSpace(chi.URLParam(r, "id"))
if formID == "" {
writeError(w, http.StatusBadRequest, "form id is required")
return
}
docs, err := a.list("form_submissions", Doc{"formId": formID}, Sort{{Key: "submittedAt", Value: -1}, {Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list form submissions")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) getPublicForm(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
formID := strings.TrimSpace(chi.URLParam(r, "id"))
if formID == "" {
writeError(w, http.StatusBadRequest, "form id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
var form Doc
if err := a.db.Collection("forms").FindOne(ctx, Doc{"id": formID}).Decode(&form); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "form not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load form")
return
}
normalizeDocument(form)
status, _ := asString(form["status"])
if status != formStatusPublished {
writeError(w, http.StatusNotFound, "form not found")
return
}
schema, _, err := normalizeFormSchema(form["schema"])
if err != nil {
writeError(w, http.StatusInternalServerError, "form schema is invalid")
return
}
writeJSON(w, http.StatusOK, publicFormResponse{
ID: stringValue(form["id"]),
Name: stringValue(form["name"]),
Description: stringValue(form["description"]),
Schema: schema,
})
}
func (a *app) createPublicFormSubmission(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
formID := strings.TrimSpace(chi.URLParam(r, "id"))
if formID == "" {
writeError(w, http.StatusBadRequest, "form id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
var form Doc
if err := a.db.Collection("forms").FindOne(ctx, Doc{"id": formID}).Decode(&form); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "form not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load form")
return
}
normalizeDocument(form)
status, _ := asString(form["status"])
if status != formStatusPublished {
writeError(w, http.StatusNotFound, "form not found")
return
}
schema, _, err := normalizeFormSchema(form["schema"])
if err != nil {
writeError(w, http.StatusInternalServerError, "form schema is invalid")
return
}
var payload struct {
Values map[string]any `json:"values"`
}
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
if payload.Values == nil {
payload.Values = map[string]any{}
}
normalizedValues, err := validateFormSubmission(schema, payload.Values)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
submission := Doc{
"formId": formID,
"values": normalizedValues,
"submittedAt": time.Now().UnixMilli(),
}
submission, err = a.create("form_submissions", submission)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to store form submission")
return
}
submittedAt := int64(0)
switch value := submission["submittedAt"].(type) {
case int64:
submittedAt = value
case int32:
submittedAt = int64(value)
case int:
submittedAt = int64(value)
case float64:
submittedAt = int64(value)
}
writeJSON(w, http.StatusCreated, publicFormSubmissionCreateResponse{
ID: stringValue(submission["id"]),
FormID: stringValue(submission["formId"]),
SubmittedAt: submittedAt,
})
}
func prepareFormPayload(payload Doc, partial bool, existing Doc) (Doc, error) {
if payload == nil {
payload = Doc{}
}
name, hasName, err := getOptionalString(payload, "name")
if err != nil {
return nil, errors.New("name must be a string")
}
if !partial && (!hasName || name == "") {
return nil, errors.New("name is required")
}
if hasName {
payload["name"] = name
}
if err := applyPublishingPolicy(payload, partial, existing, formPublishingPolicyInstance); err != nil {
return nil, err
}
description, hasDescription, err := getOptionalString(payload, "description")
if err != nil {
return nil, errors.New("description must be a string")
}
if hasDescription {
payload["description"] = description
}
schema, hasSchema, err := normalizeFormSchema(payload["schema"])
if err != nil {
return nil, err
}
if hasSchema {
payload["schema"] = schema
}
return payload, nil
}
func (formPublishingPolicy) defaultStatus() string {
return formStatusDraft
}
func (formPublishingPolicy) isAllowedStatus(status string) bool {
return status == formStatusDraft || status == formStatusPublished
}
func (formPublishingPolicy) invalidStatusMessage() string {
return "status must be one of: draft, published"
}
func (formPublishingPolicy) validate(payload Doc, existing Doc) error {
status, ok := mergedPublishingString(payload, existing, "status")
if !ok || status != formStatusPublished {
return nil
}
schemaValues, has := mergedPublishingValue(payload, existing, "schema")
if !has {
return errors.New("schema is required when status is published")
}
normalized, _, err := normalizeFormSchema(schemaValues)
if err != nil {
return err
}
if len(normalized) == 0 {
return errors.New("schema is required when status is published")
}
return nil
}
func normalizeFormSchema(raw any) ([]Doc, bool, error) {
if raw == nil {
return nil, false, nil
}
var values []any
switch source := raw.(type) {
case []any:
values = source
case List:
values = []any(source)
case []Doc:
values = make([]any, 0, len(source))
for _, field := range source {
values = append(values, field)
}
case []map[string]any:
values = make([]any, 0, len(source))
for _, field := range source {
values = append(values, field)
}
default:
return nil, false, errors.New("schema must be an array")
}
normalized := make([]Doc, 0, len(values))
for _, item := range values {
fieldMap, ok := item.(map[string]any)
if !ok {
bsonField, docOK := item.(Doc)
if !docOK {
return nil, false, errors.New("schema fields must be objects")
}
fieldMap = map[string]any(bsonField)
}
nameRaw, ok := fieldMap["name"]
if !ok {
return nil, false, errors.New("schema field name is required")
}
name, ok := nameRaw.(string)
if !ok || strings.TrimSpace(name) == "" {
return nil, false, errors.New("schema field name must be a string")
}
name = strings.TrimSpace(name)
typeRaw, ok := fieldMap["type"]
if !ok {
return nil, false, errors.New("schema field type is required")
}
fieldType, ok := typeRaw.(string)
if !ok {
return nil, false, errors.New("schema field type must be a string")
}
fieldType = strings.ToLower(strings.TrimSpace(fieldType))
if _, valid := allowedFormFieldTypes[fieldType]; !valid {
return nil, false, errors.New("schema field type is invalid")
}
normalizedField := Doc{
"name": name,
"type": fieldType,
}
if labelRaw, has := fieldMap["label"]; has {
label, ok := labelRaw.(string)
if !ok {
return nil, false, errors.New("schema field label must be a string")
}
normalizedField["label"] = strings.TrimSpace(label)
}
if requiredRaw, has := fieldMap["required"]; has {
required, ok := requiredRaw.(bool)
if !ok {
return nil, false, errors.New("schema field required must be a boolean")
}
normalizedField["required"] = required
} else {
normalizedField["required"] = false
}
if optionsRaw, has := fieldMap["options"]; has {
options, _, err := normalizeStringSlice(optionsRaw)
if err != nil {
return nil, false, errors.New("schema field options must be an array of strings")
}
normalizedField["options"] = options
}
normalized = append(normalized, normalizedField)
}
return normalized, true, nil
}
func validateFormSubmission(schema []Doc, values map[string]any) (Doc, error) {
schemaByName := map[string]Doc{}
for _, field := range schema {
name, _ := field["name"].(string)
schemaByName[name] = field
}
normalized := Doc{}
for key, value := range values {
field, exists := schemaByName[key]
if !exists {
return nil, errors.New("unknown field: " + key)
}
fieldType, _ := field["type"].(string)
normalizedValue, err := normalizeSubmissionValue(fieldType, value)
if err != nil {
return nil, errors.New(key + ": " + err.Error())
}
if fieldType == "select" {
if err := validateSelectSubmissionOption(field, normalizedValue); err != nil {
return nil, errors.New(key + ": " + err.Error())
}
}
normalized[key] = normalizedValue
}
for _, field := range schema {
name, _ := field["name"].(string)
required, _ := field["required"].(bool)
if !required {
continue
}
value, exists := normalized[name]
if !exists || isEmptySubmissionValue(value) {
return nil, errors.New("missing required field: " + name)
}
}
return normalized, nil
}
func validateSelectSubmissionOption(field Doc, value any) error {
selected, ok := value.(string)
if !ok {
return errors.New("must be a string")
}
optionsRaw, hasOptions := field["options"]
if !hasOptions || optionsRaw == nil {
return nil
}
options, hasNormalizedOptions, err := normalizeStringSlice(optionsRaw)
if err != nil {
return errors.New("select options are invalid")
}
if !hasNormalizedOptions {
return nil
}
normalizedSelected := strings.ToLower(strings.TrimSpace(selected))
for _, option := range options {
if strings.ToLower(strings.TrimSpace(option)) == normalizedSelected {
return nil
}
}
return errors.New("must be one of the configured options")
}
func normalizeSubmissionValue(fieldType string, value any) (any, error) {
switch fieldType {
case "text", "textarea", "email", "select":
text, ok := value.(string)
if !ok {
return nil, errors.New("must be a string")
}
trimmed := strings.TrimSpace(text)
if fieldType == "email" && trimmed != "" && !strings.Contains(trimmed, "@") {
return nil, errors.New("must be a valid email")
}
return trimmed, nil
case "number":
switch typed := value.(type) {
case float64:
return typed, nil
case float32:
return float64(typed), nil
case int:
return float64(typed), nil
case int64:
return float64(typed), nil
case string:
if strings.TrimSpace(typed) == "" {
return "", nil
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
if err != nil {
return nil, errors.New("must be a number")
}
return parsed, nil
default:
return nil, errors.New("must be a number")
}
case "checkbox":
boolean, ok := value.(bool)
if !ok {
return nil, errors.New("must be a boolean")
}
return boolean, nil
default:
return nil, errors.New("unsupported field type")
}
}
func isEmptySubmissionValue(value any) bool {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed) == ""
default:
return value == nil
}
}

View File

@ -0,0 +1,25 @@
package main
import (
"encoding/json"
"log"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Printf("failed to encode json response: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func decodeJSON(r *http.Request, out any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(out)
}

View File

@ -0,0 +1,225 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
themeDeployConfirmation = "DEPLOY"
themeDeployStatusSuccess = "success"
themeDeployStatusFailed = "failed"
)
type keycloakThemeDeploymentRequest struct {
Summary string `json:"summary"`
Confirmation string `json:"confirmation"`
}
func (a *app) deployKeycloakTheme(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if !hasPermission(claims, permKeycloakThemesDeploy) {
writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.deploy")
return
}
if !a.cfg.KeycloakThemeDeployEnabled {
writeError(w, http.StatusServiceUnavailable, "theme deployment is disabled")
return
}
themeID := strings.TrimSpace(chi.URLParam(r, "id"))
if themeID == "" {
writeError(w, http.StatusBadRequest, "id is required")
return
}
var payload keycloakThemeDeploymentRequest
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
if err := validateThemeDeploymentRequest(payload); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
theme, err := a.getByID("keycloak_themes", themeID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load keycloak theme")
return
}
status, _ := theme["status"].(string)
if strings.TrimSpace(strings.ToLower(status)) != keycloakThemeStatusPublished {
writeError(w, http.StatusBadRequest, "only published themes can be deployed")
return
}
actor := resolveThemeActor(claims)
deploymentKey := keycloakThemeDeploymentKey(theme)
current, err := a.getCurrentThemeDeployment(deploymentKey)
if err != nil {
a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusFailed, nil, "failed to resolve deployment state")
writeError(w, http.StatusInternalServerError, "failed to deploy keycloak theme")
return
}
rollback := buildThemeDeploymentRollback(current)
if err := a.upsertCurrentThemeDeployment(deploymentKey, theme, actor, rollback); err != nil {
a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusFailed, rollback, "failed to persist deployment state")
writeError(w, http.StatusInternalServerError, "failed to deploy keycloak theme")
return
}
a.recordThemeDeploymentAudit(theme, payload.Summary, actor, themeDeployStatusSuccess, rollback, "")
writeJSON(w, http.StatusOK, Doc{
"status": "deployed",
"themeId": strings.TrimSpace(themeID),
"rollbackThemeId": rollback["themeId"],
"rollbackVersion": rollback["version"],
})
}
func validateThemeDeploymentRequest(payload keycloakThemeDeploymentRequest) error {
if strings.TrimSpace(payload.Summary) == "" {
return errors.New("deployment summary is required")
}
if strings.TrimSpace(strings.ToUpper(payload.Confirmation)) != themeDeployConfirmation {
return errors.New("deployment confirmation must be DEPLOY")
}
return nil
}
func resolveThemeActor(claims *accessClaims) string {
if claims == nil {
return "unknown"
}
if value := strings.TrimSpace(claims.PreferredUsername); value != "" {
return value
}
if value := strings.TrimSpace(claims.Email); value != "" {
return value
}
if value := strings.TrimSpace(claims.Sub); value != "" {
return value
}
return "unknown"
}
func keycloakThemeDeploymentKey(theme Doc) string {
target, _ := theme["target"].(Doc)
realm, _ := target["realm"].(string)
client, _ := target["client"].(string)
organizationID, _ := target["organizationId"].(string)
themeType, _ := theme["type"].(string)
scope, _ := theme["scope"].(string)
parts := []string{
"realm=" + strings.TrimSpace(realm),
"client=" + strings.TrimSpace(client),
"scope=" + strings.TrimSpace(scope),
"organization=" + strings.TrimSpace(organizationID),
"type=" + strings.TrimSpace(themeType),
}
return strings.Join(parts, "|")
}
func buildThemeDeploymentRollback(current Doc) Doc {
if current == nil {
return Doc{}
}
rollback := Doc{}
if themeID, ok := current["themeId"].(string); ok && strings.TrimSpace(themeID) != "" {
rollback["themeId"] = strings.TrimSpace(themeID)
}
if version, ok := current["version"].(string); ok && strings.TrimSpace(version) != "" {
rollback["version"] = strings.TrimSpace(version)
}
if len(rollback) == 0 {
return Doc{}
}
return rollback
}
func (a *app) getCurrentThemeDeployment(deploymentKey string) (Doc, error) {
entries, err := a.list("keycloak_theme_deployments_current", Doc{"deploymentKey": deploymentKey}, nil)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
return entries[0], nil
}
func (a *app) upsertCurrentThemeDeployment(deploymentKey string, theme Doc, actor string, rollback Doc) error {
target, _ := theme["target"].(Doc)
now := time.Now().UnixMilli()
doc := Doc{
"deploymentKey": deploymentKey,
"themeId": strings.TrimSpace(anyToString(theme["id"])),
"version": strings.TrimSpace(anyToString(theme["version"])),
"scope": strings.TrimSpace(anyToString(theme["scope"])),
"type": strings.TrimSpace(anyToString(theme["type"])),
"realm": strings.TrimSpace(anyToString(target["realm"])),
"client": strings.TrimSpace(anyToString(target["client"])),
"organizationId": strings.TrimSpace(anyToString(target["organizationId"])),
"deployedBy": strings.TrimSpace(actor),
"deployedAt": now,
"rollback": rollback,
}
current, err := a.getCurrentThemeDeployment(deploymentKey)
if err != nil {
return err
}
if current == nil {
_, err = a.create("keycloak_theme_deployments_current", doc)
return err
}
_, err = a.patch("keycloak_theme_deployments_current", strings.TrimSpace(anyToString(current["id"])), doc)
return err
}
func (a *app) recordThemeDeploymentAudit(theme Doc, summary, actor, status string, rollback Doc, errorMessage string) {
audit := buildThemeDeploymentAuditDoc(theme, summary, actor, status, rollback, errorMessage)
_, _ = a.create("keycloak_theme_deployments_audit", audit)
}
func buildThemeDeploymentAuditDoc(theme Doc, summary, actor, status string, rollback Doc, errorMessage string) Doc {
audit := Doc{
"themeId": strings.TrimSpace(anyToString(theme["id"])),
"themeSlug": strings.TrimSpace(anyToString(theme["slug"])),
"version": strings.TrimSpace(anyToString(theme["version"])),
"scope": strings.TrimSpace(anyToString(theme["scope"])),
"type": strings.TrimSpace(anyToString(theme["type"])),
"status": strings.TrimSpace(status),
"summary": strings.TrimSpace(summary),
"actor": strings.TrimSpace(actor),
"rollback": rollback,
}
if errorMessage != "" {
audit["errorMessage"] = strings.TrimSpace(errorMessage)
}
return audit
}
func anyToString(value any) string {
if value == nil {
return ""
}
if raw, ok := value.(string); ok {
return raw
}
return ""
}

View File

@ -0,0 +1,274 @@
package main
import (
"errors"
"regexp"
"strconv"
"strings"
)
type keycloakThemeField struct {
Name string
Type string
Required bool
Options []string
Min float64
Max float64
}
var keycloakThemeColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
var keycloakThemeFields = []keycloakThemeField{
{Name: "logoAssetId", Type: "image"},
{Name: "faviconAssetId", Type: "image"},
{Name: "logoAssetMimeType", Type: "text"},
{Name: "faviconAssetMimeType", Type: "text"},
{Name: "primaryColor", Type: "color", Required: true},
{Name: "secondaryColor", Type: "color"},
{Name: "backgroundColor", Type: "color"},
{Name: "textColor", Type: "color"},
{Name: "buttonRadius", Type: "number", Min: 0, Max: 32},
{Name: "fontFamily", Type: "select", Options: []string{"Inter", "System UI", "Roboto", "Open Sans", "Lato", "Montserrat"}},
{Name: "loginHeading", Type: "text"},
{Name: "loginSubheading", Type: "textarea"},
{Name: "loginHelpText", Type: "textarea"},
{Name: "registrationHeading", Type: "text"},
{Name: "forgotPasswordHelpText", Type: "textarea"},
{Name: "accountConsoleHeading", Type: "text"},
{Name: "emailTemplateFooter", Type: "textarea"},
{Name: "supportUrl", Type: "url"},
{Name: "privacyUrl", Type: "url"},
{Name: "termsUrl", Type: "url"},
{Name: "footerText", Type: "textarea"},
{Name: "securityText", Type: "textarea"},
}
var keycloakThemeFieldNames = func() map[string]struct{} {
fields := map[string]struct{}{}
for _, field := range keycloakThemeFields {
fields[field.Name] = struct{}{}
}
return fields
}()
var keycloakThemeAllowedSystemMetadata = map[string]struct{}{
"updatedBy": {},
"updatedAt": {},
"createdBy": {},
"createdAt": {},
"platformApproval": {},
"exportRequested": {},
}
var keycloakThemeDefaultFormValues = Doc{
"primaryColor": "#4f46e5",
"backgroundColor": "#ffffff",
"textColor": "#0f172a",
"fontFamily": "Inter",
"buttonRadius": 8,
"loginHeading": "Welcome back",
"registrationHeading": "Create your account",
"footerText": "Powered by CMS Platform",
"securityText": "Never share your password with anyone.",
}
func keycloakThemeFormSchemaSections() []Doc {
return []Doc{
{
"id": "brand",
"title": "Brand",
"fields": []Doc{
{"type": "image", "name": "logoAssetId", "label": "Logo", "required": false},
{"type": "image", "name": "faviconAssetId", "label": "Favicon", "required": false},
{"type": "text", "name": "logoAssetMimeType", "label": "Logo MIME type", "required": false},
{"type": "text", "name": "faviconAssetMimeType", "label": "Favicon MIME type", "required": false},
{"type": "color", "name": "primaryColor", "label": "Primary colour", "required": true},
{"type": "color", "name": "secondaryColor", "label": "Secondary colour", "required": false},
{"type": "color", "name": "backgroundColor", "label": "Background colour", "required": false},
{"type": "color", "name": "textColor", "label": "Text colour", "required": false},
{"type": "select", "name": "fontFamily", "label": "Font family", "required": false, "options": []string{"Inter", "System UI", "Roboto", "Open Sans", "Lato", "Montserrat"}},
{"type": "number", "name": "buttonRadius", "label": "Button radius", "required": false},
},
},
{
"id": "authCopy",
"title": "Authentication copy",
"fields": []Doc{
{"type": "text", "name": "loginHeading", "label": "Login heading", "required": false},
{"type": "textarea", "name": "loginSubheading", "label": "Login subheading", "required": false},
{"type": "textarea", "name": "loginHelpText", "label": "Login help text", "required": false},
{"type": "text", "name": "registrationHeading", "label": "Registration heading", "required": false},
{"type": "textarea", "name": "forgotPasswordHelpText", "label": "Forgot password help", "required": false},
},
},
{
"id": "accountAndEmail",
"title": "Account and email templates",
"fields": []Doc{
{"type": "text", "name": "accountConsoleHeading", "label": "Account console heading", "required": false},
{"type": "textarea", "name": "emailTemplateFooter", "label": "Email footer text", "required": false},
},
},
{
"id": "linksAndLegal",
"title": "Links and legal",
"fields": []Doc{
{"type": "url", "name": "supportUrl", "label": "Support URL", "required": false},
{"type": "url", "name": "privacyUrl", "label": "Privacy URL", "required": false},
{"type": "url", "name": "termsUrl", "label": "Terms URL", "required": false},
{"type": "textarea", "name": "footerText", "label": "Footer text", "required": false},
{"type": "textarea", "name": "securityText", "label": "Security text", "required": false},
},
},
}
}
func ensureThemeMetadataDefaults(doc Doc) {
metadata := resolveThemeMetadata(nil, doc)
if metadata == nil {
metadata = Doc{}
}
for key, value := range keycloakThemeDefaultFormValues {
if _, exists := metadata[key]; !exists {
metadata[key] = value
}
}
doc["metadata"] = metadata
}
func validateKeycloakThemeFormValues(metadata Doc) error {
if metadata == nil {
metadata = Doc{}
}
fieldMap := map[string]keycloakThemeField{}
for _, field := range keycloakThemeFields {
fieldMap[field.Name] = field
}
for key, value := range metadata {
field, ok := fieldMap[key]
if !ok {
if _, allowed := keycloakThemeAllowedSystemMetadata[key]; allowed {
continue
}
return errors.New("theme schema validation failed: unknown field " + key)
}
if err := validateThemeFieldValue(field, value); err != nil {
return err
}
}
for _, field := range keycloakThemeFields {
if !field.Required {
continue
}
value, exists := metadata[field.Name]
if !exists || isEmptyThemeValue(value) {
return errors.New("theme schema validation failed: required field " + field.Name + " is missing")
}
}
return nil
}
func validateThemeFieldValue(field keycloakThemeField, value any) error {
if isEmptyThemeValue(value) {
if field.Required {
return errors.New("theme schema validation failed: required field " + field.Name + " is missing")
}
return nil
}
switch field.Type {
case "text", "textarea", "image":
str, ok := value.(string)
if !ok {
return errors.New("theme schema validation failed: field " + field.Name + " must be a string")
}
trimmed := strings.TrimSpace(str)
if field.Type == "image" && strings.Contains(trimmed, " ") {
return errors.New("theme schema validation failed: image field " + field.Name + " must not contain spaces")
}
case "color":
str, ok := value.(string)
if !ok || !keycloakThemeColorPattern.MatchString(strings.TrimSpace(str)) {
return errors.New("theme schema validation failed: field " + field.Name + " must be a hex color")
}
case "url":
str, ok := value.(string)
if !ok {
return errors.New("theme schema validation failed: field " + field.Name + " must be a string")
}
if strings.TrimSpace(str) != "" {
if err := validateThemeURL(str); err != nil {
return errors.New("theme schema validation failed: field " + field.Name + " must be a valid URL")
}
}
case "number":
number, ok := normalizeThemeNumber(value)
if !ok {
return errors.New("theme schema validation failed: field " + field.Name + " must be a number")
}
if field.Min != 0 || field.Max != 0 {
if number < field.Min || number > field.Max {
return errors.New("theme schema validation failed: field " + field.Name + " is out of range")
}
}
case "select":
str, ok := value.(string)
if !ok {
return errors.New("theme schema validation failed: field " + field.Name + " must be a string")
}
if len(field.Options) == 0 {
return nil
}
trimmed := strings.TrimSpace(str)
for _, option := range field.Options {
if option == trimmed {
return nil
}
}
return errors.New("theme schema validation failed: field " + field.Name + " has an unsupported value")
}
return nil
}
func normalizeThemeNumber(value any) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return 0, false
}
return parsed, true
default:
return 0, false
}
}
func isEmptyThemeValue(value any) bool {
if value == nil {
return true
}
if str, ok := value.(string); ok {
return strings.TrimSpace(str) == ""
}
return false
}
func isKeycloakThemeSchemaField(name string) bool {
_, exists := keycloakThemeFieldNames[name]
return exists
}

View File

@ -0,0 +1,286 @@
package main
import (
"errors"
"net/url"
"strconv"
"strings"
)
var allowedThemeFontFamilies = map[string]struct{}{
"inter": {},
"system ui": {},
"roboto": {},
"open sans": {},
"lato": {},
"montserrat": {},
}
var allowedThemeAssetMimeTypes = map[string]struct{}{
"image/png": {},
"image/jpeg": {},
"image/webp": {},
"image/svg+xml": {},
"image/x-icon": {},
"image/vnd.microsoft.icon": {},
}
func validateKeycloakThemePolicy(existing, patch Doc, claims *accessClaims) error {
scope := resolveThemeString(existing, patch, "scope", keycloakThemeScopePlatform)
status := resolveThemeString(existing, patch, "status", keycloakThemeStatusDraft)
metadata := resolveThemeMetadata(existing, patch)
if hasUnsafeThemeValue(metadata) {
return errors.New("theme policy violation: unsafe html or javascript content detected")
}
if err := validateThemeURLFields(metadata); err != nil {
return err
}
if err := validateThemeFontAndAssetPolicy(metadata); err != nil {
return err
}
requiresPublishLevelChecks := status == keycloakThemeStatusPublished || isThemeExportRequested(existing, patch, metadata)
if !requiresPublishLevelChecks {
return nil
}
if err := validateThemeLegalAndSecurityRequirements(metadata); err != nil {
return err
}
if scope == keycloakThemeScopeOrganization {
if err := validateOrganizationThemePublicationApproval(claims, metadata); err != nil {
return err
}
}
return nil
}
func resolveThemeString(existing, patch Doc, key, fallback string) string {
if value, ok := patch[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(strings.ToLower(value))
}
if value, ok := existing[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(strings.ToLower(value))
}
return fallback
}
func resolveThemeMetadata(existing, patch Doc) Doc {
if value, ok := patch["metadata"]; ok {
metadata, _ := normalizeThemeMetadata(value)
if metadata != nil {
return metadata
}
}
if value, ok := existing["metadata"]; ok {
metadata, _ := normalizeThemeMetadata(value)
if metadata != nil {
return metadata
}
}
return Doc{}
}
func hasUnsafeThemeValue(value any) bool {
switch v := value.(type) {
case string:
normalized := strings.ToLower(strings.TrimSpace(v))
unsafeTokens := []string{"<script", "</script", "javascript:", "onerror=", "onload=", "<iframe", "srcdoc=", "data:text/html", "vbscript:"}
for _, token := range unsafeTokens {
if strings.Contains(normalized, token) {
return true
}
}
return false
case Doc:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case map[string]any:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case List:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case []any:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
case []string:
for _, child := range v {
if hasUnsafeThemeValue(child) {
return true
}
}
return false
default:
return false
}
}
func validateThemeURLFields(metadata Doc) error {
for _, key := range []string{"supportUrl", "privacyUrl", "termsUrl"} {
value := extractThemeString(metadata, key)
if value == "" {
continue
}
if err := validateThemeURL(value); err != nil {
return errors.New("theme policy violation: " + key + " is invalid")
}
}
return nil
}
func validateThemeURL(raw string) error {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return errors.New("unsupported scheme")
}
if strings.TrimSpace(parsed.Host) == "" {
return errors.New("missing host")
}
return nil
}
func validateThemeFontAndAssetPolicy(metadata Doc) error {
font := strings.ToLower(extractThemeString(metadata, "fontFamily"))
if font != "" {
if _, ok := allowedThemeFontFamilies[font]; !ok {
return errors.New("theme policy violation: fontFamily is not allowed")
}
}
for _, key := range []string{"logoAssetMimeType", "faviconAssetMimeType"} {
mimeType := strings.ToLower(extractThemeString(metadata, key))
if mimeType == "" {
continue
}
if _, ok := allowedThemeAssetMimeTypes[mimeType]; !ok {
return errors.New("theme policy violation: unsupported asset mime type")
}
}
return nil
}
func validateThemeLegalAndSecurityRequirements(metadata Doc) error {
privacyURL := extractThemeString(metadata, "privacyUrl")
termsURL := extractThemeString(metadata, "termsUrl")
if privacyURL == "" || termsURL == "" {
return errors.New("theme policy violation: privacyUrl and termsUrl are required for publish/export")
}
if err := validateThemeURL(privacyURL); err != nil {
return errors.New("theme policy violation: privacyUrl is invalid")
}
if err := validateThemeURL(termsURL); err != nil {
return errors.New("theme policy violation: termsUrl is invalid")
}
footerText := extractThemeString(metadata, "footerText")
securityText := extractThemeString(metadata, "securityText")
if footerText == "" || securityText == "" {
return errors.New("theme policy violation: footerText and securityText are required for publish/export")
}
return nil
}
func validateOrganizationThemePublicationApproval(claims *accessClaims, metadata Doc) error {
approvalValue, ok := metadata["platformApproval"]
if !ok {
return errors.New("theme policy violation: organisation theme publication requires platform approval")
}
approval, ok := normalizeThemeMetadata(approvalValue)
if !ok || approval == nil {
return errors.New("theme policy violation: invalid platformApproval payload")
}
approvedBy := extractThemeString(approval, "approvedBy")
approvedAt := extractThemeInt64(approval, "approvedAt")
if approvedBy == "" || approvedAt <= 0 {
return errors.New("theme policy violation: platformApproval.approvedBy and approvedAt are required")
}
if !hasAnyRole(claims, "platform_owner", "platform_admin", "admin") {
return errors.New("theme policy violation: platform approval must be applied by a platform role")
}
if !hasPermission(claims, permKeycloakThemesPolicyManage) {
return errors.New("theme policy violation: missing permission: keycloak_themes.policy.manage")
}
return nil
}
func isThemeExportRequested(existing, patch, metadata Doc) bool {
if value, ok := patch["exportRequested"].(bool); ok && value {
return true
}
if value, ok := existing["exportRequested"].(bool); ok && value {
return true
}
if value, ok := metadata["exportRequested"].(bool); ok && value {
return true
}
return false
}
func extractThemeString(doc Doc, key string) string {
value, ok := doc[key]
if !ok {
return ""
}
str, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(str)
}
func extractThemeInt64(doc Doc, key string) int64 {
value, ok := doc[key]
if !ok {
return 0
}
switch v := value.(type) {
case int:
return int64(v)
case int32:
return int64(v)
case int64:
return v
case float64:
return int64(v)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err == nil {
return parsed
}
}
return 0
}

View File

@ -0,0 +1,258 @@
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)
}

View File

@ -0,0 +1,554 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
const (
keycloakThemeScopePlatform = "platform"
keycloakThemeScopeOrganization = "organization"
keycloakThemeStatusDraft = "draft"
keycloakThemeStatusPublished = "published"
keycloakThemeStatusArchived = "archived"
keycloakThemeTypeLogin = "login"
keycloakThemeTypeAccount = "account"
keycloakThemeTypeAdmin = "admin"
keycloakThemeTypeEmail = "email"
)
func (a *app) keycloakThemeReadMiddleware(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 !hasPermission(claims, permKeycloakThemesRead) {
writeError(w, http.StatusForbidden, "missing permission: keycloak_themes.read")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listPermissionRegistry(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"permissions": keycloakThemePermissionRegistry,
})
}
func (a *app) listKeycloakThemes(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
docs, err := a.list("keycloak_themes", Doc{}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list keycloak themes")
return
}
result := make([]Doc, 0, len(docs))
for _, doc := range docs {
result = append(result, sanitizeThemeDocument(doc, claims))
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) getKeycloakThemeFormSchema(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, Doc{
"sections": keycloakThemeFormSchemaSections(),
})
}
func (a *app) createKeycloakTheme(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareKeycloakThemePayload(Doc(payload), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ensureThemeMetadataDefaults(doc)
stampThemeEditorMetadata(doc, nil, claims)
if err := validateKeycloakThemeFormValues(resolveThemeMetadata(nil, doc)); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateKeycloakThemePolicy(nil, doc, claims); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := authorizeKeycloakThemeCreate(claims, doc); err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
created, err := a.create("keycloak_themes", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create keycloak theme")
return
}
writeJSON(w, http.StatusCreated, sanitizeThemeDocument(created, claims))
}
func (a *app) getKeycloakTheme(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeError(w, http.StatusBadRequest, "id is required")
return
}
doc, err := a.getByID("keycloak_themes", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load keycloak theme")
return
}
writeJSON(w, http.StatusOK, sanitizeThemeDocument(doc, claims))
}
func (a *app) patchKeycloakTheme(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeError(w, http.StatusBadRequest, "id is required")
return
}
existing, err := a.getByID("keycloak_themes", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load keycloak theme")
return
}
var patch map[string]any
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
doc, err := prepareKeycloakThemePayload(Doc(patch), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
stampThemeEditorMetadata(doc, existing, claims)
if err := validateKeycloakThemeFormValues(resolveThemeMetadata(existing, doc)); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := validateKeycloakThemePolicy(existing, doc, claims); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := authorizeKeycloakThemePatch(claims, existing, doc); err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
updated, err := a.patch("keycloak_themes", id, doc)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update keycloak theme")
return
}
writeJSON(w, http.StatusOK, sanitizeThemeDocument(updated, claims))
}
func (a *app) deleteKeycloakTheme(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
writeError(w, http.StatusBadRequest, "id is required")
return
}
existing, err := a.getByID("keycloak_themes", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load keycloak theme")
return
}
if err := authorizeKeycloakThemeDelete(claims, existing); err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
if err := a.deleteByID("keycloak_themes", id); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete keycloak theme")
return
}
w.WriteHeader(http.StatusNoContent)
}
func prepareKeycloakThemePayload(payload Doc, partial bool) (Doc, error) {
doc := Doc{}
if !partial {
if _, ok := payload["status"]; !ok {
doc["status"] = keycloakThemeStatusDraft
}
}
for key, value := range payload {
switch key {
case "name", "slug", "version":
trimmed, ok := value.(string)
if !ok || strings.TrimSpace(trimmed) == "" {
return nil, errors.New(key + " is required")
}
doc[key] = strings.TrimSpace(trimmed)
case "status":
status, ok := value.(string)
if !ok {
return nil, errors.New("status must be a string")
}
status = strings.TrimSpace(strings.ToLower(status))
if status != keycloakThemeStatusDraft && status != keycloakThemeStatusPublished && status != keycloakThemeStatusArchived {
return nil, errors.New("status must be one of: draft, published, archived")
}
doc[key] = status
case "scope":
scope, ok := value.(string)
if !ok {
return nil, errors.New("scope must be a string")
}
scope = strings.TrimSpace(strings.ToLower(scope))
if scope != keycloakThemeScopePlatform && scope != keycloakThemeScopeOrganization {
return nil, errors.New("scope must be one of: platform, organization")
}
doc[key] = scope
case "type":
typeValue, ok := value.(string)
if !ok {
return nil, errors.New("type must be a string")
}
typeValue = strings.TrimSpace(strings.ToLower(typeValue))
if typeValue != keycloakThemeTypeLogin && typeValue != keycloakThemeTypeAccount && typeValue != keycloakThemeTypeAdmin && typeValue != keycloakThemeTypeEmail {
return nil, errors.New("type must be one of: login, account, admin, email")
}
doc[key] = typeValue
case "target":
target, ok := normalizeThemeTarget(value)
if !ok {
return nil, errors.New("target must include a realm and optional client")
}
doc[key] = target
case "metadata":
metadata, ok := normalizeThemeMetadata(value)
if !ok {
return nil, errors.New("metadata must be an object")
}
if hasSensitiveThemeMetadata(metadata) {
return nil, errors.New("metadata contains restricted keys")
}
doc[key] = metadata
}
}
if !partial {
required := []string{"name", "slug", "scope", "type", "version", "target"}
for _, key := range required {
if _, ok := doc[key]; !ok {
return nil, errors.New(key + " is required")
}
}
}
if scope, ok := doc["scope"].(string); ok && scope == keycloakThemeScopeOrganization {
target, _ := doc["target"].(Doc)
orgID, _ := target["organizationId"].(string)
if strings.TrimSpace(orgID) == "" {
return nil, errors.New("target.organizationId is required for organization scope")
}
}
return doc, nil
}
func normalizeThemeTarget(value any) (Doc, bool) {
targetMap, ok := value.(map[string]any)
if !ok {
bsonTarget, docOK := value.(Doc)
if !docOK {
return nil, false
}
targetMap = map[string]any(bsonTarget)
}
realm, ok := targetMap["realm"].(string)
if !ok || strings.TrimSpace(realm) == "" {
return nil, false
}
normalized := Doc{"realm": strings.TrimSpace(realm)}
if client, ok := targetMap["client"].(string); ok && strings.TrimSpace(client) != "" {
normalized["client"] = strings.TrimSpace(client)
}
if organizationID, ok := targetMap["organizationId"].(string); ok && strings.TrimSpace(organizationID) != "" {
normalized["organizationId"] = strings.TrimSpace(organizationID)
}
return normalized, true
}
func normalizeThemeMetadata(value any) (Doc, bool) {
metadataMap, ok := value.(map[string]any)
if !ok {
bsonMetadata, docOK := value.(Doc)
if !docOK {
return nil, false
}
metadataMap = map[string]any(bsonMetadata)
}
normalized := Doc{}
for key, val := range metadataMap {
normalized[strings.TrimSpace(key)] = val
}
return normalized, true
}
func hasSensitiveThemeMetadata(metadata Doc) bool {
for key := range metadata {
k := strings.ToLower(strings.TrimSpace(key))
if strings.Contains(k, "secret") || strings.Contains(k, "password") || strings.Contains(k, "token") || strings.Contains(k, "credential") || strings.Contains(k, "filesystem") || strings.Contains(k, "path") {
return true
}
}
return false
}
func authorizeKeycloakThemeCreate(claims *accessClaims, doc Doc) error {
scope, _ := doc["scope"].(string)
status, _ := doc["status"].(string)
if scope == keycloakThemeScopePlatform {
if !hasAnyRole(claims, "platform_owner", "platform_admin") {
return errors.New("only platform internal roles can create platform themes")
}
if !hasPermission(claims, permKeycloakThemesCreate) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) {
return errors.New("missing permissions for platform theme management")
}
if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) {
return errors.New("missing permission: keycloak_themes.publish")
}
return nil
}
if scope == keycloakThemeScopeOrganization {
if !hasPermission(claims, permKeycloakThemesCreate) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) {
return errors.New("missing permissions for organisation theme management")
}
isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin")
if !isPlatformRole {
if !hasAnyRole(claims, "organisation_owner", "organisation_admin") {
return errors.New("only organisation admins can manage organisation-scoped themes")
}
if status != keycloakThemeStatusDraft {
return errors.New("organisation admins can only manage draft organisation themes")
}
}
if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) {
return errors.New("missing permission: keycloak_themes.publish")
}
return nil
}
return errors.New("invalid theme scope")
}
func authorizeKeycloakThemePatch(claims *accessClaims, existing, patch Doc) error {
scope, _ := existing["scope"].(string)
if scopePatch, ok := patch["scope"].(string); ok {
scope = scopePatch
}
status, _ := existing["status"].(string)
if statusPatch, ok := patch["status"].(string); ok {
status = statusPatch
}
if scope == keycloakThemeScopePlatform {
if !hasAnyRole(claims, "platform_owner", "platform_admin") {
return errors.New("only platform internal roles can edit platform themes")
}
if !hasPermission(claims, permKeycloakThemesEdit) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) {
return errors.New("missing permissions for platform theme management")
}
if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) {
return errors.New("missing permission: keycloak_themes.publish")
}
return nil
}
if !hasPermission(claims, permKeycloakThemesEdit) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) {
return errors.New("missing permissions for organisation theme management")
}
isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin")
if !isPlatformRole {
if !hasAnyRole(claims, "organisation_owner", "organisation_admin") {
return errors.New("only organisation admins can edit organisation themes")
}
if status != keycloakThemeStatusDraft {
return errors.New("organisation admins can only manage draft organisation themes")
}
}
if status == keycloakThemeStatusPublished && !hasPermission(claims, permKeycloakThemesPublish) {
return errors.New("missing permission: keycloak_themes.publish")
}
return nil
}
func authorizeKeycloakThemeDelete(claims *accessClaims, existing Doc) error {
scope, _ := existing["scope"].(string)
status, _ := existing["status"].(string)
if scope == keycloakThemeScopePlatform {
if !hasAnyRole(claims, "platform_owner", "platform_admin") {
return errors.New("only platform internal roles can delete platform themes")
}
if !hasPermission(claims, permKeycloakThemesArchive) || !hasPermission(claims, permKeycloakThemesManagePlatformDefaults) {
return errors.New("missing permissions for platform theme management")
}
return nil
}
if !hasPermission(claims, permKeycloakThemesArchive) || !hasPermission(claims, permKeycloakThemesManageOrgOverrides) {
return errors.New("missing permissions for organisation theme management")
}
isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin")
if !isPlatformRole {
if !hasAnyRole(claims, "organisation_owner", "organisation_admin") {
return errors.New("only organisation admins can delete organisation themes")
}
if status != keycloakThemeStatusDraft {
return errors.New("organisation admins can only manage draft organisation themes")
}
}
return nil
}
func sanitizeThemeDocument(doc Doc, claims *accessClaims) Doc {
if hasAnyRole(claims, "platform_owner", "platform_admin", "admin") {
return doc
}
sanitized := Doc{}
for key, value := range doc {
if isSensitiveThemeField(key) {
continue
}
if key == "metadata" {
metadata, ok := value.(Doc)
if !ok {
sanitized[key] = value
continue
}
clean := Doc{}
for mk, mv := range metadata {
if !isSensitiveThemeField(mk) {
clean[mk] = mv
}
}
sanitized[key] = clean
continue
}
sanitized[key] = value
}
return sanitized
}
func isSensitiveThemeField(key string) bool {
k := strings.ToLower(strings.TrimSpace(key))
return strings.Contains(k, "secret") || strings.Contains(k, "password") || strings.Contains(k, "token") || strings.Contains(k, "credential") || strings.Contains(k, "filesystem") || strings.Contains(k, "path")
}
func stampThemeEditorMetadata(doc, existing Doc, claims *accessClaims) {
metadata := resolveThemeMetadata(existing, doc)
if metadata == nil {
metadata = Doc{}
}
now := time.Now().UnixMilli()
if _, exists := metadata["createdAt"]; !exists {
metadata["createdAt"] = now
}
if _, exists := metadata["createdBy"]; !exists {
metadata["createdBy"] = themeEditorActor(claims)
}
metadata["updatedAt"] = now
metadata["updatedBy"] = themeEditorActor(claims)
doc["metadata"] = metadata
}
func themeEditorActor(claims *accessClaims) string {
if claims == nil {
return "unknown"
}
if strings.TrimSpace(claims.PreferredUsername) != "" {
return strings.TrimSpace(claims.PreferredUsername)
}
if strings.TrimSpace(claims.Email) != "" {
return strings.TrimSpace(claims.Email)
}
if strings.TrimSpace(claims.Sub) != "" {
return strings.TrimSpace(claims.Sub)
}
return "unknown"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,486 @@
package main
import (
"errors"
"strings"
"time"
)
func prepareLiveCoursePayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
slug, hasSlug, err := getOptionalString(payload, "slug")
if err != nil {
return nil, errors.New("slug must be a string")
}
if !hasSlug && !partial && hasTitle {
slug = slugify(title)
hasSlug = true
}
if hasSlug {
payload["slug"] = normalizeSlug(slug)
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = liveCourseStatusDraft
hasStatus = true
}
if hasStatus {
normalizedStatus := strings.ToLower(strings.TrimSpace(status))
if normalizedStatus != liveCourseStatusDraft && normalizedStatus != liveCourseStatusScheduled && normalizedStatus != liveCourseStatusPublished && normalizedStatus != liveCourseStatusArchived {
return nil, errors.New("status must be one of: draft, scheduled, published, archived")
}
payload["status"] = normalizedStatus
}
if visibility, has, err := getOptionalString(payload, "visibility"); err != nil {
return nil, errors.New("visibility must be a string")
} else if has {
normalized := strings.ToLower(strings.TrimSpace(visibility))
switch normalized {
case "public", "authenticated", "private", "organisation":
payload["visibility"] = normalized
default:
return nil, errors.New("visibility must be one of: public, authenticated, private, organisation")
}
}
if mode, has, err := getOptionalString(payload, "deliveryMode"); err != nil {
return nil, errors.New("deliveryMode must be a string")
} else if has {
normalized := strings.ToLower(strings.TrimSpace(mode))
switch normalized {
case "online", "in_person", "hybrid":
payload["deliveryMode"] = normalized
default:
return nil, errors.New("deliveryMode must be one of: online, in_person, hybrid")
}
}
if level, has, err := getOptionalString(payload, "level"); err != nil {
return nil, errors.New("level must be a string")
} else if has {
normalized := strings.ToLower(strings.TrimSpace(level))
switch normalized {
case "beginner", "intermediate", "advanced":
payload["level"] = normalized
default:
return nil, errors.New("level must be one of: beginner, intermediate, advanced")
}
}
if instructorIDs, has, err := normalizeStringSlice(payload["instructorIds"]); err != nil {
return nil, errors.New("instructorIds must be an array of strings")
} else if has {
payload["instructorIds"] = instructorIDs
}
if assetIDs, has, err := normalizeStringSlice(payload["assetIds"]); err != nil {
return nil, errors.New("assetIds must be an array of strings")
} else if has {
payload["assetIds"] = assetIDs
}
for _, field := range []string{"description", "durationSummary", "communityId", "entitlementKey"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
return payload, nil
}
func prepareLiveCourseCohortPayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "draft"
hasStatus = true
}
if hasStatus {
normalizedStatus := strings.ToLower(strings.TrimSpace(status))
switch normalizedStatus {
case "draft", "open", "closed", "in_progress", "completed", "cancelled":
payload["status"] = normalizedStatus
default:
return nil, errors.New("status must be one of: draft, open, closed, in_progress, completed, cancelled")
}
}
startsAt, hasStartsAt, err := parseOptionalTime(payload["startsAt"])
if err != nil {
return nil, errors.New("startsAt must be a unix milliseconds number or RFC3339 string")
}
if hasStartsAt {
payload["startsAt"] = startsAt.UnixMilli()
}
endsAt, hasEndsAt, err := parseOptionalTime(payload["endsAt"])
if err != nil {
return nil, errors.New("endsAt must be a unix milliseconds number or RFC3339 string")
}
if hasEndsAt {
payload["endsAt"] = endsAt.UnixMilli()
}
if hasStartsAt && hasEndsAt && endsAt.Before(startsAt) {
return nil, errors.New("endsAt must be greater than or equal to startsAt")
}
if timezone, has, err := getOptionalString(payload, "timezone"); err != nil {
return nil, errors.New("timezone must be a string")
} else if has {
if _, tzErr := time.LoadLocation(timezone); tzErr != nil {
return nil, errors.New("timezone must be a valid IANA timezone")
}
payload["timezone"] = timezone
}
if capacity, has, err := getOptionalInteger(payload, "capacity"); err != nil {
return nil, errors.New("capacity must be an integer")
} else if has {
if capacity < 0 {
return nil, errors.New("capacity must be greater than or equal to 0")
}
payload["capacity"] = capacity
}
if enrolledCount, has, err := getOptionalInteger(payload, "enrolledCount"); err != nil {
return nil, errors.New("enrolledCount must be an integer")
} else if has {
if enrolledCount < 0 {
return nil, errors.New("enrolledCount must be greater than or equal to 0")
}
payload["enrolledCount"] = enrolledCount
} else if !partial {
payload["enrolledCount"] = 0
}
if waitlistEnabled, has, err := getOptionalBool(payload, "waitlistEnabled"); err != nil {
return nil, errors.New("waitlistEnabled must be a boolean")
} else if has {
payload["waitlistEnabled"] = waitlistEnabled
}
for _, field := range []string{"priceId", "entitlementKey", "meetingProvider", "defaultMeetingUrl", "organisationId"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
return payload, nil
}
func prepareLiveCourseSessionPayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
title, hasTitle, err := getOptionalString(payload, "title")
if err != nil {
return nil, errors.New("title must be a string")
}
if !partial && (!hasTitle || title == "") {
return nil, errors.New("title is required")
}
if hasTitle {
payload["title"] = title
}
if description, has, err := getOptionalString(payload, "description"); err != nil {
return nil, errors.New("description must be a string")
} else if has {
payload["description"] = description
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "scheduled"
hasStatus = true
}
if hasStatus {
normalizedStatus := strings.ToLower(strings.TrimSpace(status))
switch normalizedStatus {
case "scheduled", "live", "completed", "cancelled":
payload["status"] = normalizedStatus
default:
return nil, errors.New("status must be one of: scheduled, live, completed, cancelled")
}
}
startsAt, hasStartsAt, err := parseOptionalTime(payload["startsAt"])
if err != nil {
return nil, errors.New("startsAt must be a unix milliseconds number or RFC3339 string")
}
if hasStartsAt {
payload["startsAt"] = startsAt.UnixMilli()
}
endsAt, hasEndsAt, err := parseOptionalTime(payload["endsAt"])
if err != nil {
return nil, errors.New("endsAt must be a unix milliseconds number or RFC3339 string")
}
if hasEndsAt {
payload["endsAt"] = endsAt.UnixMilli()
}
if hasStartsAt && hasEndsAt && endsAt.Before(startsAt) {
return nil, errors.New("endsAt must be greater than or equal to startsAt")
}
if timezone, has, err := getOptionalString(payload, "timezone"); err != nil {
return nil, errors.New("timezone must be a string")
} else if has {
if _, tzErr := time.LoadLocation(timezone); tzErr != nil {
return nil, errors.New("timezone must be a valid IANA timezone")
}
payload["timezone"] = timezone
}
if instructorIDs, has, err := normalizeStringSlice(payload["instructorIds"]); err != nil {
return nil, errors.New("instructorIds must be an array of strings")
} else if has {
payload["instructorIds"] = instructorIDs
}
if assetIDs, has, err := normalizeStringSlice(payload["assetIds"]); err != nil {
return nil, errors.New("assetIds must be an array of strings")
} else if has {
payload["assetIds"] = assetIDs
}
for _, field := range []string{"meetingUrl", "meetingProvider", "recordingAssetId", "communityThreadId"} {
value, has, err := getOptionalString(payload, field)
if err != nil {
return nil, errors.New(field + " must be a string")
}
if has {
payload[field] = value
}
}
return payload, nil
}
func prepareLiveCourseEnrollmentPayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
userID, hasUserID, err := getOptionalString(payload, "userId")
if err != nil {
return nil, errors.New("userId must be a string")
}
if !partial && (!hasUserID || userID == "") {
return nil, errors.New("userId is required")
}
if hasUserID {
payload["userId"] = userID
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "enrolled"
hasStatus = true
}
if hasStatus {
normalizedStatus := strings.ToLower(strings.TrimSpace(status))
switch normalizedStatus {
case "invited", "enrolled", "waitlisted", "cancelled", "completed", "removed":
payload["status"] = normalizedStatus
default:
return nil, errors.New("status must be one of: invited, enrolled, waitlisted, cancelled, completed, removed")
}
}
source, hasSource, err := getOptionalString(payload, "source")
if err != nil {
return nil, errors.New("source must be a string")
}
if !partial && !hasSource {
source = "manual"
hasSource = true
}
if hasSource {
normalizedSource := strings.ToLower(strings.TrimSpace(source))
switch normalizedSource {
case "manual", "stripe", "subscription", "organisation", "invite":
payload["source"] = normalizedSource
default:
return nil, errors.New("source must be one of: manual, stripe, subscription, organisation, invite")
}
}
if entitlementKey, has, err := getOptionalString(payload, "entitlementKey"); err != nil {
return nil, errors.New("entitlementKey must be a string")
} else if has {
payload["entitlementKey"] = entitlementKey
}
enrolledAt, hasEnrolledAt, err := parseOptionalTime(payload["enrolledAt"])
if err != nil {
return nil, errors.New("enrolledAt must be a unix milliseconds number or RFC3339 string")
}
if hasEnrolledAt {
payload["enrolledAt"] = enrolledAt.UnixMilli()
}
completedAt, hasCompletedAt, err := parseOptionalTime(payload["completedAt"])
if err != nil {
return nil, errors.New("completedAt must be a unix milliseconds number or RFC3339 string")
}
if hasCompletedAt {
payload["completedAt"] = completedAt.UnixMilli()
}
if hasEnrolledAt && hasCompletedAt && completedAt.Before(enrolledAt) {
return nil, errors.New("completedAt must be greater than or equal to enrolledAt")
}
return payload, nil
}
func prepareLiveCourseAttendancePayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
userID, hasUserID, err := getOptionalString(payload, "userId")
if err != nil {
return nil, errors.New("userId must be a string")
}
if !partial && (!hasUserID || userID == "") {
return nil, errors.New("userId is required")
}
if hasUserID {
payload["userId"] = userID
}
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return nil, errors.New("status must be a string")
}
if !partial && !hasStatus {
status = "present"
hasStatus = true
}
if hasStatus {
normalizedStatus := strings.ToLower(strings.TrimSpace(status))
switch normalizedStatus {
case "present", "late", "absent", "excused", "completed":
payload["status"] = normalizedStatus
default:
return nil, errors.New("status must be one of: present, late, absent, excused, completed")
}
}
joinedAt, hasJoinedAt, err := parseOptionalTime(payload["joinedAt"])
if err != nil {
return nil, errors.New("joinedAt must be a unix milliseconds number or RFC3339 string")
}
if hasJoinedAt {
payload["joinedAt"] = joinedAt.UnixMilli()
}
leftAt, hasLeftAt, err := parseOptionalTime(payload["leftAt"])
if err != nil {
return nil, errors.New("leftAt must be a unix milliseconds number or RFC3339 string")
}
if hasLeftAt {
payload["leftAt"] = leftAt.UnixMilli()
}
if hasJoinedAt && hasLeftAt {
if leftAt.Before(joinedAt) {
return nil, errors.New("leftAt must be greater than or equal to joinedAt")
}
payload["durationSeconds"] = int(leftAt.Sub(joinedAt).Seconds())
}
if durationSeconds, has, err := getOptionalInteger(payload, "durationSeconds"); err != nil {
return nil, errors.New("durationSeconds must be an integer")
} else if has {
if durationSeconds < 0 {
return nil, errors.New("durationSeconds must be greater than or equal to 0")
}
payload["durationSeconds"] = durationSeconds
}
if notes, has, err := getOptionalString(payload, "notes"); err != nil {
return nil, errors.New("notes must be a string")
} else if has {
payload["notes"] = notes
}
return payload, nil
}
func getOptionalInteger(payload map[string]any, field string) (int, bool, error) {
raw, ok := payload[field]
if !ok || raw == nil {
return 0, false, nil
}
switch value := raw.(type) {
case int:
return value, true, nil
case int32:
return int(value), true, nil
case int64:
return int(value), true, nil
case float64:
if value != float64(int(value)) {
return 0, false, errors.New("not an integer")
}
return int(value), true, nil
default:
return 0, false, errors.New("not an integer")
}
}

40
Backend/cmd/api/main.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
)
func main() {
cfg := loadConfig()
app, err := newApp(cfg)
if err != nil {
log.Fatalf("failed to initialize app: %v", err)
}
defer app.close()
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: app.handler(),
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}
}()
log.Printf("starting backend API on :%s", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}

View File

@ -0,0 +1,278 @@
package main
import (
"encoding/json"
"net/http"
)
const mcpProtocolVersion = "2025-03-26"
type mcpRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type mcpResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *mcpError `json:"error,omitempty"`
}
type mcpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
func (a *app) mcpProtectedResourceMetadata(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"resource": a.cfg.MCPResourceURL,
"authorization_servers": []string{a.cfg.KeycloakIssuer},
"scopes_supported": []string{
"cms.authoring.read",
"cms.authoring.propose",
"cms.authoring.preview",
"scorm.authoring.read",
"scorm.authoring.propose",
"scorm.authoring.preview",
},
"bearer_methods_supported": []string{"header"},
})
}
func (a *app) authoringRoleMiddleware(next http.Handler) http.Handler {
if a.cfg.AuthDisabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
if !hasAnyRole(claims, "editor", "instructor", "admin", "platform_owner", "platform_admin") {
writeError(w, http.StatusForbidden, "authoring role required")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) handleMCP(w http.ResponseWriter, r *http.Request) {
var request mcpRequest
if err := decodeJSON(r, &request); err != nil {
writeMCPError(w, nil, -32700, "invalid JSON-RPC request")
return
}
if request.JSONRPC != "2.0" || request.Method == "" {
writeMCPError(w, request.ID, -32600, "invalid JSON-RPC request")
return
}
switch request.Method {
case "initialize":
writeMCPResult(w, request.ID, map[string]any{
"protocolVersion": mcpProtocolVersion,
"capabilities": map[string]any{
"tools": map[string]any{"listChanged": false},
"resources": map[string]any{"subscribe": false, "listChanged": false},
},
"serverInfo": map[string]any{
"name": "cms-authoring",
"version": "0.1.0",
},
})
case "ping":
writeMCPResult(w, request.ID, map[string]any{})
case "tools/list":
writeMCPResult(w, request.ID, map[string]any{"tools": externalAuthoringTools()})
case "resources/list":
writeMCPResult(w, request.ID, map[string]any{"resources": []map[string]any{
{
"uri": "cms://capabilities",
"name": "CMS authoring capabilities",
"description": "Supported proposal-only site, theme, and SCORM authoring operations.",
"mimeType": "application/json",
},
{
"uri": "cms://themes/schema",
"name": "CMS theme schema",
"description": "Allowed semantic theme tokens and validation constraints.",
"mimeType": "application/json",
},
{
"uri": "cms://sites/schema",
"name": "CMS site proposal schema",
"description": "Allowed site structure and page proposal fields.",
"mimeType": "application/json",
},
}})
case "resources/read":
if !a.mcpScopeAllowed(r, "cms.authoring.read") {
writeMCPError(w, request.ID, -32001, "insufficient OAuth scope")
return
}
a.handleMCPResourceRead(w, request)
case "tools/call":
a.handleMCPToolCall(w, r, request)
default:
writeMCPError(w, request.ID, -32601, "method not found")
}
}
func (a *app) handleMCPResourceRead(w http.ResponseWriter, request mcpRequest) {
var params struct {
URI string `json:"uri"`
}
if err := json.Unmarshal(request.Params, &params); err != nil || params.URI == "" {
writeMCPError(w, request.ID, -32602, "uri is required")
return
}
var resource any
switch params.URI {
case "cms://capabilities":
resource = map[string]any{
"modes": []string{"theme", "site", "scorm", "site_and_course"},
"proposalOnly": true,
"requiresCMSApproval": true,
"themeTools": []string{"theme_propose", "theme_validate"},
"siteTools": []string{"site_propose_structure", "site_upsert_page_proposal", "site_validate"},
}
case "cms://themes/schema":
resource = themeSchemaResource()
case "cms://sites/schema":
resource = siteSchemaResource()
default:
writeMCPError(w, request.ID, -32002, "resource not found")
return
}
encoded, err := json.Marshal(resource)
if err != nil {
writeMCPError(w, request.ID, -32603, "failed to encode resource")
return
}
writeMCPResult(w, request.ID, map[string]any{"contents": []map[string]any{
{"uri": params.URI, "mimeType": "application/json", "text": string(encoded)},
}})
}
func (a *app) handleMCPToolCall(w http.ResponseWriter, r *http.Request, request mcpRequest) {
var params struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal(request.Params, &params); err != nil || params.Name == "" {
writeMCPError(w, request.ID, -32602, "tool name is required")
return
}
requiredScope := "cms.authoring.propose"
if params.Name == "authoring_get_status" || params.Name == "authoring_get_review_link" {
requiredScope = "cms.authoring.read"
}
if !a.mcpScopeAllowed(r, requiredScope) {
writeMCPError(w, request.ID, -32001, "insufficient OAuth scope")
return
}
result, err := a.callExternalAuthoringTool(r, params.Name, params.Arguments)
if err != nil {
writeMCPToolResult(w, request.ID, map[string]any{"error": err.Error()}, true)
return
}
writeMCPToolResult(w, request.ID, result, false)
}
func (a *app) mcpScopeAllowed(r *http.Request, required string) bool {
if a.cfg.AuthDisabled {
return true
}
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
return hasOAuthScope(claims, required)
}
func writeMCPToolResult(w http.ResponseWriter, id json.RawMessage, result any, isError bool) {
encoded, err := json.Marshal(result)
if err != nil {
writeMCPError(w, id, -32603, "failed to encode tool result")
return
}
writeMCPResult(w, id, map[string]any{
"content": []map[string]any{{"type": "text", "text": string(encoded)}},
"structuredContent": result,
"isError": isError,
})
}
func externalAuthoringTools() []mcpTool {
return []mcpTool{
newMCPTool("authoring_start", "Create a proposal-only authoring run. This tool never changes or publishes production content.", map[string]any{
"brief": map[string]any{"type": "object", "description": "Structured site, theme, or SCORM authoring brief."},
}, []string{"brief"}),
newMCPTool("authoring_get_status", "Get the status, assumptions, validation summary, and review state for an authoring run.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("theme_propose", "Store a complete semantic theme proposal in an authoring run without applying it.", map[string]any{
"runId": map[string]any{"type": "string"},
"idempotencyKey": map[string]any{"type": "string"},
"theme": map[string]any{"type": "object"},
}, []string{"runId", "idempotencyKey", "theme"}),
newMCPTool("theme_validate", "Validate the proposed theme tokens and color contrast.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("site_propose_structure", "Store a proposal-only site title and navigation structure without creating CMS pages.", map[string]any{
"runId": map[string]any{"type": "string"},
"idempotencyKey": map[string]any{"type": "string"},
"site": map[string]any{"type": "object"},
}, []string{"runId", "idempotencyKey", "site"}),
newMCPTool("site_upsert_page_proposal", "Store one complete normalized page tree in a site proposal without changing CMS pages.", map[string]any{
"runId": map[string]any{"type": "string"},
"idempotencyKey": map[string]any{"type": "string"},
"page": map[string]any{"type": "object"},
}, []string{"runId", "idempotencyKey", "page"}),
newMCPTool("site_validate", "Validate proposed page IDs, slugs, SEO, block trees, and navigation targets.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("authoring_get_review_link", "Return an authenticated CMS review URL for a validated proposal.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
newMCPTool("authoring_discard", "Discard an unapplied proposal owned by the current user.", map[string]any{
"runId": map[string]any{"type": "string"},
}, []string{"runId"}),
}
}
func newMCPTool(name, description string, properties map[string]any, required []string) mcpTool {
return mcpTool{
Name: name,
Description: description,
InputSchema: map[string]any{
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false,
},
}
}
func writeMCPResult(w http.ResponseWriter, id json.RawMessage, result any) {
writeJSON(w, http.StatusOK, mcpResponse{JSONRPC: "2.0", ID: id, Result: result})
}
func writeMCPError(w http.ResponseWriter, id json.RawMessage, code int, message string) {
writeJSON(w, http.StatusOK, mcpResponse{
JSONRPC: "2.0",
ID: id,
Error: &mcpError{Code: code, Message: message},
})
}

View File

@ -0,0 +1,198 @@
package main
import (
"context"
"net/http"
"strings"
)
type authContextKey string
const userClaimsKey authContextKey = "userClaims"
const tenantContextKey authContextKey = "tenantContext"
type accessClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
AuthorizedParty string `json:"azp"`
TenantID string `json:"tenant_id"`
TenantIDAlt string `json:"tenantId"`
OrganizationID string `json:"organization_id"`
OrganizationIDAlt string `json:"organizationId"`
OrganisationID string `json:"organisation_id"`
OrganisationIDAlt string `json:"organisationId"`
OrgID string `json:"org_id"`
OrgIDAlt string `json:"orgId"`
Permissions []string `json:"permissions"`
Scope string `json:"scope"`
Scopes []string `json:"scopes"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
}
func hasOAuthScope(claims *accessClaims, required string) bool {
if claims == nil {
return false
}
for _, scope := range append(strings.Fields(claims.Scope), claims.Scopes...) {
if scope == required {
return true
}
}
return false
}
type tenantContext struct {
ID string
BypassScoping bool
RequestedByOps bool
}
func (a *app) authMiddleware(next http.Handler) http.Handler {
if a.cfg.AuthDisabled {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.oidcVerifier == nil {
writeError(w, http.StatusServiceUnavailable, "oidc verifier not configured")
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
writeError(w, http.StatusUnauthorized, "missing authorization header")
return
}
tokenString := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
if tokenString == "" {
writeError(w, http.StatusUnauthorized, "invalid bearer token")
return
}
idToken, err := a.oidcVerifier.Verify(r.Context(), tokenString)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
claims := &accessClaims{}
if err := idToken.Claims(claims); err != nil {
writeError(w, http.StatusUnauthorized, "invalid token claims")
return
}
if expected := strings.TrimSpace(a.cfg.KeycloakAudience); expected != "" {
if !contains(idToken.Audience, expected) && strings.TrimSpace(claims.AuthorizedParty) != expected {
writeError(w, http.StatusUnauthorized, "token client is not authorized for this application")
return
}
}
tenantID := resolveClaimsTenantID(claims)
isPlatformRole := hasAnyRole(claims, "platform_owner", "platform_admin")
requestedTenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
if requestedTenantID != "" {
if !isPlatformRole && tenantID != requestedTenantID {
writeError(w, http.StatusForbidden, "tenant override requires platform role")
return
}
tenantID = requestedTenantID
}
ctx := context.WithValue(r.Context(), userClaimsKey, claims)
ctx = context.WithValue(ctx, tenantContextKey, tenantContext{
ID: tenantID,
BypassScoping: isPlatformRole,
RequestedByOps: requestedTenantID != "",
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func withCORS(allowedOrigins []string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if isAllowedOrigin(origin, allowedOrigins) {
w.Header().Set("Access-Control-Allow-Origin", origin)
} else if contains(allowedOrigins, "*") {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func isAllowedOrigin(origin string, allowed []string) bool {
if origin == "" {
return false
}
for _, entry := range allowed {
if entry == "*" || entry == origin {
return true
}
}
return false
}
func contains(values []string, needle string) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}
func hasRole(claims *accessClaims, role string) bool {
for _, candidate := range claims.RealmAccess.Roles {
if candidate == role {
return true
}
}
return false
}
func resolveClaimsTenantID(claims *accessClaims) string {
if claims == nil {
return ""
}
for _, candidate := range []string{
claims.TenantID,
claims.TenantIDAlt,
claims.OrganizationID,
claims.OrganizationIDAlt,
claims.OrganisationID,
claims.OrganisationIDAlt,
claims.OrgID,
claims.OrgIDAlt,
} {
if trimmed := strings.TrimSpace(candidate); trimmed != "" {
return trimmed
}
}
return ""
}
func resolveClaimsUserID(claims *accessClaims) string {
if claims == nil {
return ""
}
for _, candidate := range []string{claims.Sub, claims.PreferredUsername, claims.Email} {
if trimmed := strings.TrimSpace(candidate); trimmed != "" {
return trimmed
}
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,168 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strings"
)
type notificationHTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
type notificationProviderProfile struct {
Provider string
APIKey string
FromEmail string
APIBaseURL string
}
type sendGridNotificationProvider struct {
apiKey string
fromEmail string
apiBaseURL string
httpClient notificationHTTPClient
}
type sendGridEmailAddress struct {
Email string `json:"email"`
}
type sendGridPersonalization struct {
To []sendGridEmailAddress `json:"to"`
DynamicTemplateData map[string]any `json:"dynamic_template_data,omitempty"`
}
type sendGridContent struct {
Type string `json:"type"`
Value string `json:"value"`
}
type sendGridMailPayload struct {
Personalizations []sendGridPersonalization `json:"personalizations"`
From sendGridEmailAddress `json:"from"`
Subject string `json:"subject,omitempty"`
Content []sendGridContent `json:"content,omitempty"`
TemplateID string `json:"template_id,omitempty"`
}
func newNotificationProvider(cfg config) notificationProvider {
return newNotificationProviderFromProfile(notificationProviderProfile{
Provider: cfg.NotificationProvider,
APIKey: cfg.SendGridAPIKey,
FromEmail: cfg.SendGridFromEmail,
APIBaseURL: cfg.SendGridAPIBaseURL,
})
}
func newNotificationProviderFromProfile(profile notificationProviderProfile) notificationProvider {
provider := strings.TrimSpace(strings.ToLower(profile.Provider))
if provider == "" || provider == "noop" {
return noopNotificationProvider{}
}
if provider != "sendgrid" {
log.Printf("unknown NOTIFICATION_PROVIDER=%q; falling back to noop", profile.Provider)
return noopNotificationProvider{}
}
apiKey := strings.TrimSpace(profile.APIKey)
fromEmail := strings.TrimSpace(profile.FromEmail)
if apiKey == "" || fromEmail == "" {
log.Printf("sendgrid provider selected but missing SENDGRID_API_KEY or SENDGRID_FROM_EMAIL; falling back to noop")
return noopNotificationProvider{}
}
apiBaseURL := strings.TrimSpace(profile.APIBaseURL)
if apiBaseURL == "" {
apiBaseURL = "https://api.sendgrid.com"
}
return sendGridNotificationProvider{
apiKey: apiKey,
fromEmail: fromEmail,
apiBaseURL: strings.TrimRight(apiBaseURL, "/"),
httpClient: http.DefaultClient,
}
}
func (s sendGridNotificationProvider) Metadata() notificationProviderMetadata {
return notificationProviderMetadata{
Name: "sendgrid",
Capabilities: notificationProviderCapabilities{
Email: true,
Templates: true,
Webhooks: true,
Events: true,
},
}
}
func (s sendGridNotificationProvider) Send(ctx context.Context, request notificationRequest) (notificationProviderResult, error) {
if strings.TrimSpace(strings.ToLower(request.Channel)) != "email" {
return notificationProviderResult{}, errors.New("sendgrid provider supports email channel only")
}
if strings.TrimSpace(request.To) == "" {
return notificationProviderResult{}, errors.New("notification destination is required")
}
payload := sendGridMailPayload{
Personalizations: []sendGridPersonalization{
{
To: []sendGridEmailAddress{{Email: strings.TrimSpace(request.To)}},
DynamicTemplateData: request.Metadata,
},
},
From: sendGridEmailAddress{Email: s.fromEmail},
Subject: strings.TrimSpace(request.Subject),
TemplateID: strings.TrimSpace(request.TemplateID),
}
if strings.TrimSpace(request.Body) != "" {
payload.Content = []sendGridContent{{Type: "text/plain", Value: request.Body}}
}
body, err := json.Marshal(payload)
if err != nil {
return notificationProviderResult{}, fmt.Errorf("marshal sendgrid payload: %w", err)
}
endpoint := strings.TrimRight(s.apiBaseURL, "/") + "/v3/mail/send"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return notificationProviderResult{}, fmt.Errorf("create sendgrid request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
req.Header.Set("Content-Type", "application/json")
client := s.httpClient
if client == nil {
client = http.DefaultClient
}
res, err := client.Do(req)
if err != nil {
return notificationProviderResult{}, fmt.Errorf("sendgrid request failed: %w", err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode > 299 {
raw, _ := io.ReadAll(io.LimitReader(res.Body, 8192))
return notificationProviderResult{}, fmt.Errorf("sendgrid send failed: status=%d body=%s", res.StatusCode, strings.TrimSpace(string(raw)))
}
messageID := strings.TrimSpace(res.Header.Get("X-Message-Id"))
if messageID == "" {
messageID = "sendgrid-accepted"
}
return notificationProviderResult{
ProviderMessageID: messageID,
Status: "queued",
}, nil
}

489
Backend/cmd/api/pages.go Normal file
View File

@ -0,0 +1,489 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
func (a *app) createPage(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := a.preparePagePayload(Doc(payload), "", false)
if err != nil {
a.writePageValidationError(w, err)
return
}
doc, err = a.create("pages", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create resource")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) patchPage(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var patch map[string]any
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
expectedUpdatedAt, hasExpectedUpdatedAt, err := optionalExpectedUpdatedAt(patch)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
delete(patch, "expectedUpdatedAt")
doc, unset, err := a.preparePagePatchPayload(Doc(patch), id)
if err != nil {
a.writePageValidationError(w, err)
return
}
if hasExpectedUpdatedAt {
doc, err = a.patchPageAtRevision(id, expectedUpdatedAt, doc, unset)
} else {
doc, err = a.patchWithUnset("pages", id, doc, unset)
}
if err != nil {
if errors.Is(err, ErrNotFound) {
if hasExpectedUpdatedAt {
if _, loadErr := a.getByID("pages", id); loadErr == nil {
writeError(w, http.StatusConflict, "page changed since it was opened; reload before saving")
return
}
}
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update resource")
return
}
writeJSON(w, http.StatusOK, doc)
}
func optionalExpectedUpdatedAt(payload map[string]any) (int64, bool, error) {
value, exists := payload["expectedUpdatedAt"]
if !exists {
return 0, false, nil
}
number, ok := numericValue(value)
if !ok || number <= 0 || number != float64(int64(number)) {
return 0, false, errors.New("expectedUpdatedAt must be a positive integer")
}
return int64(number), true, nil
}
func (a *app) patchPageAtRevision(id string, expectedUpdatedAt int64, patch, unset Doc) (Doc, error) {
collection, err := a.resolveCollection("pages")
if err != nil {
return nil, err
}
delete(patch, "id")
delete(patch, "createdAt")
patch["updatedAt"] = time.Now().UnixMilli()
update := Doc{"$set": patch}
if len(unset) > 0 {
update["$unset"] = unset
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var updated Doc
err = collection.FindOneAndUpdate(
ctx,
Doc{"id": id, "updatedAt": expectedUpdatedAt},
update,
NewFindOneAndUpdateOptions().SetReturnDocument(ReturnAfter),
).Decode(&updated)
if err != nil {
return nil, err
}
normalizeDocument(updated)
return updated, nil
}
func (a *app) getPublicPageBySlug(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
slug := normalizeSlug(chi.URLParam(r, "slug"))
if slug == "" {
writeError(w, http.StatusBadRequest, "slug is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
var doc Doc
err := a.db.Collection("pages").FindOne(ctx, Doc{"slug": slug}).Decode(&doc)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "page not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load page")
return
}
normalizeDocument(doc)
if !isPublishedPublicPage(doc) {
writeError(w, http.StatusNotFound, "page not found")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) preparePagePatchPayload(payload Doc, pageID string) (Doc, Doc, error) {
if payload == nil {
payload = Doc{}
}
unset := Doc{}
if rawSlug, ok := payload["slug"]; ok {
slug, isString := rawSlug.(string)
if !isString {
return nil, nil, validationError{field: "slug", message: "slug must be a string"}
}
if strings.TrimSpace(slug) == "" {
unset["slug"] = ""
delete(payload, "slug")
}
}
if rawSEO, ok := payload["seo"]; ok {
if rawSEO == nil {
unset["seo"] = ""
delete(payload, "seo")
} else {
seo, hasSEO, err := normalizeSEOMetadata(rawSEO)
if err != nil {
return nil, nil, err
}
if hasSEO {
payload["seo"] = seo
} else {
unset["seo"] = ""
delete(payload, "seo")
}
}
}
doc, err := a.preparePagePayload(payload, pageID, true)
if err != nil {
return nil, nil, err
}
return doc, unset, nil
}
func (a *app) preparePagePayload(payload Doc, pageID string, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
name, hasName, err := getOptionalString(payload, "name")
if err != nil {
return nil, validationError{field: "name", message: "name must be a string"}
}
if !partial && (!hasName || name == "") {
return nil, validationError{field: "name", message: "name is required"}
}
rawSlug, hasSlug, err := getOptionalString(payload, "slug")
if err != nil {
return nil, validationError{field: "slug", message: "slug must be a string"}
}
if !hasSlug && !partial && name != "" {
rawSlug = slugify(name)
hasSlug = true
}
if hasSlug {
slug := normalizeSlug(rawSlug)
if slug == "" {
return nil, validationError{field: "slug", message: "slug is required"}
}
if !slugPattern.MatchString(slug) {
return nil, validationError{field: "slug", message: "slug must contain lowercase letters, numbers, and hyphens only"}
}
inUse, err := a.pageSlugExists(slug, pageID)
if err != nil {
return nil, err
}
if inUse {
return nil, validationError{field: "slug", message: "slug already exists"}
}
payload["slug"] = slug
}
seo, hasSEO, err := normalizeSEOMetadata(payload["seo"])
if err != nil {
return nil, err
}
if hasSEO {
payload["seo"] = seo
}
if payload["seo"] == nil {
delete(payload, "seo")
}
return payload, nil
}
func (a *app) pageSlugExists(slug, excludeID string) (bool, error) {
if a.pageSlugInUse != nil {
return a.pageSlugInUse(slug, excludeID)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := Doc{"slug": slug}
if excludeID != "" {
filter["id"] = Doc{"$ne": excludeID}
}
count, err := a.db.Collection("pages").CountDocuments(ctx, filter)
if err != nil {
return false, fmt.Errorf("check page slug uniqueness: %w", err)
}
return count > 0, nil
}
type validationError struct {
field string
message string
}
func (e validationError) Error() string {
return e.message
}
func (a *app) writePageValidationError(w http.ResponseWriter, err error) {
var validationErr validationError
if errors.As(err, &validationErr) {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": validationErr.message,
"field": validationErr.field,
"message": validationErr.message,
})
return
}
writeError(w, http.StatusInternalServerError, "failed to validate page payload")
}
func normalizeSEOMetadata(raw any) (Doc, bool, error) {
if raw == nil {
return nil, false, nil
}
meta, ok := raw.(map[string]any)
if !ok {
if docMeta, docOK := raw.(Doc); docOK {
meta = map[string]any(docMeta)
} else {
return nil, false, validationError{field: "seo", message: "seo must be an object"}
}
}
out := Doc{}
for _, field := range []string{"title", "description", "canonicalUrl"} {
value, exists, err := getOptionalString(meta, field)
if err != nil {
return nil, false, validationError{field: "seo." + field, message: field + " must be a string"}
}
if exists {
out[field] = value
}
}
if canonicalURL, ok := out["canonicalUrl"].(string); ok && canonicalURL != "" {
if err := validateAbsoluteURL(canonicalURL); err != nil {
return nil, false, validationError{field: "seo.canonicalUrl", message: "canonicalUrl must be an absolute http or https URL"}
}
}
openGraph, hasOpenGraph, err := normalizeOpenGraph(meta["openGraph"])
if err != nil {
return nil, false, err
}
if hasOpenGraph {
out["openGraph"] = openGraph
}
robots, hasRobots, err := normalizeRobots(meta["robots"])
if err != nil {
return nil, false, err
}
if hasRobots {
out["robots"] = robots
}
if len(out) == 0 {
return nil, false, nil
}
return out, true, nil
}
func normalizeOpenGraph(raw any) (Doc, bool, error) {
if raw == nil {
return nil, false, nil
}
meta, ok := raw.(map[string]any)
if !ok {
if docMeta, docOK := raw.(Doc); docOK {
meta = map[string]any(docMeta)
} else {
return nil, false, validationError{field: "seo.openGraph", message: "openGraph must be an object"}
}
}
out := Doc{}
for _, field := range []string{"title", "description", "image", "url", "type"} {
value, exists, err := getOptionalString(meta, field)
if err != nil {
return nil, false, validationError{field: "seo.openGraph." + field, message: field + " must be a string"}
}
if exists {
out[field] = value
}
}
for _, field := range []string{"image", "url"} {
value, ok := out[field].(string)
if ok && value != "" {
if err := validateAbsoluteURL(value); err != nil {
return nil, false, validationError{field: "seo.openGraph." + field, message: field + " must be an absolute http or https URL"}
}
}
}
if len(out) == 0 {
return nil, false, nil
}
return out, true, nil
}
func normalizeRobots(raw any) (Doc, bool, error) {
if raw == nil {
return nil, false, nil
}
meta, ok := raw.(map[string]any)
if !ok {
if docMeta, docOK := raw.(Doc); docOK {
meta = map[string]any(docMeta)
} else {
return nil, false, validationError{field: "seo.robots", message: "robots must be an object"}
}
}
out := Doc{}
for _, field := range []string{"index", "follow", "noarchive", "nosnippet"} {
value, exists, err := getOptionalBool(meta, field)
if err != nil {
return nil, false, validationError{field: "seo.robots." + field, message: field + " must be a boolean"}
}
if exists {
out[field] = value
}
}
if len(out) == 0 {
return nil, false, nil
}
return out, true, nil
}
func getOptionalString(payload map[string]any, field string) (string, bool, error) {
raw, ok := payload[field]
if !ok || raw == nil {
return "", false, nil
}
value, ok := raw.(string)
if !ok {
return "", false, errors.New("not a string")
}
return strings.TrimSpace(value), true, nil
}
func getOptionalBool(payload map[string]any, field string) (bool, bool, error) {
raw, ok := payload[field]
if !ok || raw == nil {
return false, false, nil
}
value, ok := raw.(bool)
if !ok {
return false, false, errors.New("not a boolean")
}
return value, true, nil
}
func normalizeSlug(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
value = strings.Trim(value, "/")
value = strings.ReplaceAll(value, "_", "-")
value = strings.Join(strings.Fields(value), "-")
var builder strings.Builder
lastHyphen := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
builder.WriteRune(r)
lastHyphen = false
case r == '-':
if builder.Len() > 0 && !lastHyphen {
builder.WriteRune(r)
lastHyphen = true
}
}
}
return strings.Trim(builder.String(), "-")
}
func slugify(value string) string {
return normalizeSlug(value)
}
func validateAbsoluteURL(value string) error {
parsed, err := url.Parse(value)
if err != nil {
return err
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return errors.New("unsupported scheme")
}
if parsed.Host == "" {
return errors.New("missing host")
}
return nil
}

View File

@ -0,0 +1,95 @@
package main
import "strings"
const (
permKeycloakThemesRead = "keycloak_themes.read"
permKeycloakThemesCreate = "keycloak_themes.create"
permKeycloakThemesEdit = "keycloak_themes.edit"
permKeycloakThemesPublish = "keycloak_themes.publish"
permKeycloakThemesArchive = "keycloak_themes.archive"
permKeycloakThemesDeploy = "keycloak_themes.deploy"
permKeycloakThemesPreview = "keycloak_themes.preview"
permKeycloakThemesPolicyManage = "keycloak_themes.policy.manage"
permKeycloakThemesManagePlatformDefaults = "keycloak_themes.manage_platform_defaults"
permKeycloakThemesManageOrgOverrides = "keycloak_themes.manage_org_overrides"
)
var keycloakThemePermissionRegistry = []string{
permKeycloakThemesRead,
permKeycloakThemesCreate,
permKeycloakThemesEdit,
permKeycloakThemesPublish,
permKeycloakThemesArchive,
permKeycloakThemesDeploy,
permKeycloakThemesPreview,
permKeycloakThemesPolicyManage,
permKeycloakThemesManagePlatformDefaults,
permKeycloakThemesManageOrgOverrides,
}
var rolePermissionRegistry = map[string][]string{
"admin": {"*"},
"platform_owner": {"keycloak_themes.*"},
"platform_admin": keycloakThemePermissionRegistry,
"platform_content_reviewer": {permKeycloakThemesRead, permKeycloakThemesPreview},
"organisation_owner": {
permKeycloakThemesRead,
permKeycloakThemesEdit,
permKeycloakThemesPreview,
permKeycloakThemesManageOrgOverrides,
},
"organisation_admin": {
permKeycloakThemesRead,
permKeycloakThemesEdit,
permKeycloakThemesPreview,
},
}
func hasAnyRole(claims *accessClaims, roles ...string) bool {
for _, role := range roles {
if hasRole(claims, role) {
return true
}
}
return false
}
func hasPermission(claims *accessClaims, permission string) bool {
if claims == nil {
return false
}
for _, candidate := range claims.Permissions {
if permissionMatch(candidate, permission) {
return true
}
}
for _, role := range claims.RealmAccess.Roles {
grants, ok := rolePermissionRegistry[role]
if !ok {
continue
}
for _, grant := range grants {
if permissionMatch(grant, permission) {
return true
}
}
}
return false
}
func permissionMatch(grant, permission string) bool {
if grant == "*" || grant == permission {
return true
}
if strings.HasSuffix(grant, ".*") {
prefix := strings.TrimSuffix(grant, "*")
return strings.HasPrefix(permission, prefix)
}
return false
}

View File

@ -0,0 +1,120 @@
package main
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
func (a *app) platformAdminMiddleware(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 !hasAnyRole(claims, "admin", "platform_owner", "platform_admin") {
writeError(w, http.StatusForbidden, "platform admin role required")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listTenants(w http.ResponseWriter, r *http.Request) {
docs, err := a.list("tenants", Doc{}, Sort{{Key: "name", Value: 1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list tenants")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createTenant(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
name := strings.TrimSpace(anyToString(payload["name"]))
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
payload["name"] = name
if slug := strings.TrimSpace(anyToString(payload["slug"])); slug != "" {
payload["slug"] = slugify(slug)
} else {
payload["slug"] = slugify(name)
}
doc, err := a.create("tenants", Doc(payload))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create tenant")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getTenant(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
doc, err := a.getByID("tenants", id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "tenant not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load tenant")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) patchTenant(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var patch map[string]any
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
if rawName, ok := patch["name"]; ok {
name := strings.TrimSpace(anyToString(rawName))
if name == "" {
writeError(w, http.StatusBadRequest, "name cannot be empty")
return
}
patch["name"] = name
}
if rawSlug, ok := patch["slug"]; ok {
slug := strings.TrimSpace(anyToString(rawSlug))
if slug == "" {
delete(patch, "slug")
} else {
patch["slug"] = slugify(slug)
}
}
doc, err := a.patch("tenants", id, Doc(patch))
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "tenant not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update tenant")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteTenant(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := a.deleteByID("tenants", id); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "tenant not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete tenant")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -0,0 +1,898 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/pgconn"
)
type pgDatabase struct {
pool *pgxpool.Pool
}
type pgCollection struct {
db *pgDatabase
spec relationalResourceSpec
}
type pgCursor struct {
docs []Doc
index int
}
type pgSingleResult struct {
doc Doc
err error
}
type pgDeleteResult struct {
DeletedCount int64
}
type pgUpdateResult struct {
ModifiedCount int64
}
type pgInsertResult struct{}
type pgReplaceResult struct{}
func openPostgres(ctx context.Context, databaseURL string) (*pgDatabase, error) {
if strings.TrimSpace(databaseURL) == "" {
return nil, errors.New("DATABASE_URL is required")
}
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("configure postgres: %w", err)
}
db := &pgDatabase{pool: pool}
if err := db.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
if err := db.ensureRelationalSchema(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("initialize relational postgres schema: %w", err)
}
return db, nil
}
func (d *pgDatabase) Ping(ctx context.Context) error {
return d.pool.Ping(ctx)
}
func (d *pgDatabase) Close() {
if d != nil && d.pool != nil {
d.pool.Close()
}
}
func (d *pgDatabase) Collection(name string) *pgCollection {
spec, ok := relationalResources[name]
if !ok {
return &pgCollection{db: d, spec: relationalResourceSpec{Name: name, Table: name}}
}
return &pgCollection{db: d, spec: spec}
}
func (c *pgCollection) Name() string {
return c.spec.Name
}
func (c *pgCollection) valid() error {
if _, ok := relationalResources[c.spec.Name]; !ok {
return fmt.Errorf("unknown relational resource %s", c.spec.Name)
}
return nil
}
func (c *pgCollection) Find(ctx context.Context, filter Doc, opts ...*FindOptions) (*pgCursor, error) {
if err := c.valid(); err != nil {
return nil, err
}
args := []any{}
where, err := c.buildFilter(filter, &args)
if err != nil {
return nil, err
}
query := "SELECT " + c.documentExpression() + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where
if len(opts) > 0 && opts[0] != nil {
query += c.sortClause(opts[0].Sort)
if opts[0].Limit != nil && *opts[0].Limit > 0 {
args = append(args, *opts[0].Limit)
query += fmt.Sprintf(" LIMIT $%d", len(args))
}
}
rows, err := c.db.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
docs := make([]Doc, 0)
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
return nil, err
}
doc, err := decodeRelationalDocument(raw)
if err != nil {
return nil, err
}
docs = append(docs, doc)
}
if err := rows.Err(); err != nil {
return nil, err
}
return &pgCursor{docs: docs}, nil
}
func (c *pgCollection) FindOne(ctx context.Context, filter Doc, opts ...*FindOneOptions) *pgSingleResult {
if err := c.valid(); err != nil {
return &pgSingleResult{err: err}
}
args := []any{}
where, err := c.buildFilter(filter, &args)
if err != nil {
return &pgSingleResult{err: err}
}
query := "SELECT " + c.documentExpression() + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where
if len(opts) > 0 && opts[0] != nil {
query += c.sortClause(opts[0].Sort)
}
query += " LIMIT 1"
var raw []byte
if err := c.db.pool.QueryRow(ctx, query, args...).Scan(&raw); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
err = ErrNotFound
}
return &pgSingleResult{err: err}
}
doc, err := decodeRelationalDocument(raw)
return &pgSingleResult{doc: doc, err: err}
}
func (c *pgCollection) CountDocuments(ctx context.Context, filter Doc, _ ...*CountOptions) (int64, error) {
if err := c.valid(); err != nil {
return 0, err
}
args := []any{}
where, err := c.buildFilter(filter, &args)
if err != nil {
return 0, err
}
var count int64
if err := c.db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM "+c.spec.Table+" WHERE "+where, args...).Scan(&count); err != nil {
return 0, err
}
return count, nil
}
func (c *pgCollection) InsertOne(ctx context.Context, doc any, _ ...*InsertOneOptions) (*pgInsertResult, error) {
if err := c.valid(); err != nil {
return nil, err
}
normalized, err := normalizeRelationalDocument(doc)
if err != nil {
return nil, err
}
tx, err := c.db.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := c.writeDocument(ctx, tx, normalized, false); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &pgInsertResult{}, nil
}
func (c *pgCollection) ReplaceOne(ctx context.Context, filter any, replacement any, _ ...*ReplaceOptions) (*pgReplaceResult, error) {
if err := c.valid(); err != nil {
return nil, err
}
normalized, err := normalizeRelationalDocument(replacement)
if err != nil {
return nil, err
}
if _, ok := normalized["id"]; !ok {
if filterMap, ok := asDocMap(filter); ok {
if id, exists := filterMap["id"]; exists {
normalized["id"] = id
}
}
}
tx, err := c.db.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := c.writeDocument(ctx, tx, normalized, true); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &pgReplaceResult{}, nil
}
func (c *pgCollection) DeleteOne(ctx context.Context, filter Doc, _ ...*DeleteOptions) (*pgDeleteResult, error) {
if err := c.valid(); err != nil {
return nil, err
}
args := []any{}
where, err := c.buildFilter(filter, &args)
if err != nil {
return nil, err
}
query := "DELETE FROM " + relationalIdent(c.spec.Table) + " WHERE " + relationalIdent("id") + " = (SELECT " + relationalIdent("id") + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where + " LIMIT 1)"
tag, err := c.db.pool.Exec(ctx, query, args...)
if err != nil {
return nil, err
}
return &pgDeleteResult{DeletedCount: tag.RowsAffected()}, nil
}
func (c *pgCollection) DeleteMany(ctx context.Context, filter Doc, _ ...*DeleteOptions) (*pgDeleteResult, error) {
if err := c.valid(); err != nil {
return nil, err
}
args := []any{}
where, err := c.buildFilter(filter, &args)
if err != nil {
return nil, err
}
tag, err := c.db.pool.Exec(ctx, "DELETE FROM "+relationalIdent(c.spec.Table)+" WHERE "+where, args...)
if err != nil {
return nil, err
}
return &pgDeleteResult{DeletedCount: tag.RowsAffected()}, nil
}
func (c *pgCollection) FindOneAndUpdate(ctx context.Context, filter any, update any, _ ...*FindOneAndUpdateOptions) *pgSingleResult {
if err := c.valid(); err != nil {
return &pgSingleResult{err: err}
}
filterMap, ok := asDocMap(filter)
if !ok {
return &pgSingleResult{err: errors.New("invalid filter")}
}
updateMap, ok := asDocMap(update)
if !ok {
return &pgSingleResult{err: errors.New("invalid update")}
}
tx, err := c.db.pool.Begin(ctx)
if err != nil {
return &pgSingleResult{err: err}
}
defer tx.Rollback(ctx)
args := []any{}
where, err := c.buildFilter(filterMap, &args)
if err != nil {
return &pgSingleResult{err: err}
}
var raw []byte
query := "SELECT " + c.documentExpression() + " FROM " + relationalIdent(c.spec.Table) + " WHERE " + where + " LIMIT 1 FOR UPDATE"
if err := tx.QueryRow(ctx, query, args...).Scan(&raw); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
err = ErrNotFound
}
return &pgSingleResult{err: err}
}
doc, err := decodeRelationalDocument(raw)
if err != nil {
return &pgSingleResult{err: err}
}
applyRelationalUpdate(doc, updateMap)
if err := c.writeDocument(ctx, tx, doc, true); err != nil {
return &pgSingleResult{err: err}
}
if err := tx.Commit(ctx); err != nil {
return &pgSingleResult{err: err}
}
return &pgSingleResult{doc: doc}
}
func (c *pgCollection) UpdateMany(ctx context.Context, filter any, update any, _ ...*UpdateOptions) (*pgUpdateResult, error) {
if err := c.valid(); err != nil {
return nil, err
}
filterMap, ok := asDocMap(filter)
if !ok {
return nil, errors.New("invalid filter")
}
updateMap, ok := asDocMap(update)
if !ok {
return nil, errors.New("invalid update")
}
tx, err := c.db.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
args := []any{}
where, err := c.buildFilter(filterMap, &args)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT "+c.documentExpression()+" FROM "+relationalIdent(c.spec.Table)+" WHERE "+where+" FOR UPDATE", args...)
if err != nil {
return nil, err
}
docs := make([]Doc, 0)
for rows.Next() {
var raw []byte
if err := rows.Scan(&raw); err != nil {
rows.Close()
return nil, err
}
doc, err := decodeRelationalDocument(raw)
if err != nil {
rows.Close()
return nil, err
}
docs = append(docs, doc)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
var modified int64
for _, doc := range docs {
applyRelationalUpdate(doc, updateMap)
if err := c.writeDocument(ctx, tx, doc, true); err != nil {
return nil, err
}
modified++
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &pgUpdateResult{ModifiedCount: modified}, nil
}
type relationalExecer interface {
Exec(context.Context, string, ...any) (interface{ RowsAffected() int64 }, error)
}
type pgxExecer interface {
Exec(context.Context, string, ...any) (pgconnCommandTag, error)
}
type pgconnCommandTag interface {
RowsAffected() int64
}
func (c *pgCollection) writeDocument(ctx context.Context, exec interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}, doc Doc, upsert bool) error {
if err := c.validateDocument(doc); err != nil {
return err
}
id := strings.TrimSpace(documentString(doc["id"]))
if id == "" {
return errors.New("document id is required")
}
fields := c.spec.columnFields()
columns := make([]string, 0, len(fields))
placeholders := make([]string, 0, len(fields))
args := make([]any, 0, len(fields))
updateParts := make([]string, 0, len(fields))
for _, field := range fields {
value, exists := doc[field]
if !exists {
value = nil
if field == "createdAt" || field == "updatedAt" {
value = int64(0)
}
}
column := relationalColumnName(field)
columns = append(columns, relationalIdent(column))
args = append(args, relationalDBValue(c.spec, field, value))
placeholder := fmt.Sprintf("$%d", len(args))
if c.spec.isJSON(field) {
placeholder += "::jsonb"
}
placeholders = append(placeholders, placeholder)
if field != "id" {
updateParts = append(updateParts, relationalIdent(column)+" = EXCLUDED."+relationalIdent(column))
}
}
if len(columns) == 0 {
return errors.New("id must be present in relational write")
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", relationalIdent(c.spec.Table), strings.Join(columns, ", "), strings.Join(placeholders, ", "))
if upsert {
query += " ON CONFLICT (" + relationalIdent("id") + ") DO UPDATE SET " + strings.Join(updateParts, ", ")
}
if _, err := exec.Exec(ctx, query, args...); err != nil {
return err
}
return c.syncListRelations(ctx, exec, doc)
}
func (c *pgCollection) validateDocument(doc Doc) error {
for field, value := range doc {
if strings.HasPrefix(field, "_") {
continue
}
if c.spec.hasField(field) {
continue
}
if value == nil {
continue
}
return fmt.Errorf("field %q is not mapped for relational resource %s", field, c.spec.Name)
}
return nil
}
func (c *pgCollection) buildFilter(filter Doc, args *[]any) (string, error) {
if len(filter) == 0 {
return "TRUE", nil
}
keys := make([]string, 0, len(filter))
for key := range filter {
keys = append(keys, key)
}
sort.Strings(keys)
clauses := make([]string, 0, len(filter))
for _, field := range keys {
raw := filter[field]
if field == "$or" {
items, ok := asAnySlice(raw)
if !ok || len(items) == 0 {
return "", errors.New("$or requires a non-empty array")
}
parts := make([]string, 0, len(items))
for _, item := range items {
sub, ok := asDocMap(item)
if !ok {
return "", errors.New("$or entries must be objects")
}
clause, err := c.buildFilter(sub, args)
if err != nil {
return "", err
}
parts = append(parts, "("+clause+")")
}
clauses = append(clauses, "("+strings.Join(parts, " OR ")+")")
continue
}
if relation, ok := c.spec.ListRelations[field]; ok {
clause, err := c.listRelationFilterClause(relation, raw, args)
if err != nil {
return "", err
}
clauses = append(clauses, clause)
continue
}
if !c.spec.hasColumn(field) {
return "", fmt.Errorf("field %q is not queryable on %s", field, c.spec.Name)
}
column := relationalIdent(relationalColumnName(field))
if operators, ok := asDocMap(raw); ok && containsRelationalOperator(operators) {
for operator, operand := range operators {
clause, err := c.operatorClause(column, field, operator, operand, args)
if err != nil {
return "", err
}
clauses = append(clauses, clause)
}
continue
}
argsValue := relationalDBValue(c.spec, field, raw)
*args = append(*args, argsValue)
if c.spec.isJSON(field) {
clauses = append(clauses, fmt.Sprintf("%s = $%d::jsonb", column, len(*args)))
} else {
clauses = append(clauses, fmt.Sprintf("%s = $%d", column, len(*args)))
}
}
if len(clauses) == 0 {
return "TRUE", nil
}
return strings.Join(clauses, " AND "), nil
}
func (c *pgCollection) operatorClause(column, field, operator string, operand any, args *[]any) (string, error) {
switch operator {
case "$exists":
exists, ok := operand.(bool)
if !ok {
return "", errors.New("$exists requires a boolean")
}
if exists {
return column + " IS NOT NULL", nil
}
return column + " IS NULL", nil
case "$ne":
*args = append(*args, relationalDBValue(c.spec, field, operand))
if c.spec.isJSON(field) {
return fmt.Sprintf("COALESCE(%s <> $%d::jsonb, TRUE)", column, len(*args)), nil
}
return fmt.Sprintf("COALESCE(%s <> $%d, TRUE)", column, len(*args)), nil
case "$in":
values, ok := asAnySlice(operand)
if !ok || len(values) == 0 {
return "FALSE", nil
}
parts := make([]string, 0, len(values))
for _, value := range values {
*args = append(*args, relationalDBValue(c.spec, field, value))
if c.spec.isJSON(field) {
parts = append(parts, fmt.Sprintf("(%s = $%d::jsonb OR %s @> jsonb_build_array($%d::jsonb))", column, len(*args), column, len(*args)))
} else {
parts = append(parts, fmt.Sprintf("%s = $%d", column, len(*args)))
}
}
return "(" + strings.Join(parts, " OR ") + ")", nil
case "$gt", "$gte", "$lt", "$lte":
if c.spec.isJSON(field) {
return "", fmt.Errorf("ordered comparison is not supported for JSON field %s", field)
}
op := map[string]string{"$gt": ">", "$gte": ">=", "$lt": "<", "$lte": "<="}[operator]
*args = append(*args, relationalDBValue(c.spec, field, operand))
return fmt.Sprintf("%s %s $%d", column, op, len(*args)), nil
default:
return "", fmt.Errorf("unsupported relational operator %s", operator)
}
}
func (c *pgCollection) sortClause(raw any) string {
sortDoc, ok := raw.(Sort)
if !ok || len(sortDoc) == 0 {
return ""
}
parts := make([]string, 0, len(sortDoc))
for _, item := range sortDoc {
if !c.spec.hasColumn(item.Key) || c.spec.isJSON(item.Key) {
continue
}
direction := "ASC"
if number, ok := numericValue(item.Value); ok && number < 0 {
direction = "DESC"
}
parts = append(parts, relationalIdent(relationalColumnName(item.Key))+" "+direction+" NULLS LAST")
}
if len(parts) == 0 {
return ""
}
return " ORDER BY " + strings.Join(parts, ", ")
}
func (c *pgCollection) documentExpression() string {
parts := make([]string, 0, len(c.spec.allFields())*2)
for _, field := range c.spec.columnFields() {
parts = append(parts, fmt.Sprintf("'%s'", field), relationalIdent(relationalColumnName(field)))
}
relationFields := make([]string, 0, len(c.spec.ListRelations))
for field := range c.spec.ListRelations {
relationFields = append(relationFields, field)
}
sort.Strings(relationFields)
for _, field := range relationFields {
relation := c.spec.ListRelations[field]
expr := fmt.Sprintf(
"(SELECT jsonb_agg(%s ORDER BY %s) FROM %s rel WHERE rel.%s = %s.%s)",
"rel."+relationalIdent(relation.TargetColumn),
"rel."+relationalIdent(relation.TargetColumn),
relationalIdent(relation.Table),
relationalIdent(relation.OwnerColumn),
relationalIdent(c.spec.Table),
relationalIdent("id"),
)
parts = append(parts, fmt.Sprintf("'%s'", field), expr)
}
return "jsonb_strip_nulls(jsonb_build_object(" + strings.Join(parts, ", ") + "))"
}
func (c *pgCollection) syncListRelations(ctx context.Context, exec interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}, doc Doc) error {
ownerID := strings.TrimSpace(documentString(doc["id"]))
if ownerID == "" {
return errors.New("relation owner id is required")
}
fields := make([]string, 0, len(c.spec.ListRelations))
for field := range c.spec.ListRelations {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
relation := c.spec.ListRelations[field]
if _, err := exec.Exec(
ctx,
"DELETE FROM "+relationalIdent(relation.Table)+" WHERE "+relationalIdent(relation.OwnerColumn)+" = $1",
ownerID,
); err != nil {
return err
}
raw, exists := doc[field]
if !exists || raw == nil {
continue
}
values, ok := asAnySlice(raw)
if !ok {
return fmt.Errorf("%s.%s must be an array", c.spec.Name, field)
}
for _, value := range values {
target := strings.TrimSpace(documentString(value))
if target == "" {
continue
}
query := fmt.Sprintf(
"INSERT INTO %s (%s, %s) VALUES ($1, $2) ON CONFLICT DO NOTHING",
relationalIdent(relation.Table),
relationalIdent(relation.OwnerColumn),
relationalIdent(relation.TargetColumn),
)
if _, err := exec.Exec(ctx, query, ownerID, target); err != nil {
return err
}
}
}
return nil
}
func (c *pgCollection) listRelationFilterClause(relation relationalListRelation, raw any, args *[]any) (string, error) {
base := "SELECT 1 FROM " + relationalIdent(relation.Table) + " rel WHERE rel." +
relationalIdent(relation.OwnerColumn) + " = " + relationalIdent(c.spec.Table) + "." + relationalIdent("id")
if operators, ok := asDocMap(raw); ok && containsRelationalOperator(operators) {
parts := make([]string, 0, len(operators))
for operator, operand := range operators {
switch operator {
case "$exists":
exists, ok := operand.(bool)
if !ok {
return "", errors.New("$exists requires a boolean")
}
if exists {
parts = append(parts, "EXISTS ("+base+")")
} else {
parts = append(parts, "NOT EXISTS ("+base+")")
}
case "$in":
values, ok := asAnySlice(operand)
if !ok || len(values) == 0 {
parts = append(parts, "FALSE")
continue
}
matches := make([]string, 0, len(values))
for _, value := range values {
*args = append(*args, documentString(value))
matches = append(matches, "rel."+relationalIdent(relation.TargetColumn)+fmt.Sprintf(" = $%d", len(*args)))
}
parts = append(parts, "EXISTS ("+base+" AND ("+strings.Join(matches, " OR ")+"))")
case "$ne":
*args = append(*args, documentString(operand))
parts = append(parts, "NOT EXISTS ("+base+" AND rel."+relationalIdent(relation.TargetColumn)+fmt.Sprintf(" = $%d)", len(*args)))
default:
return "", fmt.Errorf("unsupported relation operator %s", operator)
}
}
return "(" + strings.Join(parts, " AND ") + ")", nil
}
*args = append(*args, documentString(raw))
return "EXISTS (" + base + " AND rel." + relationalIdent(relation.TargetColumn) + fmt.Sprintf(" = $%d)", len(*args)), nil
}
func relationalDBValue(spec relationalResourceSpec, field string, value any) any {
if value == nil {
return nil
}
if spec.isJSON(field) {
raw, err := json.Marshal(value)
if err != nil {
return "null"
}
return string(raw)
}
if isBooleanResourceField(spec.Name, field) {
return anyToBool(value)
}
if isNumericField(field) {
number, ok := numericValue(value)
if !ok {
return int64(0)
}
if field == "score" || field == "progress" {
return number
}
return int64(number)
}
switch typed := value.(type) {
case time.Time:
return typed.UTC().Format(time.RFC3339Nano)
default:
return documentString(value)
}
}
func (r *pgSingleResult) Decode(target any) error {
if r.err != nil {
return r.err
}
return assignRelationalDocument(target, r.doc)
}
func (c *pgCursor) Close(context.Context) error {
return nil
}
func (c *pgCursor) All(_ context.Context, target any) error {
switch typed := target.(type) {
case *[]Doc:
out := make([]Doc, len(c.docs))
for i, doc := range c.docs {
out[i] = cloneDoc(doc)
}
*typed = out
return nil
default:
raw, err := json.Marshal(c.docs)
if err != nil {
return err
}
return json.Unmarshal(raw, target)
}
}
func (c *pgCursor) Next(context.Context) bool {
if c.index >= len(c.docs) {
return false
}
c.index++
return true
}
func (c *pgCursor) Decode(target any) error {
if c.index == 0 || c.index > len(c.docs) {
return errors.New("cursor is not positioned on a row")
}
return assignRelationalDocument(target, c.docs[c.index-1])
}
func assignRelationalDocument(target any, doc Doc) error {
switch typed := target.(type) {
case *Doc:
*typed = cloneDoc(doc)
return nil
default:
raw, err := json.Marshal(doc)
if err != nil {
return err
}
return json.Unmarshal(raw, target)
}
}
func normalizeRelationalDocument(value any) (Doc, error) {
switch typed := value.(type) {
case Doc:
return cloneDoc(typed), nil
case map[string]any:
return cloneDoc(Doc(typed)), nil
default:
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
var doc Doc
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
return doc, nil
}
}
func decodeRelationalDocument(raw []byte) (Doc, error) {
var doc Doc
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
return doc, nil
}
func cloneDoc(doc Doc) Doc {
out := make(Doc, len(doc))
for key, value := range doc {
out[key] = value
}
return out
}
func applyRelationalUpdate(doc Doc, update Doc) {
if setRaw, ok := update["$set"]; ok {
if set, ok := asDocMap(setRaw); ok {
for key, value := range set {
doc[key] = value
}
}
}
if unsetRaw, ok := update["$unset"]; ok {
if unset, ok := asDocMap(unsetRaw); ok {
for key := range unset {
delete(doc, key)
}
}
}
}
func containsRelationalOperator(value Doc) bool {
for key := range value {
if strings.HasPrefix(key, "$") {
return true
}
}
return false
}
func asDocMap(value any) (Doc, bool) {
switch typed := value.(type) {
case Doc:
return typed, true
case map[string]any:
return Doc(typed), true
default:
return nil, false
}
}
func asAnySlice(value any) ([]any, bool) {
switch typed := value.(type) {
case List:
return []any(typed), true
case []any:
return typed, true
case []string:
out := make([]any, len(typed))
for i, value := range typed {
out[i] = value
}
return out, true
default:
return nil, false
}
}
func documentString(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return text
}
return fmt.Sprint(value)
}
func documentInt64(value any) int64 {
number, ok := numericValue(value)
if !ok {
return 0
}
return int64(number)
}

View File

@ -0,0 +1,60 @@
package main
import (
"errors"
"strings"
)
type publishingPolicy interface {
defaultStatus() string
isAllowedStatus(status string) bool
invalidStatusMessage() string
validate(payload Doc, existing Doc) error
}
func applyPublishingPolicy(payload Doc, partial bool, existing Doc, policy publishingPolicy) error {
status, hasStatus, err := getOptionalString(payload, "status")
if err != nil {
return errors.New("status must be a string")
}
if !hasStatus && !partial {
status = policy.defaultStatus()
hasStatus = true
}
if hasStatus {
normalizedStatus := strings.TrimSpace(strings.ToLower(status))
if !policy.isAllowedStatus(normalizedStatus) {
return errors.New(policy.invalidStatusMessage())
}
payload["status"] = normalizedStatus
}
return policy.validate(payload, existing)
}
func mergedPublishingValue(payload Doc, existing Doc, field string) (any, bool) {
if value, ok := payload[field]; ok {
return value, true
}
if existing == nil {
return nil, false
}
value, ok := existing[field]
return value, ok
}
func mergedPublishingString(payload Doc, existing Doc, field string) (string, bool) {
value, ok := mergedPublishingValue(payload, existing, field)
if !ok {
return "", false
}
text, ok := value.(string)
if !ok {
return "", false
}
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return "", false
}
return strings.ToLower(trimmed), true
}

View File

@ -0,0 +1,565 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
)
type relationalResourceSpec struct {
Name string
Table string
Fields []string
JSONFields map[string]struct{}
References map[string]relationalReference
ListRelations map[string]relationalListRelation
}
type relationalListRelation struct {
Table string
OwnerColumn string
TargetColumn string
TargetTable string
}
type relationalReference struct {
Table string
OnDelete string
}
var relationalResources = buildRelationalResourceSpecs()
func buildRelationalResourceSpecs() map[string]relationalResourceSpec {
specs := []relationalResourceSpec{
resource("tenants", []string{"name", "slug", "description", "status"}, nil, nil),
resource("users", []string{"name", "displayName", "email", "username", "keycloakUsername", "role", "status", "gdprErasedAt"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("pages", []string{"name", "title", "slug", "status", "visibility", "publishedAt", "layout", "blocks", "content", "seo"}, []string{"layout", "blocks", "content", "seo"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("datasources", []string{"name", "description", "type", "status", "config"}, []string{"config"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("scorm_courses", []string{"title", "description", "status", "version", "visibility", "launchUrl", "scos", "manifest"}, []string{"scos", "manifest"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("scorm_exports", []string{"courseId", "version", "storageKey"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("scorm_courses", "CASCADE")}),
resource("enrollments", []string{"userId", "courseId", "courseType", "status", "score", "progress", "progressNote", "timeSpentMin", "completedAt", "archivedAt", "archivedReason", "erasedUserId", "gdprErasedAt"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("authoring_runs", []string{"userId", "status", "brief", "proposalRevision", "source", "reviewUrl", "requiresCMSApproval", "proposal", "validation", "idempotencyKeys", "theme", "site", "page"}, []string{"proposal", "validation", "idempotencyKeys", "theme", "site", "page"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("consent_receipts", []string{"userId", "policyVersion", "policyUrl", "analytics", "marketing", "preferences", "necessary", "revokedAt", "revokedReason", "categories", "receipt"}, []string{"categories", "receipt"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("data_subject_requests", []string{"userId", "type", "status", "requestedAt", "decidedAt", "mode", "reason", "archivedAt", "archivedReason", "payload", "recordTypes"}, []string{"payload", "recordTypes"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("webhooks", []string{"name", "url", "secret", "status", "type", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("forms", []string{"name", "title", "slug", "description", "status", "schema", "fields"}, []string{"schema", "fields"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("form_submissions", []string{"formId", "userId", "submittedAt", "values", "data", "metadata"}, []string{"values", "data", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "formId": ref("forms", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("sitemap_menus", []string{"name", "description", "items"}, []string{"items"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("audit_events", []string{"actorUserId", "actor", "action", "resource", "resourceId", "result", "timestamp", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "actorUserId": ref("users", "SET NULL")}),
resource("assets", []string{"title", "name", "fileName", "description", "status", "visibility", "ownerUserId", "ownerName", "relatedResourceType", "relatedResourceId", "entitlementKey", "protectionLevel", "sourceUrl", "attributionText", "claimStatus", "licence", "license", "requiresAttribution", "allowDownload", "allowReuse", "allowModification", "allowCommercialUse", "encryption", "copyright", "metadata"}, []string{"encryption", "copyright", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "ownerUserId": ref("users", "SET NULL")}),
resource("asset_access_events", []string{"assetId", "userId", "eventType", "submittedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("asset_claims", []string{"assetId", "userId", "status", "claimStatus", "submittedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "assetId": ref("assets", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("notification_templates", []string{"name", "channel", "subject", "body", "enabled", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("notification_preferences", []string{"userId", "email", "inApp", "sms", "push", "enabled", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("notification_deliveries", []string{"userId", "templateId", "channel", "to", "subject", "body", "status", "queuedAt", "sentAt", "deliveredAt", "failedAt", "deferredAt", "throttledAt", "provider", "providerMessageId", "throttleReason", "lastError", "idempotencyKey", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}),
resource("notification_queue", []string{"deliveryId", "channel", "to", "subject", "body", "templateId", "status", "attemptCount", "nextAttemptAt", "providerHint", "fallbackProvider", "idempotencyKey", "lastError", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "templateId": ref("notification_templates", "SET NULL")}),
resource("notification_delivery_attempts", []string{"deliveryId", "queueId", "attempt", "attemptStatus", "errorMessage", "providerMessageId"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "CASCADE"), "queueId": ref("notification_queue", "SET NULL")}),
resource("notification_dead_letters", []string{"deliveryId", "queueId", "channel", "to", "subject", "body", "templateId", "idempotencyKey", "providerHint", "fallbackProvider", "attemptCount", "reason", "movedAt", "replayedAt", "replayId", "deadLetterId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "deliveryId": ref("notification_deliveries", "SET NULL"), "templateId": ref("notification_templates", "SET NULL")}),
resource("notification_rate_limit_policies", []string{"channel", "throughputPerMinute", "burst", "maxRetries", "backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour", "perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "channelEnabled", "throttledAction", "fallbackProvider"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("notification_provider_configs", []string{"provider", "enabled", "apiKey", "fromEmail", "apiBaseURL", "credentialMode", "source", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("notifications", []string{"userId", "title", "message", "read", "channel", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("blog_categories", []string{"name", "slug", "description", "status"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("blog_tags", []string{"name", "slug", "description", "status"}, nil,
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("blog_authors", []string{"name", "slug", "email", "bio", "userId", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("blog_posts", []string{"title", "slug", "status", "visibility", "excerpt", "content", "authorId", "categoryId", "publishedAt", "scheduledFor", "featured", "featuredImage", "seo", "metadata"}, []string{"content", "featuredImage", "seo", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "authorId": ref("blog_authors", "SET NULL"), "categoryId": ref("blog_categories", "SET NULL")}),
resource("entitlements", []string{"userId", "key", "value", "active", "status", "scope", "source", "reason", "startsAt", "endsAt", "revokedAt", "graceAt", "convertedFromTrialAt", "convertedFromEntitlementId", "initiatedByUserId", "lastUpdatedByUserId", "platformAccountId", "sourceEventId", "stripeEventId", "stripeEventType", "checkoutSessionId", "metadata"}, []string{"value", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "CASCADE"), "convertedFromEntitlementId": ref("entitlements", "SET NULL")}),
resource("live_courses", []string{"title", "slug", "status", "visibility", "description", "deliveryMode", "level", "durationSummary", "communityId", "entitlementKey", "priceId", "organisationId", "publishedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("live_course_cohorts", []string{"courseId", "liveCourseId", "name", "status", "startsAt", "endsAt", "timezone", "capacity", "enrolledCount", "waitlistEnabled", "priceId", "meetingProvider", "defaultMeetingUrl", "organisationId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "courseId": ref("live_courses", "CASCADE"), "liveCourseId": ref("live_courses", "CASCADE")}),
resource("live_course_sessions", []string{"courseId", "liveCourseId", "cohortId", "title", "status", "startsAt", "endsAt", "meetingUrl", "recordingAssetId", "communityThreadId", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "recordingAssetId": ref("assets", "SET NULL")}),
resource("live_course_enrollments", []string{"courseId", "liveCourseId", "cohortId", "userId", "status", "source", "enrolledAt", "completedAt", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "cohortId": ref("live_course_cohorts", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("live_course_attendance", []string{"courseId", "liveCourseId", "cohortId", "sessionId", "enrollmentId", "userId", "status", "joinedAt", "leftAt", "durationSeconds", "notes", "markedBy", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "sessionId": ref("live_course_sessions", "CASCADE"), "enrollmentId": ref("live_course_enrollments", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("communities", []string{"name", "slug", "description", "status", "visibility", "accessPolicy", "requiredEntitlementKey", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("community_channels", []string{"communityId", "name", "slug", "topic", "isPrivate", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE")}),
resource("community_memberships", []string{"communityId", "userId", "role", "status", "source", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "userId": ref("users", "CASCADE")}),
resource("community_posts", []string{"communityId", "channelId", "authorId", "body", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "communityId": ref("communities", "CASCADE"), "channelId": ref("community_channels", "CASCADE"), "authorId": ref("users", "SET NULL")}),
resource("community_replies", []string{"postId", "authorId", "body", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "postId": ref("community_posts", "CASCADE"), "authorId": ref("users", "SET NULL")}),
resource("keycloak_themes", []string{"name", "slug", "version", "status", "scope", "type", "target", "organizationId", "realm", "client", "createdBy", "updatedBy", "sections", "tokens", "metadata"}, []string{"target", "sections", "tokens", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("keycloak_theme_policy", []string{"organizationId", "allowList", "policy", "metadata"}, []string{"allowList", "policy", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
resource("keycloak_theme_exports", []string{"themeId", "themeSlug", "checksum", "format", "baseDir", "fileCount", "files", "previewOnly", "metadata"}, []string{"files", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}),
resource("keycloak_theme_deployments_current", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "CASCADE")}),
resource("keycloak_theme_deployments_audit", []string{"deploymentKey", "themeId", "themeSlug", "version", "status", "summary", "target", "realm", "client", "organizationId", "actor", "deployedBy", "deployedAt", "rollback", "rollbackThemeId", "rollbackVersion", "errorMessage"}, []string{"target", "rollback"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "themeId": ref("keycloak_themes", "SET NULL")}),
resource("platform_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil),
resource("admin_settings", []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, []string{"platformIdentity", "seoDefaults", "consentLinks", "integrations", "features", "branding", "metadata"}, nil),
resource("billing_customers", []string{"userId", "stripeCustomerId", "email", "lastInitiatedByUserId", "scope", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL")}),
resource("billing_events", []string{"provider", "type", "status", "receivedAt", "stripeCreatedAt", "platformAccountId", "userId", "initiatedByUserId", "processedAt", "ignored", "reason", "scope", "billingCustomerId", "initiatedByTenantId", "isPlatformActor", "checkoutSessionId", "archivedAt", "archivedReason", "data", "metadata"}, []string{"data", "metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE"), "userId": ref("users", "SET NULL"), "billingCustomerId": ref("billing_customers", "SET NULL")}),
resource("billing_price_policies", []string{"priceId", "entitlementKey", "mode", "scope", "durationDays", "trialDays", "graceDays", "status", "metadata"}, []string{"metadata"},
map[string]relationalReference{"tenantId": ref("tenants", "CASCADE")}),
}
for i := range specs {
switch specs[i].Name {
case "blog_posts":
specs[i].ListRelations = map[string]relationalListRelation{
"tagIds": {Table: "blog_post_tags", OwnerColumn: "post_id", TargetColumn: "tag_id", TargetTable: "blog_tags"},
}
case "live_courses":
specs[i].ListRelations = map[string]relationalListRelation{
"instructorIds": {Table: "live_course_instructors", OwnerColumn: "live_course_id", TargetColumn: "user_id", TargetTable: "users"},
"assetIds": {Table: "live_course_assets", OwnerColumn: "live_course_id", TargetColumn: "asset_id", TargetTable: "assets"},
}
case "communities":
specs[i].ListRelations = map[string]relationalListRelation{
"allowedRoles": {Table: "community_allowed_roles", OwnerColumn: "community_id", TargetColumn: "role"},
}
}
}
out := make(map[string]relationalResourceSpec, len(specs))
for _, spec := range specs {
out[spec.Name] = spec
}
return out
}
func resource(name string, fields []string, jsonFields []string, references map[string]relationalReference) relationalResourceSpec {
jsonSet := map[string]struct{}{}
for _, field := range jsonFields {
jsonSet[field] = struct{}{}
}
return relationalResourceSpec{
Name: name,
Table: name,
Fields: append([]string{}, fields...),
JSONFields: jsonSet,
References: references,
ListRelations: nil,
}
}
func ref(table, onDelete string) relationalReference {
return relationalReference{Table: table, OnDelete: onDelete}
}
func (s relationalResourceSpec) columnFields() []string {
fields := []string{"id", "tenantId", "createdAt", "updatedAt"}
fields = append(fields, s.Fields...)
return uniqueStrings(fields)
}
func (s relationalResourceSpec) allFields() []string {
fields := s.columnFields()
for field := range s.ListRelations {
fields = append(fields, field)
}
return uniqueStrings(fields)
}
func (s relationalResourceSpec) hasField(field string) bool {
for _, candidate := range s.allFields() {
if candidate == field {
return true
}
}
return false
}
func (s relationalResourceSpec) hasColumn(field string) bool {
for _, candidate := range s.columnFields() {
if candidate == field {
return true
}
}
return false
}
func (s relationalResourceSpec) isJSON(field string) bool {
_, ok := s.JSONFields[field]
return ok
}
func relationalColumnName(field string) string {
if field == "tenantId" {
return "tenant_id"
}
var b strings.Builder
for i, r := range field {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('_')
}
b.WriteRune(r + ('a' - 'A'))
continue
}
b.WriteRune(r)
}
return b.String()
}
func relationalSQLType(spec relationalResourceSpec, field string) string {
if spec.isJSON(field) {
return "JSONB"
}
if isBooleanResourceField(spec.Name, field) {
return "BOOLEAN"
}
if isNumericField(field) {
if field == "score" || field == "progress" {
return "DOUBLE PRECISION"
}
return "BIGINT"
}
return "TEXT"
}
func isBooleanResourceField(resourceName, field string) bool {
switch resourceName {
case "consent_receipts":
return field == "analytics" || field == "marketing" || field == "preferences" || field == "necessary"
case "assets":
return field == "requiresAttribution" || field == "allowDownload" || field == "allowReuse" || field == "allowModification" || field == "allowCommercialUse"
case "notification_templates":
return field == "enabled"
case "notification_preferences":
return field == "email" || field == "inApp" || field == "sms" || field == "push" || field == "enabled"
case "notification_rate_limit_policies":
return field == "channelEnabled"
case "notification_provider_configs":
return field == "enabled"
case "notifications":
return field == "read"
case "entitlements":
return field == "active"
case "live_course_cohorts":
return field == "waitlistEnabled"
case "community_channels":
return field == "isPrivate"
case "authoring_runs":
return field == "requiresCMSApproval"
case "blog_posts":
return field == "featured"
case "keycloak_theme_exports":
return field == "previewOnly"
case "billing_events":
return field == "ignored" || field == "isPlatformActor"
default:
return false
}
}
func isNumericField(field string) bool {
if strings.HasSuffix(field, "At") {
return true
}
switch field {
case "createdAt", "updatedAt", "score", "progress", "timeSpentMin", "proposalRevision",
"fileCount", "capacity", "enrolledCount", "durationSeconds", "quantity", "attempt",
"attemptCount", "retryCount", "throughputPerMinute", "burst", "maxRetries",
"backoffSeconds", "maxBackoffSeconds", "deferredSeconds", "perTemplatePerHour",
"perRecipientPerHour", "quietHoursStartUtc", "quietHoursEndUtc", "durationDays",
"trialDays", "graceDays", "limit":
return true
default:
return false
}
}
func (d *pgDatabase) ensureRelationalSchema(ctx context.Context) error {
if _, err := d.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return err
}
names := make([]string, 0, len(relationalResources))
for name := range relationalResources {
names = append(names, name)
}
sort.SliceStable(names, func(i, j int) bool {
return relationalTableRank(names[i]) < relationalTableRank(names[j])
})
for _, name := range names {
spec := relationalResources[name]
if err := d.ensureRelationalTable(ctx, spec, false); err != nil {
return fmt.Errorf("create %s: %w", spec.Table, err)
}
}
for _, name := range names {
spec := relationalResources[name]
if err := d.ensureRelationalReferences(ctx, spec); err != nil {
return fmt.Errorf("reference %s: %w", spec.Table, err)
}
}
for _, name := range names {
if err := d.ensureListRelationTables(ctx, relationalResources[name]); err != nil {
return fmt.Errorf("list relations %s: %w", name, err)
}
}
if err := d.migrateLegacyDocumentTable(ctx); err != nil {
return fmt.Errorf("migrate legacy document store: %w", err)
}
_, err := d.pool.Exec(ctx, `INSERT INTO schema_migrations(version)
VALUES ('20260928_relational_v1')
ON CONFLICT (version) DO NOTHING`)
return err
}
func (d *pgDatabase) ensureListRelationTables(ctx context.Context, spec relationalResourceSpec) error {
fields := make([]string, 0, len(spec.ListRelations))
for field := range spec.ListRelations {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
relation := spec.ListRelations[field]
query := fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %s (%s TEXT NOT NULL REFERENCES %s(id) ON DELETE CASCADE, %s TEXT NOT NULL, PRIMARY KEY (%s, %s))",
relationalIdent(relation.Table),
relationalIdent(relation.OwnerColumn),
relationalIdent(spec.Table),
relationalIdent(relation.TargetColumn),
relationalIdent(relation.OwnerColumn),
relationalIdent(relation.TargetColumn),
)
if _, err := d.pool.Exec(ctx, query); err != nil {
return err
}
if relation.TargetTable != "" {
constraint := relation.Table + "_" + relation.TargetColumn + "_fk"
alter := fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = '%s') THEN
ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s)
REFERENCES %s(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
END IF;
END $$;`,
constraint,
relationalIdent(relation.Table),
relationalIdent(constraint),
relationalIdent(relation.TargetColumn),
relationalIdent(relation.TargetTable),
)
if _, err := d.pool.Exec(ctx, alter); err != nil {
return err
}
}
}
return nil
}
func (d *pgDatabase) migrateLegacyDocumentTable(ctx context.Context) error {
var legacyTable *string
if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents')::text").Scan(&legacyTable); err != nil {
return err
}
if legacyTable == nil || strings.TrimSpace(*legacyTable) == "" {
return nil
}
var archiveTable *string
if err := d.pool.QueryRow(ctx, "SELECT to_regclass('public.cms_documents_legacy_20260928')::text").Scan(&archiveTable); err != nil {
return err
}
if archiveTable != nil && strings.TrimSpace(*archiveTable) != "" {
return fmt.Errorf("legacy archive table already exists")
}
rows, err := d.pool.Query(ctx, "SELECT collection, payload FROM cms_documents ORDER BY collection, id")
if err != nil {
return err
}
grouped := map[string][]Doc{}
for rows.Next() {
var collection string
var raw []byte
if err := rows.Scan(&collection, &raw); err != nil {
rows.Close()
return err
}
if _, ok := relationalResources[collection]; !ok {
rows.Close()
return fmt.Errorf("legacy collection %q has no relational mapping", collection)
}
var doc Doc
if err := json.Unmarshal(raw, &doc); err != nil {
rows.Close()
return fmt.Errorf("decode %s legacy row: %w", collection, err)
}
grouped[collection] = append(grouped[collection], doc)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
tx, err := d.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, "SET CONSTRAINTS ALL DEFERRED"); err != nil {
return err
}
names := make([]string, 0, len(grouped))
for name := range grouped {
names = append(names, name)
}
sort.SliceStable(names, func(i, j int) bool {
return relationalTableRank(names[i]) < relationalTableRank(names[j])
})
for _, name := range names {
collection := &pgCollection{db: d, spec: relationalResources[name]}
for _, doc := range grouped[name] {
if err := collection.writeDocument(ctx, tx, doc, true); err != nil {
return fmt.Errorf("%s row %v: %w", name, doc["id"], err)
}
}
}
if _, err := tx.Exec(ctx, `ALTER TABLE "cms_documents" RENAME TO "cms_documents_legacy_20260928"`); err != nil {
return err
}
return tx.Commit(ctx)
}
func (d *pgDatabase) ensureRelationalTable(ctx context.Context, spec relationalResourceSpec, _ bool) error {
columns := []string{relationalIdent("id") + " TEXT PRIMARY KEY"}
for _, field := range spec.columnFields() {
if field == "id" {
continue
}
column := relationalColumnName(field)
sqlType := relationalSQLType(spec, field)
definition := fmt.Sprintf("%s %s", relationalIdent(column), sqlType)
if field == "createdAt" || field == "updatedAt" {
definition += " NOT NULL DEFAULT 0"
}
columns = append(columns, definition)
}
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", relationalIdent(spec.Table), strings.Join(columns, ", "))
if _, err := d.pool.Exec(ctx, query); err != nil {
return err
}
for _, field := range []string{"tenantId", "updatedAt", "status", "slug", "userId", "courseId", "communityId", "cohortId"} {
if !spec.hasColumn(field) {
continue
}
column := relationalColumnName(field)
indexName := fmt.Sprintf("%s_%s_idx", spec.Table, column)
if _, err := d.pool.Exec(ctx, fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", relationalIdent(indexName), relationalIdent(spec.Table), relationalIdent(column))); err != nil {
return err
}
}
return nil
}
func (d *pgDatabase) ensureRelationalReferences(ctx context.Context, spec relationalResourceSpec) error {
fields := make([]string, 0, len(spec.References))
for field := range spec.References {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if !spec.hasField(field) {
continue
}
reference := spec.References[field]
column := relationalColumnName(field)
constraint := fmt.Sprintf("%s_%s_fk", spec.Table, column)
query := fmt.Sprintf(`DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = '%s'
) THEN
ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s)
REFERENCES %s(id) ON DELETE %s DEFERRABLE INITIALLY DEFERRED;
END IF;
END $$;`, constraint, relationalIdent(spec.Table), relationalIdent(constraint), relationalIdent(column), relationalIdent(reference.Table), reference.OnDelete)
if _, err := d.pool.Exec(ctx, query); err != nil {
return err
}
}
return nil
}
func relationalTableRank(name string) int {
switch name {
case "tenants":
return 0
case "users", "blog_categories", "blog_tags", "blog_authors", "forms", "scorm_courses", "assets", "communities", "live_courses", "notification_templates", "billing_customers", "keycloak_themes":
return 10
case "community_channels", "live_course_cohorts", "notification_deliveries":
return 20
case "live_course_sessions", "live_course_enrollments", "community_posts", "notification_queue":
return 30
case "live_course_attendance", "community_replies", "notification_delivery_attempts":
return 40
default:
return 50
}
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func relationalIdent(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}

402
Backend/cmd/api/routes.go Normal file
View File

@ -0,0 +1,402 @@
package main
import (
"context"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
)
func (a *app) routes() http.Handler {
r := chi.NewRouter()
r.Use(chimiddleware.RequestID)
r.Use(chimiddleware.RealIP)
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)
a.registerPublicRoutes(r)
a.registerAuthRoutes(r)
r.Get("/api/health", a.healthHandler)
r.Route("/api", func(r chi.Router) {
r.Use(a.authMiddleware)
a.registerCoreAPIRoutes(r)
a.registerEntitlementsRoutes(r)
a.registerKeycloakThemeRoutes(r)
a.registerLiveCourseRoutes(r)
a.registerLearnerRoutes(r)
a.registerFormsRoutes(r)
a.registerSitemapMenuRoutes(r)
a.registerNotificationRoutes(r)
a.registerAdminRoutes(r)
a.registerAssetRoutes(r)
a.registerCommunityRoutes(r)
a.registerBlogRoutes(r)
})
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "route not found")
})
return withCORS(a.cfg.CorsOrigins, r)
}
func (a *app) healthHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
defer cancel()
postgresStatus := "up"
if a.db == nil || a.db.Ping(ctx) != nil {
postgresStatus = "down"
}
status := "ok"
if postgresStatus != "up" {
status = "degraded"
}
writeJSON(w, http.StatusOK, map[string]any{
"status": status,
"service": "cms-backend",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"deps": map[string]string{
"postgres": postgresStatus,
},
})
}
func (a *app) loginHandler(w http.ResponseWriter, r *http.Request) {
writeRouteNotEnabled(
w,
"/auth/login",
"local login is disabled",
"use Keycloak OAuth authorization code flow from the frontend client",
"docs/BACKLOG.md",
)
}
func (a *app) refreshHandler(w http.ResponseWriter, r *http.Request) {
writeRouteNotEnabled(
w,
"/auth/refresh",
"backend token refresh endpoint is disabled",
"refresh is handled by Keycloak in the frontend oauth client",
"docs/BACKLOG.md",
)
}
func (a *app) logoutHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func writeRouteNotEnabled(w http.ResponseWriter, routePath, errMsg, detail, backlogRef string) {
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": errMsg,
"message": detail,
"route": routePath,
"status": "not_enabled",
"backlogRef": backlogRef,
})
}
func (a *app) meHandler(w http.ResponseWriter, r *http.Request) {
claims, _ := r.Context().Value(userClaimsKey).(*accessClaims)
if claims == nil {
writeError(w, http.StatusUnauthorized, "no authenticated user")
return
}
role := "learner"
if hasRole(claims, "admin") {
role = "admin"
} else if hasAnyRole(claims, "platform_owner", "platform_admin") {
role = "platform_admin"
} else if hasRole(claims, "instructor") {
role = "instructor"
}
writeJSON(w, http.StatusOK, map[string]any{
"id": resolveClaimsUserID(claims),
"email": claims.Email,
"username": claims.PreferredUsername,
"role": role,
"roles": claims.RealmAccess.Roles,
"tenantId": resolveClaimsTenantID(claims),
})
}
func (a *app) listPages(w http.ResponseWriter, r *http.Request) {
a.listCollectionWithRequest("pages", w, r)
}
func (a *app) getPage(w http.ResponseWriter, r *http.Request) { a.getCollection("pages", w, r) }
func (a *app) deletePage(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("pages", w, r)
}
func (a *app) listDataSources(w http.ResponseWriter, r *http.Request) {
a.listCollectionWithRequest("datasources", w, r)
}
func (a *app) createDataSource(w http.ResponseWriter, r *http.Request) {
a.createCollection("datasources", w, r)
}
func (a *app) getDataSource(w http.ResponseWriter, r *http.Request) {
a.getCollection("datasources", w, r)
}
func (a *app) patchDataSource(w http.ResponseWriter, r *http.Request) {
a.patchCollection("datasources", w, r)
}
func (a *app) deleteDataSource(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("datasources", w, r)
}
func (a *app) listScormCourses(w http.ResponseWriter, r *http.Request) {
a.listCollectionWithRequest("scorm_courses", w, r)
}
func (a *app) createScormCourse(w http.ResponseWriter, r *http.Request) {
a.createCollection("scorm_courses", w, r)
}
func (a *app) getScormCourse(w http.ResponseWriter, r *http.Request) {
a.getCollection("scorm_courses", w, r)
}
func (a *app) patchScormCourse(w http.ResponseWriter, r *http.Request) {
a.patchCollection("scorm_courses", w, r)
}
func (a *app) deleteScormCourse(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("scorm_courses", w, r)
}
func (a *app) listUsers(w http.ResponseWriter, r *http.Request) {
a.listCollectionWithRequest("users", w, r)
}
func (a *app) createUser(w http.ResponseWriter, r *http.Request) { a.createCollection("users", w, r) }
func (a *app) getUser(w http.ResponseWriter, r *http.Request) { a.getCollection("users", w, r) }
func (a *app) patchUser(w http.ResponseWriter, r *http.Request) { a.patchCollection("users", w, r) }
func (a *app) deleteUser(w http.ResponseWriter, r *http.Request) { a.deleteCollection("users", w, r) }
func (a *app) listEnrollments(w http.ResponseWriter, r *http.Request) {
a.listCollectionWithRequest("enrollments", w, r)
}
func (a *app) createEnrollment(w http.ResponseWriter, r *http.Request) {
a.createCollection("enrollments", w, r)
}
func (a *app) getEnrollment(w http.ResponseWriter, r *http.Request) {
a.getCollection("enrollments", w, r)
}
func (a *app) patchEnrollment(w http.ResponseWriter, r *http.Request) {
a.patchCollection("enrollments", w, r)
}
func (a *app) deleteEnrollment(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("enrollments", w, r)
}
func (a *app) listWebhooks(w http.ResponseWriter, r *http.Request) {
a.listCollectionWithRequest("webhooks", w, r)
}
func (a *app) createWebhook(w http.ResponseWriter, r *http.Request) {
a.createCollection("webhooks", w, r)
}
func (a *app) getWebhook(w http.ResponseWriter, r *http.Request) { a.getCollection("webhooks", w, r) }
func (a *app) patchWebhook(w http.ResponseWriter, r *http.Request) {
a.patchCollection("webhooks", w, r)
}
func (a *app) deleteWebhook(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("webhooks", w, r)
}
func (a *app) listCollection(name string, w http.ResponseWriter) {
a.listCollectionWithRequest(name, w, nil)
}
func (a *app) listCollectionWithRequest(name string, w http.ResponseWriter, r *http.Request) {
tenantID, enforceTenant, scopeErr := requestTenantScope(r, name)
if scopeErr != nil {
writeError(w, http.StatusForbidden, scopeErr.Error())
return
}
filter := Doc{}
if enforceTenant {
filter["tenantId"] = tenantID
}
docs, err := a.list(name, filter, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list resources")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) createCollection(name string, w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
tenantID, enforceTenant, scopeErr := requestTenantScope(r, name)
if scopeErr != nil {
writeError(w, http.StatusForbidden, scopeErr.Error())
return
}
if enforceTenant {
payload["tenantId"] = tenantID
}
doc, err := a.create(name, Doc(payload))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create resource")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getCollection(name string, w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
tenantID, enforceTenant, scopeErr := requestTenantScope(r, name)
if scopeErr != nil {
writeError(w, http.StatusForbidden, scopeErr.Error())
return
}
doc, err := a.getByIDScoped(name, id, tenantID, enforceTenant)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load resource")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) patchCollection(name string, w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var patch map[string]any
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
tenantID, enforceTenant, scopeErr := requestTenantScope(r, name)
if scopeErr != nil {
writeError(w, http.StatusForbidden, scopeErr.Error())
return
}
if enforceTenant {
delete(patch, "tenantId")
}
doc, err := a.patchWithUnsetScoped(name, id, Doc(patch), nil, tenantID, enforceTenant)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update resource")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteCollection(name string, w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
tenantID, enforceTenant, scopeErr := requestTenantScope(r, name)
if scopeErr != nil {
writeError(w, http.StatusForbidden, scopeErr.Error())
return
}
if err := a.deleteByIDScoped(name, id, tenantID, enforceTenant); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete resource")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a *app) exportScormCourse(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := a.getByID("scorm_courses", id); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "course not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load course")
return
}
var body struct {
Version string `json:"version"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid export payload")
return
}
if body.Version == "" {
body.Version = "scorm12"
}
exportDoc, err := a.createScormExport(id, body.Version)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create export")
return
}
exportID, _ := exportDoc["id"].(string)
writeJSON(w, http.StatusOK, map[string]any{
"downloadUrl": "/api/scorm/" + id + "/exports/" + exportID,
"expiresAt": time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339),
})
}
func (a *app) listScormExports(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
docs, err := a.list("scorm_exports", Doc{"courseId": id}, Sort{{Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list exports")
return
}
writeJSON(w, http.StatusOK, docs)
}
func (a *app) analyticsHandler(w http.ResponseWriter, r *http.Request) {
enrollments, err := a.list("enrollments", Doc{}, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to compute analytics")
return
}
users, err := a.list("users", Doc{}, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to compute analytics")
return
}
completed := 0
totalScore := 0.0
scored := 0
for _, enrollment := range enrollments {
if status, _ := enrollment["status"].(string); status == "completed" {
completed++
}
switch score := enrollment["score"].(type) {
case float64:
totalScore += score
scored++
case int32:
totalScore += float64(score)
scored++
case int64:
totalScore += float64(score)
scored++
}
}
averageScore := 0.0
if scored > 0 {
averageScore = totalScore / float64(scored)
}
writeJSON(w, http.StatusOK, map[string]any{
"totalUsers": len(users),
"totalEnrollments": len(enrollments),
"completions": completed,
"averageScore": averageScore,
})
}

View File

@ -0,0 +1,26 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerAdminRoutes(r chi.Router) {
r.Route("/admin/settings", func(r chi.Router) {
r.Use(a.settingsAdminMiddleware)
r.Get("/", a.getAdminSettings)
r.Patch("/", a.patchAdminSettings)
})
r.Route("/admin/audit", func(r chi.Router) {
r.Use(a.auditAdminMiddleware)
r.Get("/", a.listAdminAuditEvents)
r.Get("/{id}", a.getAdminAuditEvent)
})
r.Route("/platform-admin/tenants", func(r chi.Router) {
r.Use(a.platformAdminMiddleware)
r.Get("/", a.listTenants)
r.Post("/", a.createTenant)
r.Get("/{id}", a.getTenant)
r.Patch("/{id}", a.patchTenant)
r.Delete("/{id}", a.deleteTenant)
})
}

View File

@ -0,0 +1,20 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerAssetRoutes(r chi.Router) {
r.Route("/assets", func(r chi.Router) {
r.Use(a.assetsAdminMiddleware)
r.Get("/", a.listAssets)
r.Post("/", a.createAsset)
r.Get("/{id}", a.getAsset)
r.Patch("/{id}", a.patchAsset)
r.Delete("/{id}", a.deleteAsset)
r.Get("/{id}/access-events", a.listAssetAccessEvents)
r.Get("/{id}/claims", a.listAssetClaims)
r.Post("/{id}/quarantine", a.quarantineAsset)
r.Post("/{id}/disable", a.disableAsset)
r.Post("/{id}/restore", a.restoreAsset)
r.Post("/{id}/encrypt", a.encryptAsset)
})
}

View File

@ -0,0 +1,32 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerBlogRoutes(r chi.Router) {
r.Route("/blog", func(r chi.Router) {
r.Use(a.blogEditorMiddleware)
r.Get("/posts", a.listBlogPosts)
r.Post("/posts", a.createBlogPost)
r.Get("/posts/{id}", a.getBlogPost)
r.Patch("/posts/{id}", a.patchBlogPost)
r.Delete("/posts/{id}", a.deleteBlogPost)
r.Get("/categories", a.listBlogCategories)
r.Post("/categories", a.createBlogCategory)
r.Get("/categories/{id}", a.getBlogCategory)
r.Patch("/categories/{id}", a.patchBlogCategory)
r.Delete("/categories/{id}", a.deleteBlogCategory)
r.Get("/tags", a.listBlogTags)
r.Post("/tags", a.createBlogTag)
r.Get("/tags/{id}", a.getBlogTag)
r.Patch("/tags/{id}", a.patchBlogTag)
r.Delete("/tags/{id}", a.deleteBlogTag)
r.Get("/authors", a.listBlogAuthors)
r.Post("/authors", a.createBlogAuthor)
r.Get("/authors/{id}", a.getBlogAuthor)
r.Patch("/authors/{id}", a.patchBlogAuthor)
r.Delete("/authors/{id}", a.deleteBlogAuthor)
})
}

View File

@ -0,0 +1,22 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerCommunityRoutes(r chi.Router) {
r.Route("/communities", func(r chi.Router) {
r.Use(a.communitiesAdminMiddleware)
r.Get("/", a.listCommunities)
r.Post("/", a.createCommunity)
r.Get("/{id}", a.getCommunity)
r.Patch("/{id}", a.patchCommunity)
r.Delete("/{id}", a.deleteCommunity)
r.Get("/{id}/channels", a.listCommunityChannels)
r.Post("/{id}/channels", a.createCommunityChannel)
r.Get("/{id}/channels/{channelId}", a.getCommunityChannel)
r.Patch("/{id}/channels/{channelId}", a.patchCommunityChannel)
r.Delete("/{id}/channels/{channelId}", a.deleteCommunityChannel)
r.Get("/{id}/memberships", a.listCommunityMemberships)
r.Post("/{id}/memberships", a.createCommunityMembership)
r.Patch("/{id}/memberships/{membershipId}", a.patchCommunityMembership)
})
}

View File

@ -0,0 +1,64 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerCoreAPIRoutes(r chi.Router) {
r.Route("/mcp", func(r chi.Router) {
r.Use(a.authoringRoleMiddleware)
r.Post("/", a.handleMCP)
})
r.Route("/authoring-runs", func(r chi.Router) {
r.Use(a.authoringRoleMiddleware)
r.Post("/", a.createAuthoringRun)
r.Get("/{id}", a.getAuthoringRun)
r.Post("/{id}/theme", a.proposeAuthoringThemeHTTP)
r.Post("/{id}/theme/validate", a.validateAuthoringThemeHTTP)
r.Post("/{id}/site", a.proposeAuthoringSiteStructureHTTP)
r.Put("/{id}/site/pages", a.upsertAuthoringSitePageHTTP)
r.Post("/{id}/site/validate", a.validateAuthoringSiteHTTP)
r.Delete("/{id}", a.deleteAuthoringRun)
})
r.Get("/pages", a.listPages)
r.Post("/pages", a.createPage)
r.Get("/pages/{id}", a.getPage)
r.Patch("/pages/{id}", a.patchPage)
r.Delete("/pages/{id}", a.deletePage)
r.Get("/datasources", a.listDataSources)
r.Post("/datasources", a.createDataSource)
r.Get("/datasources/{id}", a.getDataSource)
r.Patch("/datasources/{id}", a.patchDataSource)
r.Delete("/datasources/{id}", a.deleteDataSource)
r.Get("/scorm", a.listScormCourses)
r.Post("/scorm", a.createScormCourse)
r.Get("/scorm/{id}", a.getScormCourse)
r.Patch("/scorm/{id}", a.patchScormCourse)
r.Delete("/scorm/{id}", a.deleteScormCourse)
r.Post("/scorm/{id}/export", a.exportScormCourse)
r.Get("/scorm/{id}/exports", a.listScormExports)
r.Get("/users", a.listUsers)
r.Post("/users", a.createUser)
r.Get("/users/{id}", a.getUser)
r.Patch("/users/{id}", a.patchUser)
r.Delete("/users/{id}", a.deleteUser)
r.Get("/enrollments", a.listEnrollments)
r.Post("/enrollments", a.createEnrollment)
r.Get("/enrollments/{id}", a.getEnrollment)
r.Patch("/enrollments/{id}", a.patchEnrollment)
r.Delete("/enrollments/{id}", a.deleteEnrollment)
r.Get("/webhooks", a.listWebhooks)
r.Post("/webhooks", a.createWebhook)
r.Get("/webhooks/{id}", a.getWebhook)
r.Patch("/webhooks/{id}", a.patchWebhook)
r.Delete("/webhooks/{id}", a.deleteWebhook)
r.Get("/analytics", a.analyticsHandler)
r.Post("/billing/checkout", a.createCheckoutSession)
r.Get("/permissions/registry", a.listPermissionRegistry)
r.Get("/entitlements/me/{key}", a.checkMyEntitlement)
}

View File

@ -0,0 +1,15 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerEntitlementsRoutes(r chi.Router) {
r.Route("/entitlements", func(r chi.Router) {
r.Use(a.entitlementsAdminMiddleware)
r.Get("/", a.listEntitlements)
r.Post("/", a.createEntitlement)
r.Get("/{id}", a.getEntitlement)
r.Patch("/{id}", a.patchEntitlement)
r.Delete("/{id}", a.deleteEntitlement)
r.Post("/{id}/revoke", a.revokeEntitlement)
})
}

View File

@ -0,0 +1,26 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerFormsRoutes(r chi.Router) {
r.Route("/forms", func(r chi.Router) {
r.Use(a.formsAdminMiddleware)
r.Get("/", a.listForms)
r.Post("/", a.createForm)
r.Get("/{id}", a.getForm)
r.Patch("/{id}", a.patchForm)
r.Delete("/{id}", a.deleteForm)
r.Get("/{id}/submissions", a.listFormSubmissions)
})
}
func (a *app) registerSitemapMenuRoutes(r chi.Router) {
r.Route("/sitemap-menus", func(r chi.Router) {
r.Use(a.sitemapMenusAdminMiddleware)
r.Get("/", a.listSitemapMenus)
r.Post("/", a.createSitemapMenu)
r.Get("/{id}", a.getSitemapMenu)
r.Patch("/{id}", a.patchSitemapMenu)
r.Delete("/{id}", a.deleteSitemapMenu)
})
}

View File

@ -0,0 +1,18 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerKeycloakThemeRoutes(r chi.Router) {
r.Route("/keycloak-themes", func(r chi.Router) {
r.Use(a.keycloakThemeReadMiddleware)
r.Get("/schema", a.getKeycloakThemeFormSchema)
r.Get("/", a.listKeycloakThemes)
r.Post("/", a.createKeycloakTheme)
r.Get("/{id}", a.getKeycloakTheme)
r.Patch("/{id}", a.patchKeycloakTheme)
r.Delete("/{id}", a.deleteKeycloakTheme)
r.Post("/{id}/preview", a.previewKeycloakTheme)
r.Post("/{id}/export", a.exportKeycloakTheme)
r.Post("/{id}/deploy", a.deployKeycloakTheme)
})
}

View File

@ -0,0 +1,27 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerLearnerRoutes(r chi.Router) {
r.Route("/learner/live-courses", func(r chi.Router) {
r.Use(a.liveCourseLearnerMiddleware)
r.Get("/", a.listLearnerLiveCourses)
r.Get("/{id}", a.getLearnerLiveCourse)
r.Get("/{id}/sessions", a.listLearnerLiveCourseSessions)
r.Get("/{id}/sessions/{sessionId}", a.getLearnerLiveCourseSession)
r.Post("/{id}/sessions/{sessionId}/join", a.joinLearnerLiveCourseSession)
})
r.Route("/learner/communities", func(r chi.Router) {
r.Use(a.communityLearnerMiddleware)
r.Get("/", a.listLearnerCommunities)
r.Get("/{id}/channels", a.listLearnerCommunityChannels)
r.Post("/{id}/join", a.joinLearnerCommunity)
})
r.Route("/learner/scorm", func(r chi.Router) {
r.Use(a.scormLearnerMiddleware)
r.Get("/", a.listLearnerScormCourses)
r.Get("/{id}/launch", a.getLearnerScormLaunch)
})
}

View File

@ -0,0 +1,34 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerLiveCourseRoutes(r chi.Router) {
r.Route("/live-courses", func(r chi.Router) {
r.Use(a.liveCourseManagerMiddleware)
r.Get("/", a.listLiveCourses)
r.Post("/", a.createLiveCourse)
r.Get("/{id}", a.getLiveCourse)
r.Patch("/{id}", a.patchLiveCourse)
r.Delete("/{id}", a.deleteLiveCourse)
r.Get("/{id}/cohorts", a.listLiveCourseCohorts)
r.Post("/{id}/cohorts", a.createLiveCourseCohort)
r.Get("/{id}/cohorts/{cohortId}", a.getLiveCourseCohort)
r.Patch("/{id}/cohorts/{cohortId}", a.patchLiveCourseCohort)
r.Delete("/{id}/cohorts/{cohortId}", a.deleteLiveCourseCohort)
r.Get("/{id}/cohorts/{cohortId}/sessions", a.listLiveCourseSessions)
r.Post("/{id}/cohorts/{cohortId}/sessions", a.createLiveCourseSession)
r.Get("/{id}/cohorts/{cohortId}/sessions/{sessionId}", a.getLiveCourseSession)
r.Patch("/{id}/cohorts/{cohortId}/sessions/{sessionId}", a.patchLiveCourseSession)
r.Delete("/{id}/cohorts/{cohortId}/sessions/{sessionId}", a.deleteLiveCourseSession)
r.Get("/{id}/cohorts/{cohortId}/enrollments", a.listLiveCourseEnrollments)
r.Post("/{id}/cohorts/{cohortId}/enrollments", a.createLiveCourseEnrollment)
r.Patch("/{id}/cohorts/{cohortId}/enrollments/{enrollmentId}", a.patchLiveCourseEnrollment)
r.Get("/{id}/cohorts/{cohortId}/sessions/{sessionId}/attendance", a.listLiveCourseAttendance)
r.Post("/{id}/cohorts/{cohortId}/sessions/{sessionId}/attendance", a.createLiveCourseAttendance)
r.Patch("/{id}/cohorts/{cohortId}/sessions/{sessionId}/attendance/{attendanceId}", a.patchLiveCourseAttendance)
})
}

View File

@ -0,0 +1,26 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerNotificationRoutes(r chi.Router) {
r.Route("/notifications", func(r chi.Router) {
r.Route("/templates", func(r chi.Router) {
r.Use(a.notificationsAdminMiddleware)
r.Get("/", a.listNotificationTemplates)
r.Post("/", a.createNotificationTemplate)
r.Get("/{id}", a.getNotificationTemplate)
r.Patch("/{id}", a.patchNotificationTemplate)
r.Delete("/{id}", a.deleteNotificationTemplate)
})
r.Route("/deliveries", func(r chi.Router) {
r.Use(a.notificationsAdminMiddleware)
r.Get("/", a.listNotificationDeliveries)
r.Post("/", a.createNotificationDelivery)
})
r.Get("/preferences", a.listNotificationPreferences)
r.Post("/preferences", a.createNotificationPreference)
r.Patch("/preferences/{id}", a.patchNotificationPreference)
})
}

View File

@ -0,0 +1,12 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerAuthRoutes(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Post("/login", a.loginHandler)
r.Post("/refresh", a.refreshHandler)
r.Post("/logout", a.logoutHandler)
r.With(a.authMiddleware).Get("/me", a.meHandler)
})
}

View File

@ -0,0 +1,31 @@
package main
import "github.com/go-chi/chi/v5"
func (a *app) registerPublicRoutes(r chi.Router) {
r.Get("/.well-known/oauth-protected-resource", a.mcpProtectedResourceMetadata)
r.Get("/sitemap.xml", a.sitemapHandler)
r.Get("/robots.txt", a.robotsHandler)
r.Route("/api/public/forms", func(r chi.Router) {
r.Get("/{id}", a.getPublicForm)
r.Post("/{id}/submissions", a.createPublicFormSubmission)
})
r.Get("/api/public/pages/{slug}", a.getPublicPageBySlug)
r.Route("/api/public/blog", func(r chi.Router) {
r.Get("/posts", a.listPublicBlogPosts)
r.Get("/posts/{slug}", a.getPublicBlogPostBySlug)
r.Get("/categories", a.listPublicBlogCategories)
r.Get("/tags", a.listPublicBlogTags)
})
r.Route("/api/public/live-courses", func(r chi.Router) {
r.Get("/", a.listPublicLiveCourses)
r.Get("/{slug}", a.getPublicLiveCourseBySlug)
r.Get("/{slug}/cohorts", a.listPublicLiveCourseCohortsBySlug)
})
r.Post("/api/public/billing/stripe/webhook", a.handleStripeWebhook)
}

View File

@ -0,0 +1,284 @@
package main
import (
"errors"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
)
func (a *app) scormLearnerMiddleware(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, "learner") && !hasRole(claims, "admin") && !hasRole(claims, "instructor") {
writeError(w, http.StatusForbidden, "insufficient role for learner scorm access")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listLearnerScormCourses(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
enrollments, err := a.list("enrollments", Doc{"userId": userID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list learner enrollments")
return
}
courseIDs := make(map[string]struct{})
filteredEnrollments := make([]Doc, 0, len(enrollments))
for _, enrollment := range enrollments {
courseID, _ := asString(enrollment["courseId"])
if courseID == "" {
continue
}
courseType, _ := asString(enrollment["courseType"])
if courseType != "" && !strings.EqualFold(courseType, "scorm") {
continue
}
filteredEnrollments = append(filteredEnrollments, enrollment)
courseIDs[courseID] = struct{}{}
}
if len(courseIDs) == 0 {
writeJSON(w, http.StatusOK, []Doc{})
return
}
courses, err := a.list("scorm_courses", Doc{"id": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load scorm courses")
return
}
courseByID := indexByID(courses)
exports, err := a.list("scorm_exports", Doc{"courseId": Doc{"$in": mapKeys(courseIDs)}}, Sort{{Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load scorm exports")
return
}
exportsByCourse := make(map[string][]Doc)
for _, exportDoc := range exports {
courseID, _ := asString(exportDoc["courseId"])
if courseID == "" {
continue
}
exportsByCourse[courseID] = append(exportsByCourse[courseID], exportDoc)
}
items := make([]Doc, 0, len(filteredEnrollments))
seenCourse := make(map[string]struct{})
for _, enrollment := range filteredEnrollments {
courseID, _ := asString(enrollment["courseId"])
if courseID == "" {
continue
}
if _, exists := seenCourse[courseID]; exists {
continue
}
course := courseByID[courseID]
if course == nil {
continue
}
hasEntitlement, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement")
return
}
seenCourse[courseID] = struct{}{}
items = append(items, Doc{
"course": course,
"enrollment": enrollment,
"launchState": evaluateLearnerScormLaunch(course, enrollment, exportsByCourse[courseID], hasEntitlement, entitlementReason, entitlementMessage),
})
}
writeJSON(w, http.StatusOK, items)
}
func (a *app) getLearnerScormLaunch(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
userID, ok := a.learnerSubjectFromContext(r)
if !ok {
writeError(w, http.StatusUnauthorized, "missing learner subject")
return
}
courseID := strings.TrimSpace(chi.URLParam(r, "id"))
if courseID == "" {
writeError(w, http.StatusBadRequest, "scorm course id is required")
return
}
course, err := a.getByID("scorm_courses", courseID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "scorm course not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load scorm course")
return
}
enrollments, err := a.list("enrollments", Doc{"userId": userID, "courseId": courseID}, Sort{{Key: "updatedAt", Value: -1}, {Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load learner enrollment")
return
}
enrollment := firstDocument(enrollments)
exports, err := a.list("scorm_exports", Doc{"courseId": courseID}, Sort{{Key: "createdAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load scorm exports")
return
}
hasEntitlement, entitlementReason, entitlementMessage, err := a.learnerEntitlementDecision(r, userID, resolveEntitlementKey(course, nil))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to evaluate learner entitlement")
return
}
launchState := evaluateLearnerScormLaunch(course, enrollment, exports, hasEntitlement, entitlementReason, entitlementMessage)
writeJSON(w, http.StatusOK, Doc{
"course": course,
"enrollment": enrollment,
"launchState": launchState,
})
}
func evaluateLearnerScormLaunch(course, enrollment Doc, exports []Doc, entitlementAllowed bool, entitlementReason, entitlementMessage string) Doc {
if course == nil {
return Doc{
"allowed": false,
"state": "locked",
"reason": "missing_course",
"message": "SCORM course was not found.",
"progressNote": "Progress sync is unavailable because this SCORM course could not be loaded.",
}
}
if enrollment == nil {
return Doc{
"allowed": false,
"state": "locked",
"reason": "missing_enrollment",
"message": "You are not currently enrolled in this SCORM course.",
"progressNote": "Resume is unavailable until enrollment is active.",
}
}
status, _ := asString(enrollment["status"])
if !isLearnerScormEnrollmentActive(status) {
return Doc{
"allowed": false,
"state": "locked",
"reason": "inactive_enrollment",
"message": "Your enrollment is not active for SCORM launch.",
"progressNote": "Resume is unavailable while enrollment is inactive.",
}
}
if !entitlementAllowed {
if strings.TrimSpace(entitlementReason) == "" {
entitlementReason = "missing_entitlement"
}
if strings.TrimSpace(entitlementMessage) == "" {
entitlementMessage = "Your entitlement is missing or inactive for this SCORM course."
}
return Doc{
"allowed": false,
"state": "locked",
"reason": entitlementReason,
"message": entitlementMessage,
"progressNote": "Launch is unavailable until the required entitlement is active.",
}
}
if !hasScormCoursePackage(course) {
return Doc{
"allowed": false,
"state": "missing_package",
"reason": "missing_package",
"message": "This SCORM course has no package content to launch yet.",
"progressNote": "Resume will be available after content and runtime tracking are connected.",
}
}
if len(exports) == 0 {
return Doc{
"allowed": false,
"state": "export_not_ready",
"reason": "export_not_ready",
"message": "A SCORM export has not been generated for this course yet.",
"progressNote": "Resume placeholders remain visible after an export is available.",
}
}
courseID, _ := asString(course["id"])
exportID, _ := asString(exports[0]["id"])
launchURL := "/api/scorm/" + courseID + "/exports"
if exportID != "" {
launchURL += "/" + exportID
}
resumeEligible := strings.EqualFold(status, "in_progress") || strings.EqualFold(status, "completed")
progressNote := "Resume will use your last saved SCORM location once runtime tracking is fully integrated."
if !resumeEligible {
progressNote = "Progress tracking is not yet connected; start now and a resume point will appear when available."
}
return Doc{
"allowed": true,
"state": "ready",
"reason": "",
"message": "SCORM package is ready to launch.",
"launchUrl": launchURL,
"resumeAvailable": resumeEligible,
"progressNote": progressNote,
}
}
func isLearnerScormEnrollmentActive(status string) bool {
normalized := strings.TrimSpace(strings.ToLower(status))
switch normalized {
case "", "not_started", "in_progress", "completed", "failed", "enrolled":
return true
default:
return false
}
}
func hasScormCoursePackage(course Doc) bool {
raw := course["scos"]
switch value := raw.(type) {
case []any:
return len(value) > 0
case List:
return len(value) > 0
default:
return false
}
}

View File

@ -0,0 +1,307 @@
package main
import (
"encoding/xml"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type sitemapEntry struct {
Loc string
LastMod string
}
type sitemapXML struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []sitemapURL `xml:"url"`
}
type sitemapURL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
}
func (a *app) sitemapHandler(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
pages, err := a.list("pages", Doc{}, Sort{{Key: "updatedAt", Value: -1}})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to build sitemap")
return
}
entries := collectSitemapEntries(pages, requestOrigin(r))
body, err := renderSitemapXML(entries)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to render sitemap")
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}
func (a *app) robotsHandler(w http.ResponseWriter, r *http.Request) {
body := renderRobotsTXT(requestOrigin(r))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}
func collectSitemapEntries(pages []Doc, baseURL string) []sitemapEntry {
seen := make(map[string]struct{}, len(pages))
entries := make([]sitemapEntry, 0, len(pages))
for _, page := range pages {
if !isPublishedPublicPage(page) {
continue
}
loc, ok := pagePublicURL(page, baseURL)
if !ok {
continue
}
if _, exists := seen[loc]; exists {
continue
}
seen[loc] = struct{}{}
entries = append(entries, sitemapEntry{
Loc: loc,
LastMod: pageLastMod(page),
})
}
return entries
}
func isPublishedPublicPage(page Doc) bool {
published := false
now := time.Now().UTC()
if status, ok := asString(page["status"]); ok && strings.EqualFold(status, "published") {
published = true
}
if value, ok := asBool(page["published"]); ok && value {
published = true
}
if value, ok := asBool(page["isPublished"]); ok && value {
published = true
}
if publishedAt, ok := asTime(page["publishedAt"]); ok {
if publishedAt.UTC().After(now) {
return false
}
published = true
}
if !published {
return false
}
if value, ok := asBool(page["private"]); ok && value {
return false
}
if value, ok := asBool(page["isPrivate"]); ok && value {
return false
}
if visibility, ok := asString(page["visibility"]); ok && strings.EqualFold(visibility, "private") {
return false
}
if access, ok := asString(page["access"]); ok && strings.EqualFold(access, "private") {
return false
}
if !isIndexablePage(page) {
return false
}
slug, ok := asString(page["slug"])
return ok && strings.TrimSpace(slug) != ""
}
func isIndexablePage(page Doc) bool {
seo, ok := asMap(page["seo"])
if !ok {
return true
}
robotsRaw, exists := seo["robots"]
if !exists || robotsRaw == nil {
return true
}
robots, ok := asMap(robotsRaw)
if !ok {
return true
}
if index, exists := asBool(robots["index"]); exists && !index {
return false
}
return true
}
func pagePublicURL(page Doc, baseURL string) (string, bool) {
seo, _ := asMap(page["seo"])
if canonical, ok := asString(seo["canonicalUrl"]); ok && canonical != "" && isAbsoluteHTTPURL(canonical) {
return canonical, true
}
slug, ok := asString(page["slug"])
if !ok {
return "", false
}
slug = strings.Trim(strings.TrimSpace(slug), "/")
if slug == "" {
return "", false
}
return strings.TrimRight(baseURL, "/") + "/pages/" + slug, true
}
func pageLastMod(page Doc) string {
raw, ok := page["updatedAt"]
if !ok {
return ""
}
if t, ok := asTime(raw); ok {
return t.UTC().Format(time.RFC3339)
}
return ""
}
func renderSitemapXML(entries []sitemapEntry) (string, error) {
xmlDoc := sitemapXML{XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9"}
xmlDoc.URLs = make([]sitemapURL, 0, len(entries))
for _, entry := range entries {
xmlDoc.URLs = append(xmlDoc.URLs, sitemapURL{Loc: entry.Loc, LastMod: entry.LastMod})
}
body, err := xml.MarshalIndent(xmlDoc, "", " ")
if err != nil {
return "", err
}
return xml.Header + string(body), nil
}
func renderRobotsTXT(baseURL string) string {
base := strings.TrimRight(baseURL, "/")
return "User-agent: *\nAllow: /\nSitemap: " + base + "/sitemap.xml\n"
}
func requestOrigin(r *http.Request) string {
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if host == "" {
host = "localhost:8080"
}
return scheme + "://" + host
}
func isAbsoluteHTTPURL(value string) bool {
u, err := url.Parse(strings.TrimSpace(value))
if err != nil {
return false
}
if u.Host == "" {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
func asMap(raw any) (map[string]any, bool) {
switch value := raw.(type) {
case map[string]any:
return value, true
case Doc:
return map[string]any(value), true
default:
return nil, false
}
}
func asString(raw any) (string, bool) {
value, ok := raw.(string)
if !ok {
return "", false
}
value = strings.TrimSpace(value)
if value == "" {
return "", false
}
return value, true
}
func asBool(raw any) (bool, bool) {
value, ok := raw.(bool)
return value, ok
}
func asTime(raw any) (time.Time, bool) {
switch value := raw.(type) {
case time.Time:
if value.IsZero() {
return time.Time{}, false
}
return value, true
case int:
if value <= 0 {
return time.Time{}, false
}
return time.UnixMilli(int64(value)), true
case int32:
if value <= 0 {
return time.Time{}, false
}
return time.UnixMilli(int64(value)), true
case int64:
if value <= 0 {
return time.Time{}, false
}
return time.UnixMilli(value), true
case float64:
if value <= 0 {
return time.Time{}, false
}
return time.UnixMilli(int64(value)), true
case string:
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, false
}
if parsed, err := strconv.ParseInt(trimmed, 10, 64); err == nil && parsed > 0 {
return time.UnixMilli(parsed), true
}
if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil && !parsed.IsZero() {
return parsed, true
}
}
return time.Time{}, false
}

View File

@ -0,0 +1,307 @@
package main
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
const (
sitemapTargetPage = "page"
sitemapTargetPost = "post"
sitemapTargetCourse = "course"
)
var allowedSitemapTargetTypes = map[string]string{
sitemapTargetPage: "pages",
sitemapTargetPost: "blog_posts",
sitemapTargetCourse: "live_courses",
}
func (a *app) sitemapMenusAdminMiddleware(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 sitemap menu management")
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) listSitemapMenus(w http.ResponseWriter, r *http.Request) {
a.listCollection("sitemap_menus", w)
}
func (a *app) createSitemapMenu(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
var payload map[string]any
if err := decodeJSON(r, &payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid payload")
return
}
doc, err := prepareSitemapMenuPayload(Doc(payload), false)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
items, _, _ := normalizeSitemapMenuItems(doc["items"])
if err := a.validateSitemapMenuTargets(items); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
doc, err = a.create("sitemap_menus", doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create resource")
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (a *app) getSitemapMenu(w http.ResponseWriter, r *http.Request) {
a.getCollection("sitemap_menus", w, r)
}
func (a *app) patchSitemapMenu(w http.ResponseWriter, r *http.Request) {
if a.db == nil {
writeError(w, http.StatusServiceUnavailable, "database not configured")
return
}
id := chi.URLParam(r, "id")
var patch map[string]any
if err := decodeJSON(r, &patch); err != nil {
writeError(w, http.StatusBadRequest, "invalid patch payload")
return
}
doc, err := prepareSitemapMenuPayload(Doc(patch), true)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if rawItems, hasItems := doc["items"]; hasItems {
items, _, err := normalizeSitemapMenuItems(rawItems)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := a.validateSitemapMenuTargets(items); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
doc, err = a.patch("sitemap_menus", id, doc)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to update resource")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (a *app) deleteSitemapMenu(w http.ResponseWriter, r *http.Request) {
a.deleteCollection("sitemap_menus", w, r)
}
func prepareSitemapMenuPayload(payload Doc, partial bool) (Doc, error) {
if payload == nil {
payload = Doc{}
}
name, hasName, err := getOptionalString(payload, "name")
if err != nil {
return nil, errors.New("name must be a string")
}
if !partial && (!hasName || name == "") {
return nil, errors.New("name is required")
}
if hasName {
payload["name"] = name
}
description, hasDescription, err := getOptionalString(payload, "description")
if err != nil {
return nil, errors.New("description must be a string")
}
if hasDescription {
payload["description"] = description
}
items, hasItems, err := normalizeSitemapMenuItems(payload["items"])
if err != nil {
return nil, err
}
if !partial && !hasItems {
payload["items"] = []Doc{}
} else if hasItems {
payload["items"] = items
}
return payload, nil
}
func normalizeSitemapMenuItems(raw any) ([]Doc, bool, error) {
if raw == nil {
return nil, false, nil
}
var values []any
switch source := raw.(type) {
case []any:
values = source
case List:
values = []any(source)
case []Doc:
values = make([]any, 0, len(source))
for _, item := range source {
values = append(values, item)
}
default:
return nil, false, errors.New("items must be an array")
}
normalized := make([]Doc, 0, len(values))
for _, item := range values {
itemMap, ok := item.(map[string]any)
if !ok {
bsonItem, docOK := item.(Doc)
if !docOK {
return nil, false, errors.New("menu items must be objects")
}
itemMap = map[string]any(bsonItem)
}
labelRaw, hasLabel := itemMap["label"]
if !hasLabel {
return nil, false, errors.New("menu item label is required")
}
label, ok := labelRaw.(string)
if !ok || strings.TrimSpace(label) == "" {
return nil, false, errors.New("menu item label must be a string")
}
targetTypeRaw, hasTargetType := itemMap["targetType"]
if !hasTargetType {
return nil, false, errors.New("menu item targetType is required")
}
targetType, ok := targetTypeRaw.(string)
if !ok {
return nil, false, errors.New("menu item targetType must be a string")
}
targetType = strings.ToLower(strings.TrimSpace(targetType))
if _, allowed := allowedSitemapTargetTypes[targetType]; !allowed {
return nil, false, errors.New("menu item targetType is invalid")
}
targetIDRaw, hasTargetID := itemMap["targetId"]
if !hasTargetID {
return nil, false, errors.New("menu item targetId is required")
}
targetID, ok := targetIDRaw.(string)
if !ok || strings.TrimSpace(targetID) == "" {
return nil, false, errors.New("menu item targetId must be a string")
}
toRaw, hasTo := itemMap["to"]
if !hasTo {
return nil, false, errors.New("menu item to is required")
}
to, ok := toRaw.(string)
if !ok || strings.TrimSpace(to) == "" || !strings.HasPrefix(strings.TrimSpace(to), "/") {
return nil, false, errors.New("menu item to must be an absolute path")
}
normalizedItem := Doc{
"id": normalizeSitemapItemID(itemMap["id"]),
"label": strings.TrimSpace(label),
"targetType": targetType,
"targetId": strings.TrimSpace(targetID),
"to": strings.TrimSpace(to),
}
if childrenRaw, hasChildren := itemMap["children"]; hasChildren {
children, _, err := normalizeSitemapMenuItems(childrenRaw)
if err != nil {
return nil, false, err
}
normalizedItem["children"] = children
} else {
normalizedItem["children"] = []Doc{}
}
normalized = append(normalized, normalizedItem)
}
return normalized, true, nil
}
func normalizeSitemapItemID(raw any) string {
id, ok := raw.(string)
if ok && strings.TrimSpace(id) != "" {
return strings.TrimSpace(id)
}
return uuid.NewString()
}
func (a *app) validateSitemapMenuTargets(items []Doc) error {
for _, item := range items {
targetType, _ := item["targetType"].(string)
targetID, _ := item["targetId"].(string)
if err := a.validateSitemapTarget(targetType, targetID); err != nil {
return err
}
if childAny, hasChildren := item["children"]; hasChildren {
children, _, err := normalizeSitemapMenuItems(childAny)
if err != nil {
return err
}
if err := a.validateSitemapMenuTargets(children); err != nil {
return err
}
}
}
return nil
}
func (a *app) validateSitemapTarget(targetType, targetID string) error {
collection, ok := allowedSitemapTargetTypes[targetType]
if !ok {
return errors.New("menu item targetType is invalid")
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
count, err := a.db.Collection(collection).CountDocuments(ctx, Doc{"id": targetID})
if err != nil {
return errors.New("failed to validate menu target")
}
if count == 0 {
return errors.New("menu target not found: " + targetType + ":" + targetID)
}
return nil
}

View File

@ -0,0 +1,41 @@
package main
import "errors"
type Doc map[string]any
type List []any
type SortField struct {
Key string
Value any
}
type Sort []SortField
var ErrNotFound = errors.New("resource not found")
type FindOptions struct {
Sort Sort
Limit *int64
}
func NewFindOptions() *FindOptions { return &FindOptions{} }
func (o *FindOptions) SetSort(sort Sort) *FindOptions { o.Sort = sort; return o }
func (o *FindOptions) SetLimit(limit int64) *FindOptions { o.Limit = &limit; return o }
type FindOneOptions struct{ Sort Sort }
func NewFindOneOptions() *FindOneOptions { return &FindOneOptions{} }
func (o *FindOneOptions) SetSort(sort Sort) *FindOneOptions { o.Sort = sort; return o }
type ReturnDocument int
const ReturnAfter ReturnDocument = 1
type FindOneAndUpdateOptions struct{}
func NewFindOneAndUpdateOptions() *FindOneAndUpdateOptions { return &FindOneAndUpdateOptions{} }
func (o *FindOneAndUpdateOptions) SetReturnDocument(ReturnDocument) *FindOneAndUpdateOptions { return o }
type CountOptions struct{}
type InsertOneOptions struct{}
type ReplaceOptions struct{}
type DeleteOptions struct{}
type UpdateOptions struct{}

248
Backend/cmd/api/store.go Normal file
View File

@ -0,0 +1,248 @@
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
func (a *app) resolveCollection(name string) (*pgCollection, error) {
switch name {
case "pages",
"authoring_runs",
"datasources",
"scorm_courses",
"scorm_exports",
"users",
"enrollments",
"consent_receipts",
"data_subject_requests",
"webhooks",
"forms",
"form_submissions",
"sitemap_menus",
"audit_events",
"assets",
"asset_access_events",
"asset_claims",
"notification_templates",
"notification_preferences",
"notification_deliveries",
"notification_queue",
"notification_delivery_attempts",
"notification_dead_letters",
"notification_rate_limit_policies",
"notification_provider_configs",
"notifications",
"blog_posts",
"blog_categories",
"blog_tags",
"blog_authors",
"entitlements",
"live_courses",
"live_course_cohorts",
"live_course_sessions",
"live_course_enrollments",
"live_course_attendance",
"communities",
"community_channels",
"community_memberships",
"community_posts",
"community_replies",
"keycloak_themes",
"keycloak_theme_policy",
"keycloak_theme_exports",
"keycloak_theme_deployments_current",
"keycloak_theme_deployments_audit",
"platform_settings",
"admin_settings",
"billing_customers",
"billing_events",
"billing_price_policies",
"tenants":
return a.db.Collection(name), nil
default:
return nil, errors.New("invalid collection")
}
}
func (a *app) list(collection string, filter Doc, sort Sort) ([]Doc, error) {
col, err := a.resolveCollection(collection)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opts := NewFindOptions()
if len(sort) > 0 {
opts.SetSort(sort)
}
cursor, err := col.Find(ctx, filter, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var docs []Doc
if err := cursor.All(ctx, &docs); err != nil {
return nil, err
}
if docs == nil {
docs = []Doc{}
}
for _, doc := range docs {
normalizeDocument(doc)
}
return docs, nil
}
func (a *app) getByID(collection, id string) (Doc, error) {
return a.getByIDScoped(collection, id, "", false)
}
func (a *app) getByIDScoped(collection, id, tenantID string, enforceTenant bool) (Doc, error) {
col, err := a.resolveCollection(collection)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := Doc{"id": id}
if enforceTenant {
filter["tenantId"] = tenantID
}
var doc Doc
err = col.FindOne(ctx, filter).Decode(&doc)
if err != nil {
return nil, err
}
normalizeDocument(doc)
return doc, nil
}
func (a *app) create(collection string, payload Doc) (Doc, error) {
col, err := a.resolveCollection(collection)
if err != nil {
return nil, err
}
now := time.Now().UnixMilli()
if _, ok := payload["id"]; !ok {
payload["id"] = uuid.NewString()
}
if _, ok := payload["createdAt"]; !ok {
payload["createdAt"] = now
}
payload["updatedAt"] = now
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = col.InsertOne(ctx, payload)
if err != nil {
return nil, err
}
normalizeDocument(payload)
return payload, nil
}
func (a *app) patch(collection, id string, patch Doc) (Doc, error) {
return a.patchWithUnset(collection, id, patch, nil)
}
func (a *app) patchWithUnset(collection, id string, patch Doc, unset Doc) (Doc, error) {
return a.patchWithUnsetScoped(collection, id, patch, unset, "", false)
}
func (a *app) patchWithUnsetScoped(collection, id string, patch Doc, unset Doc, tenantID string, enforceTenant bool) (Doc, error) {
col, err := a.resolveCollection(collection)
if err != nil {
return nil, err
}
delete(patch, "id")
delete(patch, "createdAt")
patch["updatedAt"] = time.Now().UnixMilli()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
update := Doc{"$set": patch}
if len(unset) > 0 {
update["$unset"] = unset
}
result := col.FindOneAndUpdate(
ctx,
func() Doc {
filter := Doc{"id": id}
if enforceTenant {
filter["tenantId"] = tenantID
}
return filter
}(),
update,
NewFindOneAndUpdateOptions().SetReturnDocument(ReturnAfter),
)
var updated Doc
if err := result.Decode(&updated); err != nil {
return nil, err
}
normalizeDocument(updated)
return updated, nil
}
func (a *app) deleteByID(collection, id string) error {
return a.deleteByIDScoped(collection, id, "", false)
}
func (a *app) deleteByIDScoped(collection, id, tenantID string, enforceTenant bool) error {
col, err := a.resolveCollection(collection)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := Doc{"id": id}
if enforceTenant {
filter["tenantId"] = tenantID
}
res, err := col.DeleteOne(ctx, filter)
if err != nil {
return err
}
if res.DeletedCount == 0 {
return ErrNotFound
}
return nil
}
func (a *app) createScormExport(courseID, version string) (Doc, error) {
export := Doc{
"id": uuid.NewString(),
"courseId": courseID,
"version": version,
"storageKey": fmt.Sprintf("exports/%s/%d.zip", courseID, time.Now().UnixNano()),
"createdAt": time.Now().UnixMilli(),
}
return a.create("scorm_exports", export)
}
func normalizeDocument(doc Doc) {
delete(doc, "_id")
}

View File

@ -0,0 +1,60 @@
package main
import (
"errors"
"net/http"
"strings"
)
var tenantScopedCollections = map[string]struct{}{
"authoring_runs": {},
"pages": {},
"datasources": {},
"scorm_courses": {},
"users": {},
"enrollments": {},
"consent_receipts": {},
"data_subject_requests": {},
"webhooks": {},
"entitlements": {},
"billing_customers": {},
"billing_events": {},
"notification_templates": {},
"notification_preferences": {},
"notification_deliveries": {},
"notification_queue": {},
"notification_delivery_attempts": {},
"notification_dead_letters": {},
"notification_rate_limit_policies": {},
"notification_provider_configs": {},
}
func isTenantScopedCollection(name string) bool {
_, ok := tenantScopedCollections[name]
return ok
}
var errTenantClaimRequired = errors.New("tenant claim required for tenant-scoped resources")
func requestTenantScope(r *http.Request, collection string) (tenantID string, enforce bool, err error) {
if !isTenantScopedCollection(collection) {
return "", false, nil
}
if r == nil {
return "", false, errTenantClaimRequired
}
tenant, ok := r.Context().Value(tenantContextKey).(tenantContext)
if !ok {
return "", false, errTenantClaimRequired
}
if tenant.BypassScoping {
if trimmed := strings.TrimSpace(tenant.ID); trimmed != "" {
return trimmed, tenant.RequestedByOps, nil
}
return "", false, nil
}
if trimmed := strings.TrimSpace(tenant.ID); trimmed != "" {
return trimmed, true, nil
}
return "", false, errTenantClaimRequired
}

68
Backend/cmd/api/web.go Normal file
View File

@ -0,0 +1,68 @@
package main
import (
"net/http"
"os"
"path"
"path/filepath"
"strings"
)
func (a *app) handler() http.Handler {
api := a.routes()
webDir := strings.TrimSpace(a.cfg.WebDir)
if webDir == "" {
return api
}
indexPath := filepath.Join(webDir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
a.healthHandler(w, r)
return
}
if isBackendPath(r.URL.Path) {
api.ServeHTTP(w, r)
return
}
cleanPath := path.Clean("/" + r.URL.Path)
relativePath := strings.TrimPrefix(cleanPath, "/")
if relativePath != "" {
candidate := filepath.Join(webDir, filepath.FromSlash(relativePath))
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
http.ServeFile(w, r, candidate)
return
}
// Missing static assets should be real 404s rather than the SPA shell.
if filepath.Ext(relativePath) != "" {
http.NotFound(w, r)
return
}
}
if _, err := os.Stat(indexPath); err != nil {
api.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, indexPath)
})
}
func isBackendPath(requestPath string) bool {
switch {
case requestPath == "/api", strings.HasPrefix(requestPath, "/api/"):
return true
case requestPath == "/auth", strings.HasPrefix(requestPath, "/auth/"):
return true
case requestPath == "/.well-known", strings.HasPrefix(requestPath, "/.well-known/"):
return true
case requestPath == "/sitemap.xml", requestPath == "/robots.txt":
return true
default:
return false
}
}

21
Backend/go.mod Normal file
View File

@ -0,0 +1,21 @@
module cms/backend
go 1.22
require (
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

65
Backend/go.sum Normal file
View File

@ -0,0 +1,65 @@
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS web-builder
WORKDIR /src/Frontend
COPY Frontend/package.json Frontend/package-lock.json ./
RUN npm ci
COPY Frontend/ ./
ARG VITE_API_BASE=""
ARG VITE_KEYCLOAK_URL="https://auth.arrowstack.dev"
ARG VITE_KEYCLOAK_REALM="dev"
ARG VITE_KEYCLOAK_CLIENT_ID="learn-platform"
ENV VITE_API_BASE=${VITE_API_BASE} \
VITE_KEYCLOAK_URL=${VITE_KEYCLOAK_URL} \
VITE_KEYCLOAK_REALM=${VITE_KEYCLOAK_REALM} \
VITE_KEYCLOAK_CLIENT_ID=${VITE_KEYCLOAK_CLIENT_ID}
RUN npm run build
FROM golang:1.22-alpine AS api-builder
WORKDIR /src/Backend
COPY Backend/go.mod Backend/go.sum ./
RUN go mod download
COPY Backend/ ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/learn-platform ./cmd/api
FROM alpine:3.20
RUN apk add --no-cache ca-certificates \
&& addgroup -S app \
&& adduser -S app -G app
WORKDIR /app
COPY --from=api-builder /out/learn-platform /app/learn-platform
COPY --from=web-builder /src/Frontend/dist /app/web
ENV PORT=8080 \
WEB_DIR=/app/web \
AUTH_DISABLED=false
USER app
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
ENTRYPOINT ["/app/learn-platform"]

View File

@ -0,0 +1,120 @@
/* KEEP_IN_SYNC(fullscreen/resources/figmake/shadcn/globals.css) */
:root {
--font-size: 16px;
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #030213;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
--muted: #ececf0;
--muted-foreground: #717182;
--accent: #e9ebef;
--accent-foreground: #030213;
--destructive: #d4183d;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.1);
--input: transparent;
--input-background: #f3f3f5;
--switch-background: #cbced4;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #030213;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}

22
Frontend/index.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Expand Theme Building</title>
<meta name="description" content="Enables users to create and customize website themes easily within a CMS frontend, enhancing design flexibility for developers and content creators." />
<meta name="robots" content="noindex, nofollow" />
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6132
Frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

101
Frontend/package.json Normal file
View File

@ -0,0 +1,101 @@
{
"name": "@figma/my-make-file",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite --host",
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "VITE_E2E_AUTH_MODE=mock playwright test",
"test:e2e:ui": "VITE_E2E_AUTH_MODE=mock playwright test --ui",
"test:e2e:headed": "VITE_E2E_AUTH_MODE=mock playwright test --headed",
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@mui/icons-material": "7.3.5",
"@mui/material": "7.3.5",
"@popperjs/core": "2.11.8",
"@radix-ui/react-accordion": "1.2.3",
"@radix-ui/react-alert-dialog": "1.1.6",
"@radix-ui/react-aspect-ratio": "1.1.2",
"@radix-ui/react-avatar": "1.1.3",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-collapsible": "1.1.3",
"@radix-ui/react-context-menu": "2.2.6",
"@radix-ui/react-dialog": "1.1.6",
"@radix-ui/react-dropdown-menu": "2.1.6",
"@radix-ui/react-hover-card": "1.1.6",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-menubar": "1.1.6",
"@radix-ui/react-navigation-menu": "1.2.5",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-progress": "1.1.2",
"@radix-ui/react-radio-group": "1.2.3",
"@radix-ui/react-scroll-area": "1.2.3",
"@radix-ui/react-select": "2.1.6",
"@radix-ui/react-separator": "1.1.2",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-switch": "1.1.3",
"@radix-ui/react-tabs": "1.1.3",
"@radix-ui/react-toggle": "1.1.2",
"@radix-ui/react-toggle-group": "1.1.2",
"@radix-ui/react-tooltip": "1.1.8",
"@types/jszip": "^3.4.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "3.6.0",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"jszip": "^3.10.1",
"keycloak-js": "26.1.1",
"lucide-react": "0.487.0",
"motion": "12.23.24",
"next-themes": "0.4.6",
"react-day-picker": "8.10.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-hook-form": "7.55.0",
"react-popper": "2.3.0",
"react-resizable-panels": "2.1.7",
"react-responsive-masonry": "2.7.1",
"react-router": "7.17.0",
"react-slick": "0.31.0",
"recharts": "2.15.2",
"sonner": "2.0.3",
"tailwind-merge": "3.2.0",
"tw-animate-css": "1.3.8",
"vaul": "1.1.2"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/vite": "4.1.12",
"@vitejs/plugin-react": "4.7.0",
"tailwindcss": "4.1.12",
"vite": "6.4.3",
"vitest": "4.1.8"
},
"peerDependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
},
"pnpm": {
"overrides": {
"vite": "6.4.3"
}
}
}

View File

@ -0,0 +1,15 @@
/**
* PostCSS Configuration
*
* Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required
* PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here.
*
* This file only exists for adding additional PostCSS plugins, if needed.
* For example:
*
* import postcssNested from 'postcss-nested'
* export default { plugins: [postcssNested()] }
*
* Otherwise, you can leave this file empty.
*/
export default {}

16
Frontend/src/app/App.tsx Normal file
View File

@ -0,0 +1,16 @@
import { RouterProvider } from 'react-router'
import { router } from './routes/index'
import { ConsentManager } from './components/ConsentManager'
// Re-export all types so existing component imports from '../App' continue to work
export type { ThemeConfig, CanvasBlock, CustomBlockDef, Page, DataSource, DataCacheEntry } from './types'
export { findBlockInTree, PRESET_THEMES, DEFAULT_THEME } from './cms-utils'
export default function App() {
return (
<>
<RouterProvider router={router} />
<ConsentManager />
</>
)
}

View File

@ -0,0 +1,164 @@
import { apiRequest } from './api/client'
export type AssetStatus = 'active' | 'archived' | 'quarantined' | 'disabled' | 'deleted' | 'unknown'
export type AssetRecord = {
id: string
title: string
status: AssetStatus
visibility: string
owner: string
licence: string
protectionLevel: string
relatedResource: string
copyrightMetadata: Record<string, unknown>
}
export type AssetAccessEvent = {
id: string
action: string
result: string
reason: string
createdAt: number | null
actor: string
}
export type AssetClaim = {
id: string
claimType: string
status: string
claimant: string
submittedAt: number | null
}
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
return {}
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asTimestamp(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value)
if (!Number.isNaN(parsed)) return parsed
}
return null
}
function normalizeStatus(value: unknown): AssetStatus {
const status = asString(value).toLowerCase()
if (status === 'active' || status === 'archived' || status === 'quarantined' || status === 'disabled' || status === 'deleted') {
return status
}
return 'unknown'
}
function normalizeRelatedResource(raw: Record<string, unknown>): string {
const type = asString(raw.relatedResourceType)
const id = asString(raw.relatedResourceId)
if (!type && !id) return 'Not linked'
if (!type) return id
if (!id) return type
return `${type}:${id}`
}
export function normalizeAssetRecord(raw: unknown): AssetRecord | null {
const source = asRecord(raw)
const id = asString(source.id)
if (!id) return null
const copyright = asRecord(source.copyright)
const owner = asString(source.ownerName) || asString(source.ownerUserId) || asString(copyright.ownerName) || asString(copyright.ownerUserId) || 'Unknown'
return {
id,
title: asString(source.title) || asString(source.filename) || `asset-${id}`,
status: normalizeStatus(source.status),
visibility: asString(source.visibility) || 'unknown',
owner,
licence: asString(copyright.licence) || asString(copyright.license) || 'unknown',
protectionLevel: asString(source.protectionLevel) || 'unknown',
relatedResource: normalizeRelatedResource(source),
copyrightMetadata: copyright,
}
}
export async function loadAdminAssets(): Promise<AssetRecord[]> {
const rows = await apiRequest<unknown[]>('/api/assets/')
return rows
.map(normalizeAssetRecord)
.filter((row): row is AssetRecord => row !== null)
.sort((a, b) => a.title.localeCompare(b.title))
}
export async function loadAssetAccessEvents(assetID: string): Promise<AssetAccessEvent[]> {
const rows = await apiRequest<unknown[]>(`/api/assets/${assetID}/access-events`)
return rows
.map(raw => {
const source = asRecord(raw)
const id = asString(source.id)
if (!id) return null
return {
id,
action: asString(source.action) || 'unknown',
result: asString(source.result) || 'unknown',
reason: asString(source.reason) || 'n/a',
createdAt: asTimestamp(source.createdAt),
actor: asString(source.userId) || asString(source.actor) || 'unknown',
}
})
.filter((row): row is AssetAccessEvent => row !== null)
}
export async function loadAssetClaims(assetID: string): Promise<AssetClaim[]> {
const rows = await apiRequest<unknown[]>(`/api/assets/${assetID}/claims`)
return rows
.map(raw => {
const source = asRecord(raw)
const id = asString(source.id)
if (!id) return null
return {
id,
claimType: asString(source.claimType) || 'unknown',
status: asString(source.status) || 'unknown',
claimant: asString(source.claimantName) || asString(source.claimantEmail) || 'Unknown claimant',
submittedAt: asTimestamp(source.submittedAt),
}
})
.filter((row): row is AssetClaim => row !== null)
}
export async function updateAssetStatus(assetID: string, action: 'quarantine' | 'disable' | 'restore'): Promise<AssetRecord> {
const row = await apiRequest<unknown>(`/api/assets/${assetID}/${action}`, {
method: 'POST',
})
const asset = normalizeAssetRecord(row)
if (!asset) {
throw new Error('Unexpected asset response from status update')
}
return asset
}
export function filterAssets(records: AssetRecord[], status: 'all' | AssetStatus, query: string): AssetRecord[] {
const normalizedQuery = query.trim().toLowerCase()
return records.filter(record => {
if (status !== 'all' && record.status !== status) {
return false
}
if (!normalizedQuery) {
return true
}
return (
record.title.toLowerCase().includes(normalizedQuery)
|| record.owner.toLowerCase().includes(normalizedQuery)
|| record.relatedResource.toLowerCase().includes(normalizedQuery)
|| record.licence.toLowerCase().includes(normalizedQuery)
)
})
}

View File

@ -0,0 +1,101 @@
import { apiRequest } from './api/client'
export type AuditResult = 'success' | 'failed' | 'unknown'
export type AuditEvent = {
id: string
actor: string
action: string
resource: string
result: AuditResult
timestamp: number | null
metadata: Record<string, unknown>
}
export type AuditFilters = {
action: string
resource: string
actor: string
result: '' | 'success' | 'failed'
since: string
until: string
}
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
return {}
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeResult(value: string): AuditResult {
const normalized = value.toLowerCase()
if (normalized === 'success') return 'success'
if (normalized === 'failed') return 'failed'
return 'unknown'
}
function normalizeTimestamp(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value)
if (!Number.isNaN(parsed)) return parsed
}
return null
}
export function normalizeAuditEvent(raw: unknown): AuditEvent | null {
const source = asRecord(raw)
const id = asString(source.id)
if (!id) return null
const metadataSource = asRecord(source.metadata)
return {
id,
actor: asString(source.actor) || 'system',
action: asString(source.action) || 'unknown.action',
resource: asString(source.resource) || 'unknown_resource',
result: normalizeResult(asString(source.result)),
timestamp: normalizeTimestamp(source.timestamp),
metadata: metadataSource,
}
}
function buildAuditQuery(filters: AuditFilters, limit = 100): string {
const query = new URLSearchParams()
if (filters.action.trim()) query.set('action', filters.action.trim())
if (filters.resource.trim()) query.set('resource', filters.resource.trim())
if (filters.actor.trim()) query.set('actor', filters.actor.trim())
if (filters.result) query.set('result', filters.result)
if (filters.since.trim()) query.set('since', filters.since.trim())
if (filters.until.trim()) query.set('until', filters.until.trim())
query.set('limit', String(limit))
return query.toString()
}
export async function loadAuditEvents(filters: AuditFilters): Promise<AuditEvent[]> {
const query = buildAuditQuery(filters)
const rows = await apiRequest<unknown[]>(`/api/admin/audit/?${query}`)
return rows
.map(normalizeAuditEvent)
.filter((event): event is AuditEvent => event !== null)
.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))
}
export function filterAuditEvents(events: AuditEvent[], query: string): AuditEvent[] {
const normalized = query.trim().toLowerCase()
if (!normalized) return events
return events.filter(event => (
event.action.toLowerCase().includes(normalized)
|| event.actor.toLowerCase().includes(normalized)
|| event.resource.toLowerCase().includes(normalized)
|| event.result.toLowerCase().includes(normalized)
))
}

View File

@ -0,0 +1,244 @@
import { listResource } from './api/client'
export type ContentPublishStatus = 'draft' | 'published' | 'scheduled' | 'archived' | 'unknown'
export type ContentRecordType = 'page' | 'blog' | 'form' | 'asset'
export type ContentSeoState = 'ready' | 'missing' | 'unknown'
export type ContentRightsState = 'protected' | 'review' | 'unknown'
export type AdminContentRecord = {
id: string
type: ContentRecordType
title: string
status: ContentPublishStatus
updatedAt: number | null
owner: string | null
to: string
seo: ContentSeoState
rights: ContentRightsState
}
export type AdminContentData = {
records: AdminContentRecord[]
loadWarnings: string[]
}
type ResourceLoader = <T>(resource: string) => Promise<T[]>
type DateLike = string | number | null | undefined
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
return {}
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') return value
return null
}
function parseDate(value: DateLike): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? null : parsed
}
return null
}
function pickDate(source: Record<string, unknown>): number | null {
return (
parseDate(source.updatedAt as DateLike)
?? parseDate(source.updated_at as DateLike)
?? parseDate(source.publishedAt as DateLike)
?? parseDate(source.createdAt as DateLike)
?? parseDate(source.created_at as DateLike)
)
}
function inferStatus(source: Record<string, unknown>): ContentPublishStatus {
const normalized = asString(source.status).toLowerCase()
if (normalized === 'draft' || normalized === 'published' || normalized === 'scheduled' || normalized === 'archived') {
return normalized
}
const archived = asBoolean(source.archived)
if (archived === true) return 'archived'
const published = asBoolean(source.published)
if (published === true) return 'published'
const publishAt = parseDate(source.publishAt as DateLike) ?? parseDate(source.scheduledAt as DateLike)
if (publishAt !== null && publishAt > Date.now()) return 'scheduled'
const publishedAt = parseDate(source.publishedAt as DateLike)
if (publishedAt !== null) return 'published'
if (normalized) return 'unknown'
return 'draft'
}
function inferOwner(source: Record<string, unknown>): string | null {
const direct = [
source.owner,
source.ownerName,
source.author,
source.authorName,
source.createdBy,
source.updatedBy,
]
for (const candidate of direct) {
const value = asString(candidate)
if (value) return value
}
const createdBy = asRecord(source.createdBy)
for (const key of ['name', 'fullName', 'username', 'email']) {
const value = asString(createdBy[key])
if (value) return value
}
return null
}
function inferTitle(source: Record<string, unknown>, fallback: string): string {
for (const key of ['title', 'name', 'label', 'slug', 'id']) {
const value = asString(source[key])
if (value) return value
}
return fallback
}
function inferSeoState(source: Record<string, unknown>, type: ContentRecordType): ContentSeoState {
if (type !== 'page' && type !== 'blog') {
return 'unknown'
}
const seo = asRecord(source.seo)
const title = asString(seo.title)
const description = asString(seo.description)
const canonicalUrl = asString(seo.canonicalUrl)
if (title || description || canonicalUrl) {
return 'ready'
}
return 'missing'
}
function inferRightsState(source: Record<string, unknown>): ContentRightsState {
const rights = asRecord(source.copyright)
const protectedFlags = [
source.protected,
source.isProtected,
source.encrypted,
rights.protected,
rights.enforced,
]
if (protectedFlags.some(flag => asBoolean(flag) === true)) {
return 'protected'
}
const reviewSignals = [
source.copyrightStatus,
rights.status,
rights.claimStatus,
source.license,
]
if (reviewSignals.some(signal => asString(signal) !== '')) {
return 'review'
}
return 'unknown'
}
function mapRecord(raw: unknown, type: ContentRecordType, to: string): AdminContentRecord | null {
const source = asRecord(raw)
const id = asString(source.id)
if (!id) return null
return {
id,
type,
title: inferTitle(source, `${type}-${id}`),
status: inferStatus(source),
updatedAt: pickDate(source),
owner: inferOwner(source),
to,
seo: inferSeoState(source, type),
rights: inferRightsState(source),
}
}
export function filterContentRecords(
records: AdminContentRecord[],
statusFilter: 'all' | Exclude<ContentPublishStatus, 'unknown'>,
query: string,
): AdminContentRecord[] {
const normalizedQuery = query.trim().toLowerCase()
return records.filter(record => {
if (statusFilter !== 'all' && record.status !== statusFilter) {
return false
}
if (!normalizedQuery) {
return true
}
return (
record.title.toLowerCase().includes(normalizedQuery)
|| record.type.toLowerCase().includes(normalizedQuery)
|| (record.owner ?? '').toLowerCase().includes(normalizedQuery)
)
})
}
export async function loadAdminContentData(loader: ResourceLoader = listResource): Promise<AdminContentData> {
const warnings: string[] = []
const resources: Array<{ resource: string; type: ContentRecordType; to: string }> = [
{ resource: 'pages', type: 'page', to: '/admin/builder' },
{ resource: 'blog/posts', type: 'blog', to: '/admin/blog' },
{ resource: 'forms', type: 'form', to: '/admin/forms' },
{ resource: 'assets', type: 'asset', to: '/admin/assets' },
]
const loaded = await Promise.all(
resources.map(async item => {
try {
const rows = await loader<unknown>(item.resource)
return { ...item, rows }
} catch {
warnings.push(`Unable to load ${item.resource}`)
return { ...item, rows: [] as unknown[] }
}
}),
)
const records = loaded
.flatMap(item => item.rows.map(row => mapRecord(row, item.type, item.to)))
.filter((row): row is AdminContentRecord => row !== null)
.sort((a, b) => {
const aDate = a.updatedAt ?? 0
const bDate = b.updatedAt ?? 0
if (aDate !== bDate) return bDate - aDate
return a.title.localeCompare(b.title)
})
return {
records,
loadWarnings: warnings,
}
}

View File

@ -0,0 +1,159 @@
import { listResource } from './api/client'
export type DashboardKey =
| 'pages'
| 'blogPosts'
| 'forms'
| 'submissions'
| 'learners'
| 'courses'
| 'notifications'
| 'assets'
export type AdminDashboardCount = {
key: DashboardKey
label: string
value: number | null
to: string
placeholder?: string
}
export type SetupWarning = {
key: 'sendgrid' | 'stripe' | 'asset-encryption'
title: string
message: string
}
export type AdminDashboardData = {
counts: AdminDashboardCount[]
setupWarnings: SetupWarning[]
loadWarnings: string[]
}
type ResourceLoader = <T>(resource: string) => Promise<T[]>
type FormRecord = { id: string }
type UserRecord = { roles?: string[]; role?: string }
async function safeListCount(loader: ResourceLoader, resource: string): Promise<{ count: number | null; warning?: string }> {
try {
const result = await loader<unknown>(resource)
return { count: result.length }
} catch {
return { count: null, warning: `Unable to load ${resource}` }
}
}
function normalizeRoleList(user: UserRecord): string[] {
if (Array.isArray(user.roles)) {
return user.roles.map(role => String(role).trim().toLowerCase()).filter(Boolean)
}
if (typeof user.role === 'string' && user.role.trim() !== '') {
return [user.role.trim().toLowerCase()]
}
return []
}
export async function loadAdminDashboardData(loader: ResourceLoader = listResource): Promise<AdminDashboardData> {
const warnings: string[] = []
const [pages, blogPosts, forms, users, scormCourses, liveCourses, notifications] = await Promise.all([
safeListCount(loader, 'pages'),
safeListCount(loader, 'blog/posts'),
safeListCount(loader, 'forms'),
safeListCount(loader, 'users'),
safeListCount(loader, 'scorm'),
safeListCount(loader, 'live-courses'),
safeListCount(loader, 'notifications/deliveries'),
])
for (const result of [pages, blogPosts, forms, users, scormCourses, liveCourses, notifications]) {
if (result.warning) warnings.push(result.warning)
}
let learnersCount: number | null = null
try {
const records = await loader<UserRecord>('users')
learnersCount = records.filter(user => normalizeRoleList(user).includes('learner')).length
} catch {
warnings.push('Unable to derive learner count from users')
}
let submissionsCount: number | null = null
try {
const formRecords = await loader<FormRecord>('forms')
const submissions = await Promise.all(
formRecords
.map(form => String(form.id ?? '').trim())
.filter(Boolean)
.map(formID => safeListCount(loader, `forms/${encodeURIComponent(formID)}/submissions`)),
)
if (submissions.length === 0) {
submissionsCount = 0
} else {
let sum = 0
let hasAny = false
for (const result of submissions) {
if (result.count !== null) {
hasAny = true
sum += result.count
}
}
submissionsCount = hasAny ? sum : null
if (!hasAny) {
warnings.push('Unable to load form submissions')
}
}
} catch {
warnings.push('Unable to load forms for submissions summary')
}
let coursesCount: number | null = null
if (scormCourses.count !== null || liveCourses.count !== null) {
coursesCount = (scormCourses.count ?? 0) + (liveCourses.count ?? 0)
}
const counts: AdminDashboardCount[] = [
{ key: 'pages', label: 'Pages', value: pages.count, to: '/admin/content', placeholder: 'Pages data unavailable' },
{ key: 'blogPosts', label: 'Blog Posts', value: blogPosts.count, to: '/admin/blog', placeholder: 'Blog data unavailable' },
{ key: 'forms', label: 'Forms', value: forms.count, to: '/admin/forms', placeholder: 'Forms data unavailable' },
{ key: 'submissions', label: 'Submissions', value: submissionsCount, to: '/admin/forms', placeholder: 'Submission data unavailable' },
{ key: 'learners', label: 'Learners', value: learnersCount, to: '/admin/learners', placeholder: 'Learner data unavailable' },
{ key: 'courses', label: 'Courses', value: coursesCount, to: '/admin/scorm', placeholder: 'Course data unavailable' },
{ key: 'notifications', label: 'Notifications', value: notifications.count, to: '/admin/notifications', placeholder: 'Notification data unavailable' },
{ key: 'assets', label: 'Assets', value: null, to: '/admin/assets', placeholder: 'Asset index not available yet' },
]
const setupWarnings: SetupWarning[] = []
if ((notifications.count ?? 0) === 0) {
setupWarnings.push({
key: 'sendgrid',
title: 'SendGrid setup may be incomplete',
message: 'No notification deliveries are available yet. Verify SendGrid provider and credentials.',
})
}
setupWarnings.push({
key: 'stripe',
title: 'Stripe setup needs verification',
message: 'Billing health checks are not yet exposed in this dashboard. Confirm Stripe keys and webhook secret.',
})
setupWarnings.push({
key: 'asset-encryption',
title: 'Asset encryption setup incomplete',
message: 'Asset encryption configuration status is not enabled yet. Treat uploads as not encrypted-at-rest by platform policy.',
})
const hasAnyLoadedMetric = counts.some(count => count.value !== null)
if (!hasAnyLoadedMetric) {
throw new Error('Unable to load dashboard metrics')
}
return {
counts,
setupWarnings,
loadWarnings: warnings,
}
}

View File

@ -0,0 +1,293 @@
import { listResource } from './api/client'
export type LearnerUserStatus = 'active' | 'inactive' | 'pending' | 'unknown'
export type LearnerRole = 'learner' | 'instructor' | 'editor' | 'admin' | 'unknown'
export type LearnerEnrollmentStatus = 'not_started' | 'in_progress' | 'completed' | 'failed' | 'unknown'
export type LearnerEnrollment = {
id: string
userId: string
courseId: string
courseTitle: string
courseType: 'cms' | 'scorm' | 'unknown'
status: LearnerEnrollmentStatus
progress: number | null
score: number | null
startedAt: number | null
completedAt: number | null
timeSpentMin: number | null
}
export type LearnerProfile = {
id: string
displayName: string
email: string
status: LearnerUserStatus
roles: LearnerRole[]
createdAt: number | null
updatedAt: number | null
lastActiveAt: number | null
enrollments: LearnerEnrollment[]
}
export type AdminLearnerData = {
learners: LearnerProfile[]
loadWarnings: string[]
}
type ResourceLoader = <T>(resource: string) => Promise<T[]>
type StatusFilter = 'all' | LearnerUserStatus
type RoleFilter = 'all' | LearnerRole
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
return {}
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function asStringList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.map(asString).filter(Boolean)
}
function normalizeRole(value: string): LearnerRole {
const normalized = value.toLowerCase()
if (normalized === 'learner') return 'learner'
if (normalized === 'instructor') return 'instructor'
if (normalized === 'editor') return 'editor'
if (normalized === 'admin') return 'admin'
return 'unknown'
}
function normalizeRoleList(user: Record<string, unknown>): LearnerRole[] {
const collected = [
...asStringList(user.roles),
...asStringList(user.realmRoles),
asString(user.role),
].map(normalizeRole)
const unique = Array.from(new Set(collected.filter(Boolean)))
return unique.length > 0 ? unique : ['unknown']
}
function normalizeStatus(value: string): LearnerUserStatus {
const normalized = value.toLowerCase()
if (normalized === 'active') return 'active'
if (normalized === 'inactive') return 'inactive'
if (normalized === 'pending') return 'pending'
return 'unknown'
}
function normalizeEnrollmentStatus(value: string): LearnerEnrollmentStatus {
const normalized = value.toLowerCase()
if (normalized === 'not_started') return 'not_started'
if (normalized === 'in_progress') return 'in_progress'
if (normalized === 'completed') return 'completed'
if (normalized === 'failed') return 'failed'
return 'unknown'
}
function pickDate(source: Record<string, unknown>, ...keys: string[]): number | null {
for (const key of keys) {
const value = asNumber(source[key])
if (value !== null) return value
}
return null
}
function normalizeLearnerProfile(raw: unknown): LearnerProfile | null {
const source = asRecord(raw)
const id = asString(source.id)
if (!id) return null
const displayName = asString(source.name) || asString(source.username) || asString(source.email) || `user-${id}`
const email = asString(source.email)
return {
id,
displayName,
email,
status: normalizeStatus(asString(source.status) || 'unknown'),
roles: normalizeRoleList(source),
createdAt: pickDate(source, 'createdAt', 'created_at'),
updatedAt: pickDate(source, 'updatedAt', 'updated_at'),
lastActiveAt: pickDate(source, 'lastActive', 'lastActiveAt', 'updatedAt', 'updated_at'),
enrollments: [],
}
}
function normalizeCourseType(raw: string): LearnerEnrollment['courseType'] {
const normalized = raw.toLowerCase()
if (normalized === 'cms') return 'cms'
if (normalized === 'scorm') return 'scorm'
return 'unknown'
}
function clampProgress(progress: number | null): number | null {
if (progress === null) return null
if (progress < 0) return 0
if (progress > 100) return 100
return progress
}
function normalizeEnrollment(raw: unknown, titleLookup: Map<string, string>): LearnerEnrollment | null {
const source = asRecord(raw)
const id = asString(source.id)
const userId = asString(source.userId)
const courseId = asString(source.courseId)
if (!id || !userId || !courseId) return null
const title = asString(source.courseTitle) || titleLookup.get(courseId) || `Course ${courseId}`
return {
id,
userId,
courseId,
courseTitle: title,
courseType: normalizeCourseType(asString(source.courseType)),
status: normalizeEnrollmentStatus(asString(source.status) || 'unknown'),
progress: clampProgress(asNumber(source.progress)),
score: asNumber(source.score),
startedAt: pickDate(source, 'startedAt', 'started_at'),
completedAt: pickDate(source, 'completedAt', 'completed_at'),
timeSpentMin: asNumber(source.timeSpentMin),
}
}
function sortLearners(learners: LearnerProfile[]): LearnerProfile[] {
return [...learners].sort((a, b) => {
const aRecent = a.lastActiveAt ?? 0
const bRecent = b.lastActiveAt ?? 0
if (aRecent !== bRecent) return bRecent - aRecent
return a.displayName.localeCompare(b.displayName)
})
}
function buildCourseTitleLookup(resources: Array<{ id: string; title: string }>): Map<string, string> {
const map = new Map<string, string>()
for (const item of resources) {
if (item.id && item.title && !map.has(item.id)) {
map.set(item.id, item.title)
}
}
return map
}
async function loadOptionalMap(
loader: ResourceLoader,
resource: string,
idKey: string,
titleKey: string,
warnings: string[],
): Promise<Array<{ id: string; title: string }>> {
try {
const rows = await loader<unknown>(resource)
return rows
.map(raw => {
const item = asRecord(raw)
return {
id: asString(item[idKey]),
title: asString(item[titleKey]),
}
})
.filter(item => item.id && item.title)
} catch {
warnings.push(`Unable to load ${resource}`)
return []
}
}
export async function loadAdminLearnerData(loader: ResourceLoader = listResource): Promise<AdminLearnerData> {
const warnings: string[] = []
const [users, enrollments, pages, scorm] = await Promise.all([
loader<unknown>('users'),
loader<unknown>('enrollments').catch(() => {
warnings.push('Unable to load enrollments')
return [] as unknown[]
}),
loadOptionalMap(loader, 'pages', 'id', 'name', warnings),
loadOptionalMap(loader, 'scorm', 'id', 'title', warnings),
])
const learnerProfiles = users
.map(normalizeLearnerProfile)
.filter((user): user is LearnerProfile => user !== null)
.filter(user => user.roles.includes('learner'))
const byId = new Map(learnerProfiles.map(user => [user.id, user]))
const titleLookup = buildCourseTitleLookup([...pages, ...scorm])
for (const raw of enrollments) {
const enrollment = normalizeEnrollment(raw, titleLookup)
if (!enrollment) continue
const user = byId.get(enrollment.userId)
if (!user) continue
user.enrollments.push(enrollment)
}
const learners = sortLearners(learnerProfiles).map(user => ({
...user,
enrollments: [...user.enrollments].sort((a, b) => {
const aUpdated = a.completedAt ?? a.startedAt ?? 0
const bUpdated = b.completedAt ?? b.startedAt ?? 0
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return a.courseTitle.localeCompare(b.courseTitle)
}),
}))
return {
learners,
loadWarnings: warnings,
}
}
export function filterLearners(
learners: LearnerProfile[],
query: string,
statusFilter: StatusFilter,
roleFilter: RoleFilter,
): LearnerProfile[] {
const normalizedQuery = query.trim().toLowerCase()
return learners.filter(learner => {
if (statusFilter !== 'all' && learner.status !== statusFilter) {
return false
}
if (roleFilter !== 'all' && !learner.roles.includes(roleFilter)) {
return false
}
if (!normalizedQuery) return true
return (
learner.displayName.toLowerCase().includes(normalizedQuery)
|| learner.email.toLowerCase().includes(normalizedQuery)
|| learner.roles.some(role => role.includes(normalizedQuery))
)
})
}
export function progressSummary(enrollment: LearnerEnrollment): { label: string; value: number | null } {
if (enrollment.progress === null) {
return { label: 'Not yet tracked', value: null }
}
return { label: `${enrollment.progress}%`, value: enrollment.progress }
}

View File

@ -0,0 +1,67 @@
export type AdminNavItem = {
key: string
label: string
to: string
disabled?: boolean
description?: string
}
const BASE_ADMIN_NAV: AdminNavItem[] = [
{ key: 'workspace', label: 'Workspace', to: '/home' },
{ key: 'dashboard', label: 'Dashboard', to: '/admin' },
{ key: 'builder', label: 'Builder', to: '/admin/builder' },
{ key: 'content', label: 'Content', to: '/admin/content' },
{ key: 'courses', label: 'Courses', to: '/admin/scorm' },
{ key: 'blog', label: 'Blog', to: '/admin/blog' },
{ key: 'forms', label: 'Forms', to: '/admin/forms' },
{ key: 'sitemap', label: 'Sitemap', to: '/admin/sitemap' },
{ key: 'themes', label: 'Themes', to: '/admin/keycloak-themes' },
{ key: 'learners', label: 'Learners', to: '/admin/learners' },
{ key: 'notifications', label: 'Notifications', to: '/admin/notifications' },
{ key: 'assets', label: 'Assets', to: '/admin/assets' },
{ key: 'billing', label: 'Billing', to: '/admin/billing' },
{ key: 'communities', label: 'Communities', to: '/admin/communities' },
{ key: 'audit', label: 'Audit', to: '/admin/audit' },
{ key: 'settings', label: 'Settings', to: '/admin/settings' },
]
const ADMIN_ONLY_KEYS = new Set(['sitemap', 'themes', 'billing', 'audit', 'settings'])
const INSTRUCTOR_DISABLED_KEYS = new Set(['notifications', 'assets', 'billing', 'audit', 'settings'])
export function hasAdminShellAccess(roles: string[]): boolean {
return roles.includes('admin') || roles.includes('instructor')
}
export function isAdminOnlyAdminRoute(pathname: string): boolean {
return pathname.startsWith('/admin/sitemap')
|| pathname.startsWith('/admin/keycloak-themes')
|| pathname.startsWith('/admin/billing')
|| pathname.startsWith('/admin/audit')
|| pathname.startsWith('/admin/settings')
}
export function getAdminNavItems(roles: string[]): AdminNavItem[] {
const isAdmin = roles.includes('admin')
const isInstructor = roles.includes('instructor')
if (!isAdmin && !isInstructor) {
return []
}
if (isAdmin) {
return BASE_ADMIN_NAV
}
return BASE_ADMIN_NAV
.filter(item => !ADMIN_ONLY_KEYS.has(item.key))
.map(item => {
if (!INSTRUCTOR_DISABLED_KEYS.has(item.key)) {
return item
}
return {
...item,
disabled: true,
description: 'Requires additional permissions',
}
})
}

View File

@ -0,0 +1,156 @@
import { apiRequest } from './api/client'
export type IntegrationKey = 'sendgrid' | 'stripe' | 'minio' | 'keycloak' | 'assetEncryption'
export type IntegrationStatus = {
configured: boolean
setupInstruction: string
details: string[]
}
export type AdminEditableSettings = {
platformIdentity: {
siteName: string
supportEmail: string
}
seoDefaults: {
defaultTitle: string
defaultDescription: string
canonicalBaseUrl: string
}
consentLinks: {
privacyPolicyUrl: string
termsOfServiceUrl: string
cookiePolicyUrl: string
}
}
export type AdminSettingsData = {
integrations: Record<IntegrationKey, IntegrationStatus>
settings: AdminEditableSettings
environment: {
notificationProvider: string
keycloakAuthDisabled: boolean
keycloakThemeDeploy: boolean
databaseConnected: boolean
}
}
type RawIntegration = {
configured?: unknown
setupInstruction?: unknown
[key: string]: unknown
}
type RawSettingsResponse = {
integrations?: Record<string, RawIntegration>
settings?: Record<string, unknown>
environment?: Record<string, unknown>
}
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value === 'object' && value !== null) return value as Record<string, unknown>
return {}
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asBool(value: unknown): boolean {
return value === true
}
function normalizeIntegration(raw: RawIntegration): IntegrationStatus {
const details: string[] = []
for (const [key, value] of Object.entries(raw)) {
if (key === 'configured' || key === 'setupInstruction') continue
if (typeof value === 'string' && value.trim()) {
details.push(`${key}: ${value.trim()}`)
continue
}
if (typeof value === 'number') {
details.push(`${key}: ${value}`)
continue
}
if (typeof value === 'boolean') {
details.push(`${key}: ${value ? 'yes' : 'no'}`)
}
}
return {
configured: asBool(raw.configured),
setupInstruction: asString(raw.setupInstruction) || 'See deployment configuration documentation for setup requirements.',
details,
}
}
export function normalizeAdminSettingsResponse(raw: unknown): AdminSettingsData {
const source = asRecord(raw) as RawSettingsResponse
const integrations = source.integrations ?? {}
const defaultSettings: AdminEditableSettings = {
platformIdentity: {
siteName: '',
supportEmail: '',
},
seoDefaults: {
defaultTitle: '',
defaultDescription: '',
canonicalBaseUrl: '',
},
consentLinks: {
privacyPolicyUrl: '',
termsOfServiceUrl: '',
cookiePolicyUrl: '',
},
}
const rawSettings = asRecord(source.settings)
const rawIdentity = asRecord(rawSettings.platformIdentity)
const rawSeo = asRecord(rawSettings.seoDefaults)
const rawConsent = asRecord(rawSettings.consentLinks)
defaultSettings.platformIdentity.siteName = asString(rawIdentity.siteName)
defaultSettings.platformIdentity.supportEmail = asString(rawIdentity.supportEmail)
defaultSettings.seoDefaults.defaultTitle = asString(rawSeo.defaultTitle)
defaultSettings.seoDefaults.defaultDescription = asString(rawSeo.defaultDescription)
defaultSettings.seoDefaults.canonicalBaseUrl = asString(rawSeo.canonicalBaseUrl)
defaultSettings.consentLinks.privacyPolicyUrl = asString(rawConsent.privacyPolicyUrl)
defaultSettings.consentLinks.termsOfServiceUrl = asString(rawConsent.termsOfServiceUrl)
defaultSettings.consentLinks.cookiePolicyUrl = asString(rawConsent.cookiePolicyUrl)
const rawEnvironment = asRecord(source.environment)
return {
integrations: {
sendgrid: normalizeIntegration(integrations.sendgrid ?? {}),
stripe: normalizeIntegration(integrations.stripe ?? {}),
minio: normalizeIntegration(integrations.minio ?? {}),
keycloak: normalizeIntegration(integrations.keycloak ?? {}),
assetEncryption: normalizeIntegration(integrations.assetEncryption ?? {}),
},
settings: defaultSettings,
environment: {
notificationProvider: asString(rawEnvironment.notificationProvider),
keycloakAuthDisabled: asBool(rawEnvironment.keycloakAuthDisabled),
keycloakThemeDeploy: asBool(rawEnvironment.keycloakThemeDeploy),
databaseConnected: asBool(rawEnvironment.databaseConnected),
},
}
}
export async function loadAdminSettings(): Promise<AdminSettingsData> {
const result = await apiRequest<RawSettingsResponse>('/api/admin/settings/')
return normalizeAdminSettingsResponse(result)
}
export async function saveAdminSettings(settings: AdminEditableSettings): Promise<AdminEditableSettings> {
const result = await apiRequest<{ settings?: unknown }>('/api/admin/settings/', {
method: 'PATCH',
body: JSON.stringify(settings),
})
const normalized = normalizeAdminSettingsResponse({ settings: result.settings })
return normalized.settings
}

View File

@ -0,0 +1,82 @@
import { getAccessToken } from '../auth/keycloak'
const BASE = import.meta.env.VITE_API_BASE ?? ''
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
type RequestOptions = RequestInit & {
skipAuth?: boolean
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers ?? {})
headers.set('Content-Type', 'application/json')
if (!options.skipAuth) {
const token = getAccessToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(`${BASE}${path}`, { ...options, headers })
if (!res.ok) {
let message = `Request failed with status ${res.status}`
try {
const body = await res.json() as { error?: string }
if (body?.error) message = body.error
} catch {
// Non-JSON errors use the status fallback.
}
throw new ApiError(message, res.status)
}
if (res.status === 204) return null as T
return await res.json() as T
}
export async function listResource<T>(resource: string): Promise<T[]> {
const result = await apiRequest<T[] | null>(`/api/${resource}`)
return Array.isArray(result) ? result : []
}
export async function getResource<T>(resource: string, id: string): Promise<T> {
return await apiRequest<T>(`/api/${resource}/${id}`)
}
export async function createResource<T>(resource: string, payload: Record<string, unknown>): Promise<T> {
return await apiRequest<T>(`/api/${resource}`, { method: 'POST', body: JSON.stringify(payload) })
}
export async function patchResource<T>(resource: string, id: string, payload: Record<string, unknown>): Promise<T> {
return await apiRequest<T>(`/api/${resource}/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
}
export async function deleteResource(resource: string, id: string): Promise<void> {
await apiRequest<void>(`/api/${resource}/${id}`, { method: 'DELETE' })
}
export async function syncCollection<T extends { id: string }>(resource: string, desired: T[]): Promise<void> {
const existing = await listResource<T>(resource)
const existingByID = new Map(existing.map(item => [item.id, item]))
for (const item of desired) {
if (existingByID.has(item.id)) {
await patchResource(resource, item.id, item as unknown as Record<string, unknown>)
existingByID.delete(item.id)
} else {
await createResource(resource, item as unknown as Record<string, unknown>)
}
}
for (const stale of existingByID.values()) {
await deleteResource(resource, stale.id)
}
}

View File

@ -0,0 +1,59 @@
import Keycloak from 'keycloak-js'
const isE2EMockMode = import.meta.env.VITE_E2E_AUTH_MODE === 'mock'
const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL ?? 'http://localhost:8081',
realm: import.meta.env.VITE_KEYCLOAK_REALM ?? 'cms',
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'cms-frontend',
})
let refreshTimer: number | null = null
export async function initKeycloak(): Promise<void> {
if (isE2EMockMode) return
await keycloak.init({
onLoad: 'login-required',
checkLoginIframe: false,
pkceMethod: 'S256',
})
if (refreshTimer !== null) window.clearInterval(refreshTimer)
refreshTimer = window.setInterval(async () => {
try {
await keycloak.updateToken(30)
} catch {
await keycloak.login()
}
}, 20_000)
}
export function getAccessToken(): string | null {
if (isE2EMockMode) return 'e2e-mock-token'
return keycloak.token ?? null
}
export function logout(): Promise<void> {
if (isE2EMockMode) return Promise.resolve()
return keycloak.logout({ redirectUri: window.location.origin })
}
export function currentUser() {
if (isE2EMockMode) {
return {
username: 'e2e-learner',
email: 'e2e-learner@example.com',
roles: ['learner', 'admin'],
}
}
return {
username: keycloak.tokenParsed?.preferred_username,
email: keycloak.tokenParsed?.email,
roles: (keycloak.tokenParsed?.realm_access as { roles?: string[] } | undefined)?.roles ?? [],
}
}
export default keycloak

View File

@ -0,0 +1,47 @@
type BlogSeoInput = {
title: string
description?: string
canonicalUrl?: string
}
function upsertMeta(name: string, content: string, attr: 'name' | 'property' = 'name') {
const selector = `meta[${attr}="${name}"]`
let element = document.head.querySelector(selector) as HTMLMetaElement | null
if (!element) {
element = document.createElement('meta')
element.setAttribute(attr, name)
document.head.appendChild(element)
}
element.setAttribute('content', content)
}
function upsertCanonical(url?: string) {
const existing = document.head.querySelector('link[rel="canonical"]') as HTMLLinkElement | null
if (!url) {
existing?.remove()
return
}
if (existing) {
existing.href = url
return
}
const link = document.createElement('link')
link.rel = 'canonical'
link.href = url
document.head.appendChild(link)
}
export function applyBlogSeo(input: BlogSeoInput): void {
document.title = input.title
if (input.description) {
upsertMeta('description', input.description)
upsertMeta('og:description', input.description, 'property')
}
upsertMeta('og:title', input.title, 'property')
upsertMeta('og:type', 'article', 'property')
upsertCanonical(input.canonicalUrl)
}

View File

@ -0,0 +1,40 @@
import type { BlogPost, BlogTaxonomy } from './blog'
export function normalizeToken(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export function taxonomyToken(item: BlogTaxonomy): string {
return normalizeToken(item.slug || item.name || item.id)
}
export function resolveTaxonomyLabel(token: string, items: BlogTaxonomy[]): string {
const normalized = normalizeToken(token)
const matched = items.find(item => taxonomyToken(item) === normalized)
return matched?.name ?? token
}
export function postMatchesFilters(post: BlogPost, categoryToken?: string, tagToken?: string): boolean {
const categories = (post.categories ?? []).map(normalizeToken)
const tags = (post.tags ?? []).map(normalizeToken)
if (categoryToken && !categories.includes(normalizeToken(categoryToken))) {
return false
}
if (tagToken && !tags.includes(normalizeToken(tagToken))) {
return false
}
return true
}
export function summarizePost(post: BlogPost): string {
if (post.excerpt && post.excerpt.trim()) return post.excerpt.trim()
if (post.content && post.content.trim()) {
return post.content.trim().replace(/\s+/g, ' ').slice(0, 200)
}
return ''
}

168
Frontend/src/app/blog.ts Normal file
View File

@ -0,0 +1,168 @@
import { apiRequest } from './api/client'
export type BlogStatus = 'draft' | 'scheduled' | 'published'
export type BlogPost = {
id: string
title: string
slug: string
excerpt?: string
content?: string
authorId?: string
contentPagePath?: string
categories?: string[]
tags?: string[]
status?: BlogStatus
publishedAt?: number
updatedAt?: number
createdAt?: number
seo?: {
title?: string
description?: string
canonicalUrl?: string
}
}
export type BlogTaxonomy = {
id: string
name: string
slug?: string
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === 'string').map(item => item.trim()).filter(Boolean)
}
function asNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
return undefined
}
function normalizePost(raw: unknown): BlogPost {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
title: asString(source.title),
slug: asString(source.slug),
excerpt: asString(source.excerpt) || undefined,
content: asString(source.content) || undefined,
authorId: asString(source.authorId) || undefined,
contentPagePath: asString(source.contentPagePath || source.content_page_path || source.linkedPagePath || source.contentPageUrl) || undefined,
categories: asStringArray(source.categories),
tags: asStringArray(source.tags),
status: asString(source.status) as BlogStatus,
publishedAt: asNumber(source.publishedAt),
updatedAt: asNumber(source.updatedAt),
createdAt: asNumber(source.createdAt),
seo: typeof source.seo === 'object' && source.seo !== null
? {
title: asString((source.seo as Record<string, unknown>).title) || undefined,
description: asString((source.seo as Record<string, unknown>).description) || undefined,
canonicalUrl: asString((source.seo as Record<string, unknown>).canonicalUrl) || undefined,
}
: undefined,
}
}
function normalizeTaxonomy(raw: unknown): BlogTaxonomy {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
name: asString(source.name),
slug: asString(source.slug) || undefined,
}
}
export async function fetchPublicBlogPosts(): Promise<BlogPost[]> {
const result = await apiRequest<unknown[]>('/api/public/blog/posts', { skipAuth: true })
return Array.isArray(result)
? result.map(normalizePost).filter(post => post.slug && post.title)
: []
}
export async function fetchPublicBlogPostBySlug(slug: string): Promise<BlogPost> {
const result = await apiRequest<unknown>(`/api/public/blog/posts/${encodeURIComponent(slug)}`, { skipAuth: true })
return normalizePost(result)
}
export async function fetchPublicBlogCategories(): Promise<BlogTaxonomy[]> {
const result = await apiRequest<unknown[]>('/api/public/blog/categories', { skipAuth: true })
return Array.isArray(result)
? result.map(normalizeTaxonomy).filter(item => item.name)
: []
}
export async function fetchPublicBlogTags(): Promise<BlogTaxonomy[]> {
const result = await apiRequest<unknown[]>('/api/public/blog/tags', { skipAuth: true })
return Array.isArray(result)
? result.map(normalizeTaxonomy).filter(item => item.name)
: []
}
export type AdminBlogPostInput = {
title: string
slug?: string
excerpt?: string
content?: string
contentPagePath?: string
categories?: string[]
tags?: string[]
authorId?: string
status?: BlogStatus
publishedAt?: number
}
export async function fetchAdminBlogPosts(): Promise<BlogPost[]> {
const result = await apiRequest<unknown[]>('/api/blog/posts')
return Array.isArray(result)
? result.map(normalizePost).filter(post => post.id && post.title)
: []
}
export async function fetchAdminBlogCategories(): Promise<BlogTaxonomy[]> {
const result = await apiRequest<unknown[]>('/api/blog/categories')
return Array.isArray(result)
? result.map(normalizeTaxonomy).filter(item => item.name)
: []
}
export async function fetchAdminBlogTags(): Promise<BlogTaxonomy[]> {
const result = await apiRequest<unknown[]>('/api/blog/tags')
return Array.isArray(result)
? result.map(normalizeTaxonomy).filter(item => item.name)
: []
}
export async function fetchAdminBlogAuthors(): Promise<BlogTaxonomy[]> {
const result = await apiRequest<unknown[]>('/api/blog/authors')
return Array.isArray(result)
? result.map(normalizeTaxonomy).filter(item => item.name)
: []
}
export async function createAdminBlogPost(input: AdminBlogPostInput): Promise<BlogPost> {
const result = await apiRequest<unknown>('/api/blog/posts', {
method: 'POST',
body: JSON.stringify(input),
})
return normalizePost(result)
}
export async function updateAdminBlogPost(id: string, input: Partial<AdminBlogPostInput>): Promise<BlogPost> {
const result = await apiRequest<unknown>(`/api/blog/posts/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(input),
})
return normalizePost(result)
}
export async function deleteAdminBlogPost(id: string): Promise<void> {
await apiRequest<void>(`/api/blog/posts/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}

View File

@ -0,0 +1,158 @@
import type { CanvasBlock, Page, DataSource, ThemeConfig } from './types'
import { createResource, deleteResource, listResource, patchResource, syncCollection } from './api/client'
export async function loadPages(): Promise<Page[]> {
return await listResource<Page>('pages')
}
export async function savePages(pages: Page[]): Promise<void> {
await syncCollection('pages', pages)
}
export async function savePage(page: Page, exists: boolean, expectedUpdatedAt?: number): Promise<Page> {
const payload = {
...page,
...(exists && expectedUpdatedAt !== undefined ? { expectedUpdatedAt } : {}),
} as unknown as Record<string, unknown>
return exists
? await patchResource<Page>('pages', page.id, payload)
: await createResource<Page>('pages', payload)
}
export async function deletePage(pageId: string): Promise<void> {
await deleteResource('pages', pageId)
}
export async function loadDataSources(): Promise<DataSource[]> {
return await listResource<DataSource>('datasources')
}
export async function saveDataSources(sources: DataSource[]): Promise<void> {
await syncCollection('datasources', sources)
}
// ── Tree helpers ───────────────────────────────────────────────
export function findBlockInTree(blocks: CanvasBlock[], instanceId: string): CanvasBlock | null {
for (const b of blocks) {
if (b.instanceId === instanceId) return b
if (b.slots) {
for (const slot of b.slots) {
const found = findBlockInTree(slot, instanceId)
if (found) return found
}
}
}
return null
}
export function updateBlockInTree(
blocks: CanvasBlock[], instanceId: string, updater: (b: CanvasBlock) => CanvasBlock,
): CanvasBlock[] {
return blocks.map(b => {
if (b.instanceId === instanceId) return updater(b)
if (b.slots) return { ...b, slots: b.slots.map(slot => updateBlockInTree(slot, instanceId, updater)) }
return b
})
}
export function removeBlockFromTree(blocks: CanvasBlock[], instanceId: string): CanvasBlock[] {
return blocks
.filter(b => b.instanceId !== instanceId)
.map(b => b.slots ? { ...b, slots: b.slots.map(slot => removeBlockFromTree(slot, instanceId)) } : b)
}
export function moveBlockInTree(blocks: CanvasBlock[], instanceId: string, dir: 'up' | 'down'): CanvasBlock[] {
const idx = blocks.findIndex(b => b.instanceId === instanceId)
if (idx !== -1) {
if (dir === 'up' && idx === 0) return blocks
if (dir === 'down' && idx === blocks.length - 1) return blocks
const next = [...blocks]
const swap = dir === 'up' ? idx - 1 : idx + 1
;[next[idx], next[swap]] = [next[swap], next[idx]]
return next
}
return blocks.map(b => b.slots ? { ...b, slots: b.slots.map(slot => moveBlockInTree(slot, instanceId, dir)) } : b)
}
export async function fetchDataSource(source: DataSource): Promise<{ data: Record<string, unknown>[]; error: string | null }> {
if (source.type === 'static') {
try {
const parsed = JSON.parse(source.staticData || '[]')
return { data: Array.isArray(parsed) ? parsed : [parsed], error: null }
} catch {
return { data: [], error: 'Invalid JSON' }
}
}
try {
const hdrs: Record<string, string> = { 'Content-Type': 'application/json' }
try { Object.assign(hdrs, JSON.parse(source.headers || '{}')) } catch {}
const res = await fetch(source.url, {
method: source.method,
headers: hdrs,
body: source.method === 'POST' && source.body ? source.body : undefined,
})
if (!res.ok) return { data: [], error: `HTTP ${res.status} ${res.statusText}` }
const json = await res.json()
const resolved = source.path
? source.path.split('.').reduce((acc: unknown, k) => acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[k] : undefined, json)
: json
const arr = Array.isArray(resolved) ? resolved : (resolved ? [resolved] : [])
return { data: arr as Record<string, unknown>[], error: null }
} catch (e) {
return { data: [], error: String(e) }
}
}
// ── Theme presets ──────────────────────────────────────────────
export const PRESET_THEMES: Record<string, ThemeConfig & { name: string }> = {
zinc: {
name: 'Zinc', background: '#ffffff', foreground: '#09090b', primary: '#4f46e5',
primaryForeground: '#fafafa', muted: '#f4f4f5', mutedForeground: '#71717a',
border: '#e4e4e7', accent: '#f4f4f5', radius: 0.5,
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
dark: {
name: 'Dark', background: '#09090b', foreground: '#fafafa', primary: '#e4e4e7',
primaryForeground: '#18181b', muted: '#27272a', mutedForeground: '#a1a1aa',
border: '#3f3f46', accent: '#27272a', radius: 0.5,
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
rose: {
name: 'Rose', background: '#ffffff', foreground: '#0c0a09', primary: '#e11d48',
primaryForeground: '#fff1f2', muted: '#f5f5f4', mutedForeground: '#78716c',
border: '#e7e5e4', accent: '#fce7f3', radius: 0.5,
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
blue: {
name: 'Blue', background: '#ffffff', foreground: '#020817', primary: '#2563eb',
primaryForeground: '#eff6ff', muted: '#f1f5f9', mutedForeground: '#64748b',
border: '#e2e8f0', accent: '#dbeafe', radius: 0.375,
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
green: {
name: 'Green', background: '#ffffff', foreground: '#052e16', primary: '#16a34a',
primaryForeground: '#f0fdf4', muted: '#f0fdf4', mutedForeground: '#4d7c5f',
border: '#bbf7d0', accent: '#dcfce7', radius: 0.5,
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
orange: {
name: 'Orange', background: '#ffffff', foreground: '#1c0a00', primary: '#ea580c',
primaryForeground: '#fff7ed', muted: '#fff7ed', mutedForeground: '#9a3412',
border: '#fed7aa', accent: '#ffedd5', radius: 0.5,
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
purple: {
name: 'Purple', background: '#ffffff', foreground: '#0f0a19', primary: '#7c3aed',
primaryForeground: '#f5f3ff', muted: '#f5f3ff', mutedForeground: '#6d28d9',
border: '#ddd6fe', accent: '#ede9fe', radius: 0.5,
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
},
rounded: {
name: 'Soft', background: '#fafafa', foreground: '#1e1e2e', primary: '#6d28d9',
primaryForeground: '#ffffff', muted: '#f1f0f8', mutedForeground: '#6b7280',
border: '#e5e7eb', accent: '#ede9fe', radius: 1.25,
fontFamily: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif',
},
}
export const DEFAULT_THEME = PRESET_THEMES.zinc

View File

@ -0,0 +1,19 @@
import type { PropsWithChildren } from 'react'
export type AdminPageSurfaceMode = 'framed' | 'plain'
type AdminPageSurfaceProps = PropsWithChildren<{
mode?: AdminPageSurfaceMode
}>
export function AdminPageSurface({ children, mode = 'framed' }: AdminPageSurfaceProps) {
if (mode === 'plain') {
return <div className="min-h-0 w-full max-w-full overflow-hidden">{children}</div>
}
return (
<section className="mx-auto min-h-0 w-full max-w-full overflow-x-hidden overflow-y-auto rounded-[28px] border border-stone-200/80 bg-white/88 shadow-[0_20px_60px_rgba(65,48,31,0.08)] backdrop-blur">
{children}
</section>
)
}

View File

@ -0,0 +1,246 @@
import { useState } from 'react'
import { X, Type, Paintbrush, Palette, RotateCcw } from 'lucide-react'
import { BLOCKS } from './blocks'
import { ThemeBuilder } from './ThemeBuilder'
import type { ThemeConfig, CanvasBlock, CustomBlockDef, DataSource } from '../App'
type Tab = 'content' | 'style' | 'theme'
function ColorControl({ label, value, onChange, themeVar, themeLabel }: {
label: string
value: string
onChange: (v: string) => void
themeVar?: string
themeLabel?: string
}) {
const isTheme = value === themeVar
const hexVal = /^#[0-9a-fA-F]{6}$/.test(value) ? value : '#888888'
return (
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-zinc-600">{label}</span>
{themeVar && (
<button
onClick={() => onChange(isTheme ? '' : themeVar!)}
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${isTheme ? 'bg-indigo-100 text-indigo-600' : 'text-zinc-400 hover:text-indigo-500'}`}
>
{isTheme ? '✓ Using theme' : `Use ${themeLabel}`}
</button>
)}
</div>
{!isTheme && (
<div className="flex items-center gap-1.5">
<input type="color" aria-label={`${label} color picker`} value={hexVal} onChange={e => onChange(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border border-zinc-200 p-0.5 bg-white" />
<input type="text" aria-label={`${label} value`} value={value} onChange={e => onChange(e.target.value)}
className="flex-1 text-xs font-mono px-2 py-1 border border-zinc-200 rounded outline-none focus:border-indigo-400"
placeholder="#000000 or CSS value" maxLength={30} />
</div>
)}
</div>
)
}
function TextControl({ label, value, onChange, placeholder }: {
label: string; value: string; onChange: (v: string) => void; placeholder?: string
}) {
return (
<div className="mb-2">
<span className="text-xs text-zinc-600 block mb-1">{label}</span>
<input type="text" aria-label={label} value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white"
/>
</div>
)
}
export function BlockEditor({
block, customBlocks, theme, dataSources,
onContentChange, onStyleChange, onStyleReset, onThemeChange, onDeselect,
}: {
block: CanvasBlock
customBlocks: CustomBlockDef[]
theme: ThemeConfig
dataSources: DataSource[]
onContentChange: (instanceId: string, key: string, value: string) => void
onStyleChange: (instanceId: string, key: string, value: string) => void
onStyleReset: (instanceId: string) => void
onThemeChange: (t: ThemeConfig) => void
onDeselect: () => void
}) {
const [tab, setTab] = useState<Tab>('content')
const isCustom = block.blockId.startsWith('custom:')
const def = isCustom ? null : BLOCKS.find(b => b.id === block.blockId)
const customDef = isCustom ? customBlocks.find(b => `custom:${b.id}` === block.blockId) : null
const blockLabel = isCustom ? (customDef?.name ?? 'Custom') : (def?.label ?? block.blockId)
const contentFields = def?.contentFields ?? []
const hasOverrides = Object.keys(block.styles).length > 0
const styleVal = (key: string) => block.styles[key] ?? ''
const setStyle = (key: string, val: string) => onStyleChange(block.instanceId, key, val)
const setContent = (key: string, val: string) => onContentChange(block.instanceId, key, val)
const contentVal = (key: string, fallback: string) => block.content[key] ?? fallback
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-zinc-100 flex items-center justify-between">
<div>
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest mb-0.5">Editing</h2>
<p className="text-sm font-semibold text-zinc-800">{blockLabel}</p>
</div>
<button onClick={onDeselect} aria-label="Close block editor"
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-zinc-100 text-zinc-400 transition-colors">
<X size={13} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-zinc-100 px-3 pt-2 gap-0.5">
{([
{ id: 'content' as Tab, label: 'Content', icon: Type },
{ id: 'style' as Tab, label: 'Style', icon: Paintbrush },
{ id: 'theme' as Tab, label: 'Theme', icon: Palette },
]).map(({ id, label, icon: Icon }) => (
<button key={id} onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-t-md text-xs font-medium transition-all border-b-2 -mb-px ${
tab === id ? 'border-indigo-500 text-indigo-600 bg-indigo-50/50' : 'border-transparent text-zinc-500 hover:text-zinc-700'
}`}>
<Icon size={11} />{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{/* Content tab */}
{tab === 'content' && (
<div className="p-4">
{isCustom ? (
<p className="text-xs text-zinc-400 text-center py-4">Custom React components are edited in the code editor. Click the ✎ icon in the block library.</p>
) : contentFields.length === 0 ? (
<p className="text-xs text-zinc-400 text-center py-4">This block has no editable content fields.</p>
) : (
<div>
{contentFields.map(field => {
const val = contentVal(field.key, def?.defaultContent?.[field.key] ?? '')
return (
<div key={field.key} className="mb-3">
<label className="text-xs font-medium text-zinc-600 block mb-1">{field.label}</label>
{field.type === 'textarea' ? (
<textarea value={val} onChange={e => setContent(field.key, e.target.value)}
placeholder={field.placeholder}
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white resize-none"
style={{ height: '72px' }} />
) : field.type === 'select' ? (
<select value={val} onChange={e => setContent(field.key, e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white">
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
) : field.type === 'image' ? (
<div>
<input type="url" value={val}
onChange={e => setContent(field.key, e.target.value)}
placeholder={field.placeholder ?? 'https://example.com/image.jpg'}
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white mb-1.5" />
{val && (
<div className="rounded border border-zinc-200 overflow-hidden bg-zinc-50" style={{ height: '80px' }}>
<img src={val} alt="preview" className="w-full h-full object-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
</div>
)}
</div>
) : field.type === 'datasource' ? (
<div>
<select value={val} onChange={e => setContent(field.key, e.target.value)}
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white">
<option value="">— Select data source —</option>
{dataSources.map(ds => <option key={ds.id} value={ds.id}>{ds.name}</option>)}
</select>
{dataSources.length === 0 && (
<p className="text-xs text-zinc-400 mt-1">No data sources yet. Click "Data" in the header to add one.</p>
)}
</div>
) : field.type === 'datafield' ? (
<input type="text" value={val}
onChange={e => setContent(field.key, e.target.value)}
placeholder={field.placeholder ?? field.hint ?? 'field name'}
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white font-mono" />
) : (
<input type={field.type === 'url' ? 'url' : 'text'} value={val}
onChange={e => setContent(field.key, e.target.value)}
placeholder={field.placeholder}
className="w-full text-xs px-2 py-1.5 border border-zinc-200 rounded outline-none focus:border-indigo-400 bg-white" />
)}
</div>
)
})}
</div>
)}
</div>
)}
{/* Style tab */}
{tab === 'style' && (
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Component Overrides</span>
{hasOverrides && (
<button onClick={() => onStyleReset(block.instanceId)}
className="flex items-center gap-1 text-xs text-zinc-400 hover:text-red-500 transition-colors">
<RotateCcw size={9} /> Reset
</button>
)}
</div>
<ColorControl label="Background" value={styleVal('backgroundColor')}
onChange={v => setStyle('backgroundColor', v)}
themeVar="var(--cms-bg)" themeLabel="bg" />
<ColorControl label="Text color" value={styleVal('color')}
onChange={v => setStyle('color', v)}
themeVar="var(--cms-fg)" themeLabel="fg" />
<div className="border-t border-zinc-100 my-3" />
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Spacing</p>
<TextControl label="Padding" value={styleVal('padding')} onChange={v => setStyle('padding', v)} placeholder="1rem 2rem" />
<div className="grid grid-cols-2 gap-2">
<TextControl label="Margin top" value={styleVal('marginTop')} onChange={v => setStyle('marginTop', v)} placeholder="1rem" />
<TextControl label="Margin bottom" value={styleVal('marginBottom')} onChange={v => setStyle('marginBottom', v)} placeholder="1rem" />
</div>
<div className="border-t border-zinc-100 my-3" />
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-3">Shape & Layout</p>
<TextControl label="Border radius" value={styleVal('borderRadius')} onChange={v => setStyle('borderRadius', v)} placeholder="0.5rem" />
<TextControl label="Max width" value={styleVal('maxWidth')} onChange={v => setStyle('maxWidth', v)} placeholder="640px" />
<div className="border-t border-zinc-100 my-3" />
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Opacity</p>
<div className="flex items-center gap-2 mb-3">
<input type="range" min={0} max={1} step={0.05}
value={parseFloat(styleVal('opacity') || '1')}
onChange={e => setStyle('opacity', e.target.value)}
className="flex-1 accent-indigo-500" />
<span className="text-xs font-mono text-zinc-500 w-8 text-right">{styleVal('opacity') || '1'}</span>
</div>
<div className="border-t border-zinc-100 my-3" />
<p className="text-xs font-semibold text-zinc-500 uppercase tracking-wider mb-2">Border</p>
<TextControl label="Border" value={styleVal('border')} onChange={v => setStyle('border', v)} placeholder="1px solid #e4e4e7" />
<TextControl label="Box shadow" value={styleVal('boxShadow')} onChange={v => setStyle('boxShadow', v)} placeholder="0 4px 24px rgba(0,0,0,0.1)" />
</div>
)}
{/* Theme tab */}
{tab === 'theme' && (
<div>
<div className="px-4 py-2 bg-indigo-50 border-b border-indigo-100">
<p className="text-xs text-indigo-600">Changes here apply to all blocks via CSS variables.</p>
</div>
<ThemeBuilder theme={theme} onChange={onThemeChange} />
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,257 @@
import { useState } from 'react'
import { useDrag } from 'react-dnd'
import { Search, ChevronDown, ChevronRight, Plus, Code2, Trash2 } from 'lucide-react'
import { BLOCKS, CATEGORIES, type BlockDef } from './blocks'
import type { CustomBlockDef } from '../App'
export const DRAG_TYPE = 'CMS_BLOCK'
function DraggableBlockItem({ block, onAdd }: { block: BlockDef; onAdd: (id: string) => void }) {
const [{ isDragging }, drag] = useDrag(() => ({
type: DRAG_TYPE,
item: { blockId: block.id },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}))
const Icon = block.icon
return (
<button
type="button"
ref={drag as unknown as React.Ref<HTMLButtonElement>}
onClick={() => onAdd(block.id)}
title={`Click or drag to add "${block.label}"`}
aria-label={`Add ${block.label} block`}
style={{ opacity: isDragging ? 0.4 : 1 }}
className="flex w-full items-center gap-2.5 px-3 py-2 rounded-md cursor-grab active:cursor-grabbing hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-500 transition-colors group select-none"
>
<div className="w-7 h-7 rounded flex items-center justify-center bg-zinc-100 group-hover:bg-white transition-colors flex-shrink-0">
<Icon size={14} className="text-zinc-500" />
</div>
<span className="text-sm text-zinc-700">{block.label}</span>
</button>
)
}
function DraggableCustomItem({
block,
onAdd,
onDelete,
}: {
block: CustomBlockDef
onAdd: (id: string) => void
onDelete: (id: string) => void
}) {
const blockId = `custom:${block.id}`
const [{ isDragging }, drag] = useDrag(() => ({
type: DRAG_TYPE,
item: { blockId },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}))
return (
<div
ref={drag as unknown as React.Ref<HTMLDivElement>}
role="button"
tabIndex={0}
aria-label={`Add ${block.name} custom block`}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onAdd(blockId)
}
}}
style={{ opacity: isDragging ? 0.4 : 1 }}
className="flex items-center gap-2 px-3 py-2 rounded-md hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-500 transition-colors group select-none cursor-grab active:cursor-grabbing"
>
<div
className="w-7 h-7 rounded flex items-center justify-center bg-indigo-50 group-hover:bg-indigo-100 transition-colors flex-shrink-0"
onClick={() => onAdd(blockId)}
>
<Code2 size={13} className="text-indigo-500" />
</div>
<span
className="text-sm text-zinc-700 flex-1 truncate"
onClick={() => onAdd(blockId)}
>
{block.name}
</span>
<div className="hidden group-hover:flex group-focus-within:flex items-center gap-0.5 flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); onDelete(block.id) }}
title="Delete component"
className="w-6 h-6 flex items-center justify-center rounded hover:bg-red-50 text-zinc-400 hover:text-red-500 transition-colors"
>
<Trash2 size={11} />
</button>
</div>
</div>
)
}
function CollapsibleCategory({
label,
blocks,
onAdd,
}: {
label: string
blocks: BlockDef[]
onAdd: (id: string) => void
}) {
const [open, setOpen] = useState(true)
return (
<div className="mb-1">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between px-4 py-1.5 hover:bg-zinc-50 transition-colors group"
>
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider group-hover:text-zinc-500 transition-colors">
{label}
</span>
{open
? <ChevronDown size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
: <ChevronRight size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
}
</button>
{open && (
<div className="px-2">
{blocks.map((block) => (
<DraggableBlockItem key={block.id} block={block} onAdd={onAdd} />
))}
</div>
)}
</div>
)
}
function CustomSection({
customBlocks,
onAdd,
onDelete,
}: {
customBlocks: CustomBlockDef[]
onAdd: (id: string) => void
onDelete: (id: string) => void
}) {
const [open, setOpen] = useState(true)
return (
<div className="mb-1">
<div className="flex items-center">
<button
onClick={() => setOpen((o) => !o)}
className="flex-1 flex items-center justify-between px-4 py-1.5 hover:bg-zinc-50 transition-colors group"
>
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider group-hover:text-zinc-500 transition-colors flex items-center gap-1.5">
<Code2 size={11} className="text-indigo-400" />
Custom
{customBlocks.length > 0 && (
<span className="bg-indigo-100 text-indigo-500 rounded-full px-1.5 py-0 text-xs font-bold leading-4">
{customBlocks.length}
</span>
)}
</span>
{open
? <ChevronDown size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
: <ChevronRight size={12} className="text-zinc-300 group-hover:text-zinc-400 transition-colors" />
}
</button>
</div>
{open && (
<div className="px-2">
{customBlocks.length === 0 ? (
<div className="px-3 py-3 text-center">
<p className="text-xs text-zinc-400 mb-2">No custom components yet</p>
</div>
) : (
customBlocks.map((b) => (
<DraggableCustomItem
key={b.id}
block={b}
onAdd={onAdd}
onDelete={onDelete}
/>
))
)}
<button
disabled
title="Requires an isolated custom component sandbox"
className="mt-1 flex w-full cursor-not-allowed items-center justify-center gap-1.5 rounded-md border border-dashed border-zinc-200 px-3 py-2 text-xs font-medium text-zinc-400"
>
<Plus size={12} />
Sandbox required
</button>
</div>
)}
</div>
)
}
export function BlockLibrary({
onAddBlock,
customBlocks,
onDeleteCustomBlock,
}: {
onAddBlock: (blockId: string) => void
customBlocks: CustomBlockDef[]
onDeleteCustomBlock: (id: string) => void
}) {
const [query, setQuery] = useState('')
const filtered = query.trim()
? BLOCKS.filter((b) => b.label.toLowerCase().includes(query.toLowerCase()))
: BLOCKS
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-zinc-100">
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest mb-3">Blocks</h2>
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
aria-label="Search blocks"
placeholder="Search blocks…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-zinc-100 rounded-md outline-none placeholder:text-zinc-400 focus:ring-1 focus:ring-zinc-300"
/>
</div>
</div>
{/* Block list */}
<div className="flex-1 overflow-y-auto py-2">
{query.trim() ? (
<div className="px-2">
{filtered.length === 0 ? (
<p className="text-xs text-zinc-400 px-3 py-4 text-center">No blocks match "{query}"</p>
) : (
filtered.map((block) => (
<DraggableBlockItem key={block.id} block={block} onAdd={onAddBlock} />
))
)}
</div>
) : (
<>
<CustomSection
customBlocks={customBlocks}
onAdd={onAddBlock}
onDelete={onDeleteCustomBlock}
/>
<div className="my-2 mx-4 border-t border-zinc-100" />
{CATEGORIES.map((cat) => {
const catBlocks = BLOCKS.filter((b) => b.category === cat)
return <CollapsibleCategory key={cat} label={cat} blocks={catBlocks} onAdd={onAddBlock} />
})}
</>
)}
</div>
<div className="px-4 py-3 border-t border-zinc-100">
<p className="text-xs text-zinc-400 text-center">Click or drag blocks onto the canvas</p>
</div>
</div>
)
}

View File

@ -0,0 +1,303 @@
import { useDrop } from 'react-dnd'
import { Trash2, ChevronUp, ChevronDown, GripVertical, Plus } from 'lucide-react'
import { DRAG_TYPE } from './BlockLibrary'
import { BLOCKS, type BlockDef } from './blocks'
import { CustomBlockRenderer } from './CustomBlockRenderer'
import type { ThemeConfig, CanvasBlock, CustomBlockDef, DataSource, DataCacheEntry } from '../App'
function blockById(id: string): BlockDef | undefined {
return BLOCKS.find((b) => b.id === id)
}
function SlotDropZone({
blocks, slotIndex, parentInstanceId,
theme, customBlocks, selectedId, dataCache,
onSelect, onRemove, onMove, onDrop,
}: {
blocks: CanvasBlock[]
slotIndex: number
parentInstanceId: string
theme: ThemeConfig
customBlocks: CustomBlockDef[]
selectedId: string | null
dataCache: Record<string, DataCacheEntry>
onSelect: (id: string | null) => void
onRemove: (instanceId: string) => void
onMove: (instanceId: string, dir: 'up' | 'down') => void
onDrop: (blockId: string, parentInstanceId?: string, slotIndex?: number) => void
}) {
const [{ isOver, canDrop }, drop] = useDrop<{ blockId: string }, { dropped: boolean }, { isOver: boolean; canDrop: boolean }>({
accept: DRAG_TYPE,
drop: (item, monitor) => {
if (!monitor.isOver({ shallow: true })) return undefined
onDrop(item.blockId, parentInstanceId, slotIndex)
return { dropped: true }
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
})
const dropActive = isOver && canDrop
return (
<div
ref={drop as unknown as React.Ref<HTMLDivElement>}
onClick={(e) => e.stopPropagation()}
style={{
minHeight: 64, borderRadius: '4px',
border: dropActive ? '2px dashed #6366f1' : '2px dashed transparent',
background: dropActive ? 'rgba(99,102,241,0.05)' : 'transparent',
transition: 'all 0.12s', padding: '2px',
}}
>
{blocks.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 60, color: 'var(--cms-muted-fg)', fontSize: '0.72rem', pointerEvents: 'none', gap: '0.375rem' }}>
<Plus size={12} />{dropActive ? 'Release to drop' : 'Drop blocks here'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{blocks.map((block, idx) => (
<CanvasItem
key={block.instanceId}
block={block}
selected={selectedId === block.instanceId}
isFirst={idx === 0}
isLast={idx === blocks.length - 1}
theme={theme}
customBlocks={customBlocks}
selectedId={selectedId}
dataCache={dataCache}
onSelect={onSelect}
onRemove={onRemove}
onMove={onMove}
onDrop={onDrop}
/>
))}
{dropActive && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0.5rem', color: '#6366f1', fontSize: '0.75rem', fontWeight: 500, borderTop: '1px dashed #c7d2fe' }}>
<Plus size={12} style={{ marginRight: '0.25rem' }} />Drop to add
</div>
)}
</div>
)}
</div>
)
}
function CanvasItem({
block, selected, isFirst, isLast,
theme, customBlocks, selectedId, dataCache,
onSelect, onRemove, onMove, onDrop,
}: {
block: CanvasBlock
selected: boolean
isFirst: boolean
isLast: boolean
theme: ThemeConfig
customBlocks: CustomBlockDef[]
selectedId: string | null
dataCache: Record<string, DataCacheEntry>
onSelect: (id: string | null) => void
onRemove: (instanceId: string) => void
onMove: (instanceId: string, dir: 'up' | 'down') => void
onDrop: (blockId: string, parentInstanceId?: string, slotIndex?: number) => void
}) {
const isCustom = block.blockId.startsWith('custom:')
const customId = isCustom ? block.blockId.slice(7) : null
const customDef = customId ? customBlocks.find((b) => b.id === customId) : null
const def = isCustom ? null : blockById(block.blockId)
const label = isCustom ? (customDef?.name ?? 'Custom') : def?.label ?? ''
if (!isCustom && !def) return null
if (isCustom && !customDef) return null
const slotNodes = def?.slots
? Array.from({ length: def.slots }, (_, i) => (
<SlotDropZone
key={i}
blocks={block.slots?.[i] ?? []}
slotIndex={i}
parentInstanceId={block.instanceId}
theme={theme}
customBlocks={customBlocks}
selectedId={selectedId}
dataCache={dataCache}
onSelect={onSelect}
onRemove={onRemove}
onMove={onMove}
onDrop={onDrop}
/>
))
: undefined
const blockData = def?.isDynamic
? dataCache[block.content.dataSource ?? '']?.data ?? []
: undefined
return (
<div
onClick={(e) => { e.stopPropagation(); onSelect(block.instanceId) }}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
onSelect(block.instanceId)
}
}}
role="group"
tabIndex={0}
aria-label={`Select ${label} block`}
aria-current={selected ? 'true' : undefined}
style={{ position: 'relative', outline: selected ? '2px solid #6366f1' : '2px solid transparent', outlineOffset: '2px', borderRadius: '6px', cursor: 'default', transition: 'outline 0.1s' }}
className="group focus-visible:outline focus-visible:outline-2 focus-visible:outline-indigo-500"
>
{selected && (
<div
style={{ position: 'absolute', top: '-36px', right: 0, zIndex: 10, display: 'flex', gap: '4px', background: '#6366f1', borderRadius: '6px', padding: '4px' }}
onClick={(e) => e.stopPropagation()}
>
<button onClick={() => onMove(block.instanceId, 'up')} disabled={isFirst} title="Move up"
style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', borderRadius: '4px', cursor: isFirst ? 'not-allowed' : 'pointer', opacity: isFirst ? 0.4 : 1, color: '#fff' }}>
<ChevronUp size={14} />
</button>
<button onClick={() => onMove(block.instanceId, 'down')} disabled={isLast} title="Move down"
style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', borderRadius: '4px', cursor: isLast ? 'not-allowed' : 'pointer', opacity: isLast ? 0.4 : 1, color: '#fff' }}>
<ChevronDown size={14} />
</button>
<div style={{ width: 1, background: 'rgba(255,255,255,0.3)', margin: '4px 0' }} />
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.8)' }}>
<GripVertical size={12} />
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.3)', margin: '4px 0' }} />
<button onClick={() => onRemove(block.instanceId)} title="Remove block"
style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: 'none', borderRadius: '4px', cursor: 'pointer', color: '#fca5a5' }}>
<Trash2 size={13} />
</button>
</div>
)}
{!selected && (
<div style={{ position: 'absolute', top: 0, left: 0, zIndex: 5, opacity: 0, pointerEvents: 'none', transition: 'opacity 0.15s' }} className="group-hover:opacity-100">
<span style={{ background: isCustom ? 'rgba(79,70,229,0.9)' : 'rgba(99,102,241,0.9)', color: '#fff', fontSize: '0.65rem', fontWeight: 600, padding: '2px 6px', borderRadius: '4px 0 4px 0', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
{label}
</span>
</div>
)}
<div style={{
fontFamily: theme.fontFamily,
['--cms-bg' as string]: theme.background,
['--cms-fg' as string]: theme.foreground,
['--cms-primary' as string]: theme.primary,
['--cms-primary-fg' as string]: theme.primaryForeground,
['--cms-muted' as string]: theme.muted,
['--cms-muted-fg' as string]: theme.mutedForeground,
['--cms-border' as string]: theme.border,
['--cms-accent' as string]: theme.accent,
['--cms-radius' as string]: `${theme.radius}rem`,
['--cms-font' as string]: theme.fontFamily,
} as React.CSSProperties}>
{isCustom && customDef
? <CustomBlockRenderer code={customDef.code} theme={theme} />
: <div style={block.styles as React.CSSProperties}>{def!.render({ ...(def!.defaultContent ?? {}), ...block.content }, slotNodes, blockData)}</div>
}
</div>
</div>
)
}
export function Canvas({
blocks, customBlocks, theme, selectedId, dataSources: _dataSources, dataCache,
onSelect, onDrop, onRemove, onMove,
}: {
blocks: CanvasBlock[]
customBlocks: CustomBlockDef[]
theme: ThemeConfig
selectedId: string | null
dataSources: DataSource[]
dataCache: Record<string, DataCacheEntry>
onSelect: (id: string | null) => void
onDrop: (blockId: string, parentInstanceId?: string, slotIndex?: number) => void
onRemove: (instanceId: string) => void
onMove: (instanceId: string, dir: 'up' | 'down') => void
}) {
const [{ isOver, canDrop }, drop] = useDrop<{ blockId: string }, void, { isOver: boolean; canDrop: boolean }>({
accept: DRAG_TYPE,
drop: (item, monitor) => {
if (monitor.didDrop()) return
onDrop(item.blockId)
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
})
const dropActive = isOver && canDrop
return (
<div className="min-h-full flex flex-col" style={{ background: '#f1f5f9' }} onClick={() => onSelect(null)}>
<div className="flex items-center justify-between px-6 py-2 bg-white border-b border-zinc-200 sticky top-0 z-20">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full bg-red-400" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-400" />
<div className="w-2.5 h-2.5 rounded-full bg-green-400" />
<span className="ml-2 text-xs text-zinc-400 font-medium">Canvas Preview</span>
</div>
<div className="flex items-center gap-1">
{blocks.length > 0 && <span className="text-xs text-zinc-400">{blocks.length} block{blocks.length !== 1 ? 's' : ''}</span>}
</div>
</div>
<div className="flex-1 flex justify-center px-8 py-8">
<div
style={{ background: theme.background, width: '100%', maxWidth: '860px', minHeight: '600px', borderRadius: '8px', boxShadow: '0 4px 24px rgba(0,0,0,0.08)', padding: '2rem', boxSizing: 'border-box', position: 'relative' }}
ref={drop as unknown as React.Ref<HTMLDivElement>}
>
{blocks.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '480px', border: `2px dashed ${dropActive ? '#6366f1' : '#cbd5e1'}`, borderRadius: '8px', transition: 'border-color 0.15s, background 0.15s', background: dropActive ? 'rgba(99,102,241,0.04)' : 'transparent' }}>
<div style={{ width: 48, height: 48, borderRadius: '12px', background: dropActive ? '#6366f1' : '#e2e8f0', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '1rem', transition: 'background 0.15s' }}>
<Plus size={22} color={dropActive ? '#fff' : '#94a3b8'} />
</div>
<p style={{ color: '#64748b', fontSize: '0.9rem', fontWeight: 500, margin: 0 }}>
{dropActive ? 'Release to add block' : 'Drop blocks here or click from the sidebar'}
</p>
<p style={{ color: '#94a3b8', fontSize: '0.8rem', marginTop: '0.375rem' }}>
Start building your page by selecting blocks on the left
</p>
</div>
) : (
<div style={{ minHeight: '400px', border: dropActive ? '2px dashed #6366f1' : '2px dashed transparent', borderRadius: '6px', padding: dropActive ? '0.5rem' : '0', transition: 'all 0.15s', background: dropActive ? 'rgba(99,102,241,0.03)' : 'transparent' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{blocks.map((block, idx) => (
<CanvasItem
key={block.instanceId}
block={block}
selected={selectedId === block.instanceId}
isFirst={idx === 0}
isLast={idx === blocks.length - 1}
theme={theme}
customBlocks={customBlocks}
selectedId={selectedId}
dataCache={dataCache}
onSelect={onSelect}
onRemove={onRemove}
onMove={onMove}
onDrop={onDrop}
/>
))}
</div>
{dropActive && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', color: '#6366f1', fontSize: '0.875rem', fontWeight: 500, borderTop: '1px dashed #c7d2fe', marginTop: '0.5rem' }}>
<Plus size={16} style={{ marginRight: '0.375rem' }} />Drop to add at end
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,229 @@
import { useMemo, useState } from 'react'
import { X } from 'lucide-react'
import {
DEFAULT_CONSENT,
acceptAllConsent,
readConsentPreferences,
rejectNonEssentialConsent,
saveConsentPreferences,
withDecisionTimestamp,
type ConsentPreferences,
} from '../consent'
function CategoryToggle({
label,
description,
checked,
disabled,
onChange,
}: {
label: string
description: string
checked: boolean
disabled?: boolean
onChange: (checked: boolean) => void
}) {
return (
<label className="flex items-start justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2.5">
<span className="min-w-0">
<span className="block text-xs font-semibold text-zinc-800">{label}</span>
<span className="mt-0.5 block text-[11px] leading-4 text-zinc-500">{description}</span>
</span>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={event => onChange(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-60"
/>
</label>
)
}
export function ConsentManager() {
const [savedConsent, setSavedConsent] = useState<ConsentPreferences | null>(() => readConsentPreferences())
const [editingConsent, setEditingConsent] = useState<ConsentPreferences>(() => savedConsent ?? DEFAULT_CONSENT)
const [showPreferences, setShowPreferences] = useState(false)
const [showReopenShortcut, setShowReopenShortcut] = useState(true)
const needsDecision = savedConsent === null
const consentSummary = useMemo(() => {
if (!savedConsent) {
return 'No consent choice saved yet.'
}
const enabled = [
savedConsent.analytics ? 'analytics' : null,
savedConsent.marketing ? 'marketing' : null,
savedConsent.preferences ? 'preferences' : null,
].filter(Boolean)
if (enabled.length === 0) {
return 'Only necessary cookies enabled.'
}
return `Enabled: necessary, ${enabled.join(', ')}.`
}, [savedConsent])
const persist = (next: ConsentPreferences) => {
saveConsentPreferences(next)
setSavedConsent(next)
setEditingConsent(next)
setShowPreferences(false)
}
return (
<>
{needsDecision && (
<div data-testid="consent-banner" className="fixed inset-x-0 bottom-0 z-[100] border-t border-zinc-200 bg-white/98 px-4 py-3 shadow-[0_-10px_30px_rgba(0,0,0,0.08)] backdrop-blur">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="max-w-3xl">
<p className="text-sm font-semibold text-zinc-900">Cookie Preferences</p>
<p className="mt-1 text-xs text-zinc-600">
We use necessary cookies to run the platform. You can accept all, reject non-essential cookies, or customize preferences.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
data-testid="consent-customize"
onClick={() => {
setEditingConsent(savedConsent ?? DEFAULT_CONSENT)
setShowPreferences(true)
}}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 transition-colors hover:bg-zinc-100"
>
Customize
</button>
<button
data-testid="consent-reject-non-essential"
onClick={() => persist(rejectNonEssentialConsent())}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 transition-colors hover:bg-zinc-100"
>
Reject Non-essential
</button>
<button
data-testid="consent-accept-all"
onClick={() => persist(acceptAllConsent())}
className="rounded-md bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-zinc-800"
>
Accept All
</button>
</div>
</div>
</div>
)}
{!needsDecision && showReopenShortcut && (
<div className="fixed bottom-4 left-4 z-[90] flex items-center gap-1.5">
<button
data-testid="consent-reopen-preferences"
onClick={() => {
setEditingConsent(savedConsent)
setShowPreferences(true)
}}
className="rounded-full border border-zinc-300 bg-white px-3 py-1.5 text-[11px] font-semibold text-zinc-700 shadow-sm transition-colors hover:bg-zinc-100"
title={consentSummary}
>
Cookie Preferences
</button>
<button
data-testid="consent-dismiss-reopen-preferences"
onClick={() => setShowReopenShortcut(false)}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-zinc-300 bg-white text-zinc-500 shadow-sm transition-colors hover:bg-zinc-100 hover:text-zinc-700"
aria-label="Dismiss cookie preferences shortcut"
title="Dismiss"
>
<X size={11} />
</button>
</div>
)}
{showPreferences && (
<div
data-testid="consent-modal"
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4"
onClick={() => setShowPreferences(false)}
>
<div
className="w-full max-w-2xl rounded-xl border border-zinc-200 bg-zinc-50 shadow-2xl"
onClick={event => event.stopPropagation()}
>
<div className="border-b border-zinc-200 bg-white px-5 py-4">
<h2 className="text-sm font-semibold text-zinc-900">Consent Preferences</h2>
<p className="mt-1 text-xs text-zinc-600">Manage which optional cookie categories are enabled for this browser.</p>
</div>
<div className="space-y-2 p-4">
<CategoryToggle
label="Necessary"
description="Required for login, security, and essential application functionality."
checked={true}
disabled
onChange={() => {}}
/>
<CategoryToggle
label="Analytics"
description="Allows anonymized usage measurement to improve product experience."
checked={editingConsent.analytics}
onChange={checked => setEditingConsent(prev => ({ ...prev, analytics: checked }))}
/>
<CategoryToggle
label="Marketing"
description="Allows campaign attribution and marketing optimization features."
checked={editingConsent.marketing}
onChange={checked => setEditingConsent(prev => ({ ...prev, marketing: checked }))}
/>
<CategoryToggle
label="Preferences"
description="Saves personalization options such as UI preferences and defaults."
checked={editingConsent.preferences}
onChange={checked => setEditingConsent(prev => ({ ...prev, preferences: checked }))}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 bg-white px-5 py-3">
<p className="text-[11px] text-zinc-500">{consentSummary}</p>
<div className="flex flex-wrap items-center gap-2">
<button
data-testid="consent-modal-cancel"
onClick={() => setShowPreferences(false)}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-100"
>
Cancel
</button>
<button
data-testid="consent-modal-reject-non-essential"
onClick={() => persist(rejectNonEssentialConsent())}
className="rounded-md border border-zinc-300 px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-100"
>
Reject Non-essential
</button>
<button
data-testid="consent-modal-accept-all"
onClick={() => persist(acceptAllConsent())}
className="rounded-md border border-zinc-800 px-3 py-1.5 text-xs font-semibold text-zinc-900 hover:bg-zinc-100"
>
Accept All
</button>
<button
data-testid="consent-modal-save-preferences"
onClick={() => persist(withDecisionTimestamp({
necessary: true,
analytics: editingConsent.analytics,
marketing: editingConsent.marketing,
preferences: editingConsent.preferences,
}))}
className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-indigo-500"
>
Save Preferences
</button>
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@ -0,0 +1,20 @@
import type { ThemeConfig } from '../App'
export function CustomBlockRenderer({ code: _code, theme }: { code: string; theme: ThemeConfig }) {
return (
<div
role="status"
style={{
border: `1px dashed ${theme.border}`,
background: theme.muted,
color: theme.mutedForeground,
borderRadius: `${theme.radius}rem`,
padding: '1rem',
fontFamily: theme.fontFamily,
fontSize: '0.8rem',
}}
>
Custom component execution is disabled until an isolated sandbox runtime is configured.
</div>
)
}

View File

@ -0,0 +1,379 @@
import { useState, useCallback, useEffect } from 'react'
import {
X, Plus, Trash2, Database, Globe, FileJson, Play, ChevronRight,
CheckCircle2, XCircle, Loader2, Eye, EyeOff, RefreshCw,
} from 'lucide-react'
import type { DataSource, DataCacheEntry } from '../App'
const EMPTY: DataSource = {
id: '', name: '', type: 'rest', url: '', method: 'GET',
headers: '{}', body: '', staticData: '[\n {\n "id": 1,\n "title": "Example item",\n "description": "A sample data entry"\n }\n]',
path: '',
}
function getFieldsFromData(data: Record<string, unknown>[]): string[] {
if (!data.length) return []
return Object.keys(data[0])
}
function resolvePath(obj: unknown, path: string): unknown {
if (!path.trim()) return obj
return path.split('.').reduce((acc: unknown, key) => {
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key]
return undefined
}, obj)
}
async function fetchSource(source: DataSource): Promise<{ data: Record<string, unknown>[]; error: string | null }> {
if (source.type === 'static') {
try {
const parsed = JSON.parse(source.staticData || '[]')
const arr = Array.isArray(parsed) ? parsed : [parsed]
return { data: arr as Record<string, unknown>[], error: null }
} catch {
return { data: [], error: 'Invalid JSON' }
}
}
try {
const hdrs: Record<string, string> = { 'Content-Type': 'application/json' }
try { Object.assign(hdrs, JSON.parse(source.headers || '{}')) } catch {}
const res = await fetch(source.url, {
method: source.method,
headers: hdrs,
body: source.method === 'POST' && source.body ? source.body : undefined,
})
if (!res.ok) return { data: [], error: `HTTP ${res.status} ${res.statusText}` }
const json = await res.json()
const resolved = source.path ? resolvePath(json, source.path) : json
const arr = Array.isArray(resolved) ? resolved : (resolved ? [resolved] : [])
return { data: arr as Record<string, unknown>[], error: null }
} catch (e) {
return { data: [], error: String(e) }
}
}
// ── Example presets ────────────────────────────────────────────
const PRESETS = [
{
label: 'JSONPlaceholder Posts',
source: { ...EMPTY, name: 'Blog Posts', type: 'rest' as const, url: 'https://jsonplaceholder.typicode.com/posts', method: 'GET' as const, path: '' },
},
{
label: 'JSONPlaceholder Users',
source: { ...EMPTY, name: 'Users', type: 'rest' as const, url: 'https://jsonplaceholder.typicode.com/users', method: 'GET' as const, path: '' },
},
{
label: 'Open Library Search',
source: { ...EMPTY, name: 'Books', type: 'rest' as const, url: 'https://openlibrary.org/search.json?q=javascript&limit=10', method: 'GET' as const, path: 'docs' },
},
{
label: 'Static JSON',
source: { ...EMPTY, name: 'My Data', type: 'static' as const },
},
]
export function DataSourceManager({
dataSources,
dataCache,
onSave,
onDelete,
onRefresh,
onClose,
}: {
dataSources: DataSource[]
dataCache: Record<string, DataCacheEntry>
onSave: (source: DataSource) => void
onDelete: (id: string) => void
onRefresh: (id: string) => void
onClose: () => void
}) {
const [editing, setEditing] = useState<DataSource | null>(null)
const [testResult, setTestResult] = useState<{ data: Record<string, unknown>[]; error: string | null } | null>(null)
const [testing, setTesting] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const startNew = (preset?: DataSource) => {
const id = `ds-${Date.now()}`
setEditing({ ...(preset ?? EMPTY), id })
setTestResult(null)
setShowPreview(false)
}
const handleTest = useCallback(async () => {
if (!editing) return
setTesting(true)
setTestResult(null)
const result = await fetchSource(editing)
setTestResult(result)
setTesting(false)
setShowPreview(true)
}, [editing])
const handleSave = () => {
if (!editing || !editing.name.trim()) return
onSave(editing)
setEditing(null)
setTestResult(null)
}
const fields = testResult?.data ? getFieldsFromData(testResult.data) : []
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'stretch', background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)' }}
onClick={onClose}
>
<div
style={{ display: 'flex', width: '100%', height: '100%', maxWidth: 940, margin: 'auto', background: '#fff', borderRadius: '16px', overflow: 'hidden', boxShadow: '0 24px 80px rgba(0,0,0,0.25)', maxHeight: '88vh' }}
onClick={e => e.stopPropagation()}
>
{/* Source list */}
<div style={{ width: 260, flexShrink: 0, borderRight: '1px solid #f1f5f9', display: 'flex', flexDirection: 'column', background: '#fafafa' }}>
<div style={{ padding: '1rem', borderBottom: '1px solid #f1f5f9', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<p style={{ margin: 0, fontSize: '0.8rem', fontWeight: 700, color: '#0f172a' }}>Data Sources</p>
<p style={{ margin: '2px 0 0', fontSize: '0.7rem', color: '#94a3b8' }}>{dataSources.length} configured</p>
</div>
<button onClick={() => startNew()} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.3rem 0.625rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600 }}>
<Plus size={11} />New
</button>
</div>
{/* Presets */}
{dataSources.length === 0 && !editing && (
<div style={{ padding: '0.75rem', borderBottom: '1px solid #f1f5f9' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.7rem', fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Quick start</p>
{PRESETS.map(p => (
<button key={p.label} onClick={() => startNew(p.source)}
style={{ width: '100%', textAlign: 'left', padding: '0.4rem 0.5rem', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.75rem', color: '#475569', borderRadius: '5px', display: 'flex', alignItems: 'center', gap: '0.375rem' }}
onMouseEnter={e => (e.currentTarget.style.background = '#f1f5f9')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{p.source.type === 'static' ? <FileJson size={11} /> : <Globe size={11} />}
{p.label}
</button>
))}
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: '0.5rem' }}>
{dataSources.length === 0 && (
<div style={{ padding: '1.5rem 0.5rem', textAlign: 'center', color: '#94a3b8', fontSize: '0.75rem' }}>
<Database size={24} style={{ opacity: 0.3, marginBottom: '0.5rem' }} />
<p style={{ margin: 0 }}>No data sources yet</p>
</div>
)}
{dataSources.map(source => {
const cache = dataCache[source.id]
const isEditing = editing?.id === source.id
return (
<div
key={source.id}
onClick={() => { setEditing({ ...source }); setTestResult(null); setShowPreview(false) }}
style={{
padding: '0.625rem 0.75rem', borderRadius: '8px', cursor: 'pointer',
border: isEditing ? '2px solid #6366f1' : '2px solid transparent',
background: isEditing ? '#f5f3ff' : 'transparent', marginBottom: '2px',
transition: 'all 0.1s',
}}
onMouseEnter={e => { if (!isEditing) (e.currentTarget as HTMLDivElement).style.background = '#f1f5f9' }}
onMouseLeave={e => { if (!isEditing) (e.currentTarget as HTMLDivElement).style.background = 'transparent' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', marginBottom: '2px' }}>
{source.type === 'static' ? <FileJson size={11} style={{ color: '#6366f1' }} /> : <Globe size={11} style={{ color: '#0ea5e9' }} />}
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: '#0f172a', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{source.name}</span>
{cache?.loading && <Loader2 size={10} style={{ color: '#6366f1', animation: 'spin 1s linear infinite' }} />}
{!cache?.loading && cache?.error && <XCircle size={10} style={{ color: '#ef4444' }} />}
{!cache?.loading && !cache?.error && cache?.data?.length > 0 && <CheckCircle2 size={10} style={{ color: '#22c55e' }} />}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ fontSize: '0.65rem', color: '#94a3b8' }}>
{source.type === 'static' ? 'Static' : `${source.method} · ${source.url.split('/').slice(0, 4).join('/')}`}
</span>
{cache?.data && <span style={{ fontSize: '0.65rem', color: '#64748b', fontWeight: 600 }}>{cache.data.length} items</span>}
</div>
</div>
)
})}
</div>
</div>
{/* Edit pane */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.875rem 1.25rem', borderBottom: '1px solid #f1f5f9', flexShrink: 0 }}>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#0f172a' }}>
{editing ? (editing.name || 'New data source') : 'Data Sources'}
</span>
<button onClick={onClose} style={{ width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: '#f1f5f9', borderRadius: '7px', cursor: 'pointer', color: '#64748b' }}>
<X size={14} />
</button>
</div>
{editing ? (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{/* Form */}
<div style={{ padding: '1rem 1.25rem', borderBottom: '1px solid #f1f5f9' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>Name</label>
<input style={inp} value={editing.name} onChange={e => setEditing(s => s ? { ...s, name: e.target.value } : s)} placeholder="My API" />
</div>
<div>
<label style={lbl}>Type</label>
<select style={inp} value={editing.type} onChange={e => setEditing(s => s ? { ...s, type: e.target.value as 'rest' | 'static' } : s)}>
<option value="rest">REST API</option>
<option value="static">Static JSON</option>
</select>
</div>
{editing.type === 'rest' && (
<div>
<label style={lbl}>Method</label>
<select style={inp} value={editing.method} onChange={e => setEditing(s => s ? { ...s, method: e.target.value as 'GET' | 'POST' } : s)}>
<option>GET</option>
<option>POST</option>
</select>
</div>
)}
{editing.type === 'rest' && (
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>URL</label>
<input style={inp} value={editing.url} onChange={e => setEditing(s => s ? { ...s, url: e.target.value } : s)} placeholder="https://api.example.com/items" />
</div>
)}
{editing.type === 'rest' && (
<div>
<label style={lbl}>Response path <span style={{ color: '#94a3b8' }}>(optional)</span></label>
<input style={inp} value={editing.path} onChange={e => setEditing(s => s ? { ...s, path: e.target.value } : s)} placeholder="data.results" />
</div>
)}
{editing.type === 'rest' && (
<div>
<label style={lbl}>Headers <span style={{ color: '#94a3b8' }}>(JSON)</span></label>
<input style={inp} value={editing.headers} onChange={e => setEditing(s => s ? { ...s, headers: e.target.value } : s)} placeholder='{"Authorization": "Bearer ..."}' />
</div>
)}
{editing.type === 'rest' && editing.method === 'POST' && (
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>Request body</label>
<textarea style={{ ...inp, height: 64, resize: 'none' as const }} value={editing.body} onChange={e => setEditing(s => s ? { ...s, body: e.target.value } : s)} placeholder='{"query": "..."}' />
</div>
)}
{editing.type === 'static' && (
<div style={{ gridColumn: 'span 2' }}>
<label style={lbl}>JSON data <span style={{ color: '#94a3b8' }}>(array)</span></label>
<textarea
style={{ ...inp, height: 160, resize: 'vertical' as const, fontFamily: 'monospace', fontSize: '0.75rem' }}
value={editing.staticData}
onChange={e => setEditing(s => s ? { ...s, staticData: e.target.value } : s)}
/>
</div>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.875rem', flexWrap: 'wrap' }}>
<button onClick={handleTest} disabled={testing} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#f1f5f9', border: '1px solid #e2e8f0', borderRadius: '7px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, color: '#475569' }}>
{testing ? <Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} /> : <Play size={12} />}
Test
</button>
<button onClick={handleSave} disabled={!editing.name.trim()} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600, opacity: editing.name.trim() ? 1 : 0.5 }}>
Save source
</button>
{dataSources.find(s => s.id === editing.id) && (
confirmDelete === editing.id ? (
<>
<span style={{ fontSize: '0.75rem', color: '#ef4444', display: 'flex', alignItems: 'center' }}>Delete?</span>
<button onClick={() => { onDelete(editing.id); setEditing(null); setConfirmDelete(null) }} style={{ padding: '0.4rem 0.625rem', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem', fontWeight: 600 }}>Yes</button>
<button onClick={() => setConfirmDelete(null)} style={{ padding: '0.4rem 0.625rem', background: '#f1f5f9', color: '#64748b', border: 'none', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem' }}>No</button>
</>
) : (
<button onClick={() => setConfirmDelete(editing.id)} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.4rem 0.625rem', background: 'transparent', border: '1px solid #fecaca', color: '#ef4444', borderRadius: '7px', cursor: 'pointer', fontSize: '0.75rem' }}>
<Trash2 size={11} />Delete
</button>
)
)}
<button onClick={() => { setEditing(null); setTestResult(null) }} style={{ marginLeft: 'auto', padding: '0.4rem 0.625rem', background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: '0.75rem' }}>Cancel</button>
</div>
</div>
{/* Test result */}
{testResult && (
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.625rem 1.25rem', borderBottom: '1px solid #f1f5f9', background: testResult.error ? '#fef2f2' : '#f0fdf4', flexShrink: 0 }}>
{testResult.error
? <><XCircle size={14} style={{ color: '#ef4444' }} /><span style={{ fontSize: '0.75rem', color: '#dc2626', fontWeight: 600 }}>Error: {testResult.error}</span></>
: <><CheckCircle2 size={14} style={{ color: '#22c55e' }} /><span style={{ fontSize: '0.75rem', color: '#15803d', fontWeight: 600 }}>{testResult.data.length} items returned</span>
{fields.length > 0 && <span style={{ fontSize: '0.7rem', color: '#64748b', marginLeft: '0.5rem' }}>Fields: {fields.join(', ')}</span>}
</>
}
{!testResult.error && (
<button onClick={() => setShowPreview(p => !p)} style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.25rem', padding: '0.2rem 0.5rem', background: 'transparent', border: '1px solid #e2e8f0', borderRadius: '5px', cursor: 'pointer', fontSize: '0.7rem', color: '#64748b' }}>
{showPreview ? <EyeOff size={10} /> : <Eye size={10} />}{showPreview ? 'Hide' : 'Preview'}
</button>
)}
</div>
{showPreview && !testResult.error && testResult.data.length > 0 && (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.72rem' }}>
<thead>
<tr style={{ background: '#f8fafc' }}>
{fields.slice(0, 8).map(f => (
<th key={f} style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 700, color: '#475569', borderBottom: '1px solid #f1f5f9', whiteSpace: 'nowrap' }}>{f}</th>
))}
</tr>
</thead>
<tbody>
{testResult.data.slice(0, 8).map((row, i) => (
<tr key={i} style={{ borderBottom: '1px solid #f8fafc' }}>
{fields.slice(0, 8).map(f => (
<td key={f} style={{ padding: '0.4rem 0.75rem', color: '#334155', maxWidth: 180, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{String(row[f] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
{testResult.data.length > 8 && <p style={{ padding: '0.5rem 0.75rem', margin: 0, fontSize: '0.7rem', color: '#94a3b8' }}>… and {testResult.data.length - 8} more rows</p>}
</div>
)}
</div>
)}
</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', gap: '0.5rem' }}>
<Database size={36} style={{ opacity: 0.25 }} />
<p style={{ margin: 0, fontSize: '0.875rem', fontWeight: 500 }}>Select a source to edit</p>
<p style={{ margin: 0, fontSize: '0.8rem' }}>or <button onClick={() => startNew()} style={{ background: 'none', border: 'none', color: '#6366f1', cursor: 'pointer', fontWeight: 600, padding: 0, fontSize: '0.8rem' }}>create a new one</button></p>
</div>
)}
{/* Live cache overview when no editing */}
{!editing && dataSources.length > 0 && (
<div style={{ padding: '0.75rem 1.25rem', borderTop: '1px solid #f1f5f9', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{dataSources.map(s => {
const cache = dataCache[s.id]
return (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.25rem 0.625rem', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '6px', fontSize: '0.72rem', color: '#475569' }}>
<span style={{ fontWeight: 600 }}>{s.name}</span>
{cache?.loading && <Loader2 size={9} style={{ color: '#6366f1' }} />}
{!cache?.loading && cache?.error && <span style={{ color: '#ef4444' }}>error</span>}
{!cache?.loading && !cache?.error && <span style={{ color: '#22c55e' }}>{cache?.data?.length ?? 0} items</span>}
<button onClick={() => onRefresh(s.id)} title="Refresh" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: '#94a3b8' }}>
<RefreshCw size={9} />
</button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
const lbl: React.CSSProperties = { display: 'block', fontSize: '0.72rem', fontWeight: 600, color: '#475569', marginBottom: '0.25rem' }
const inp: React.CSSProperties = { width: '100%', fontSize: '0.78rem', padding: '0.4rem 0.625rem', border: '1px solid #e2e8f0', borderRadius: '6px', outline: 'none', background: '#fff', color: '#0f172a', boxSizing: 'border-box', fontFamily: 'inherit' }

View File

@ -0,0 +1,450 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { X, Zap, Wrench, Webhook as WebhookIcon, ChevronRight, Play, Copy, Check, Plus, Trash2, ToggleLeft, ToggleRight, Circle, AlertCircle, CheckCircle2 } from 'lucide-react'
import { EventBus, MCP_EVENTS, type MCPEventEntry, type MCPEventName } from '../mcp/EventBus'
import { TOOLS, callTool } from '../mcp/tools'
import { loadWebhooks, saveWebhooks, createWebhook, type Webhook } from '../mcp/WebhookStore'
// ── Event Log ─────────────────────────────────────────────────
const EVENT_COLORS: Record<string, string> = {
cms: '#6366f1',
scorm: '#8b5cf6',
admin: '#10b981',
}
function categoryOf(event: string) {
return event.startsWith('cms:') ? 'cms' : event.startsWith('scorm:') ? 'scorm' : 'admin'
}
function EventLogTab() {
const [entries, setEntries] = useState<MCPEventEntry[]>(() => EventBus.log)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [filter, setFilter] = useState<string>('all')
const [copied, setCopied] = useState<string | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const unsub = EventBus.onLog(entry => {
setEntries(prev => [entry, ...prev].slice(0, 200))
})
return unsub
}, [])
function toggleExpand(id: string) {
setExpanded(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
}
async function copyEntry(entry: MCPEventEntry) {
const text = JSON.stringify({ event: entry.event, payload: entry.payload }, null, 2)
await navigator.clipboard.writeText(text)
setCopied(entry.id); setTimeout(() => setCopied(null), 1500)
}
const FILTER_OPTS = ['all', 'cms', 'scorm', 'admin']
const filtered = filter === 'all' ? entries : entries.filter(e => categoryOf(e.event) === filter)
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 p-3 border-b border-zinc-800 flex-shrink-0">
<div className="flex gap-1">
{FILTER_OPTS.map(f => (
<button key={f} onClick={() => setFilter(f)}
className={`px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wide transition-colors ${filter === f ? 'bg-indigo-500 text-white' : 'text-zinc-500 hover:text-zinc-300'}`}>
{f}
</button>
))}
</div>
<span className="ml-auto text-xs text-zinc-600">{filtered.length} events</span>
<button onClick={() => setEntries([])} className="text-xs text-zinc-600 hover:text-red-400 transition-colors">clear</button>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center h-32 text-zinc-700">
<Zap size={20} className="mb-2 opacity-40" />
<p className="text-xs">No events yet</p>
<p className="text-xs mt-0.5 opacity-60">Events fire as you use the builder</p>
</div>
)}
{filtered.map(entry => {
const cat = categoryOf(entry.event)
const color = EVENT_COLORS[cat] ?? '#6366f1'
const isOpen = expanded.has(entry.id)
const time = new Date(entry.timestamp)
return (
<div key={entry.id} className="border-b border-zinc-800/60">
<div className="flex items-center gap-2 px-3 py-2 hover:bg-zinc-800/40 cursor-pointer select-none"
onClick={() => toggleExpand(entry.id)}>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: color }} />
<span className="text-xs font-mono text-zinc-300 flex-1 truncate">{entry.event}</span>
<span className="text-[9px] text-zinc-600 flex-shrink-0 font-mono">
{time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<button onClick={e => { e.stopPropagation(); copyEntry(entry) }}
className="text-zinc-600 hover:text-zinc-300 flex-shrink-0 transition-colors">
{copied === entry.id ? <Check size={10} className="text-green-400" /> : <Copy size={10} />}
</button>
<ChevronRight size={10} className={`text-zinc-600 flex-shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</div>
{isOpen && (
<div className="px-3 pb-3">
<pre className="text-xs font-mono text-zinc-400 bg-zinc-900 rounded p-2 overflow-x-auto whitespace-pre-wrap break-all leading-relaxed">
{JSON.stringify(entry.payload, null, 2)}
</pre>
</div>
)}
</div>
)
})}
<div ref={bottomRef} />
</div>
</div>
)
}
// ── Tools Tab ─────────────────────────────────────────────────
function ToolsTab() {
const [selected, setSelected] = useState<string | null>(null)
const [args, setArgs] = useState<Record<string, string>>({})
const [result, setResult] = useState<unknown>(null)
const [error, setError] = useState<string | null>(null)
const [running, setRunning] = useState(false)
const [catFilter, setCatFilter] = useState<'all' | 'cms' | 'scorm' | 'admin'>('all')
const tool = TOOLS.find(t => t.name === selected)
function selectTool(name: string) {
setSelected(name)
const t = TOOLS.find(t => t.name === name)
setArgs(Object.fromEntries(t?.params.map(p => [p.name, '']) ?? []))
setResult(null); setError(null)
}
async function run() {
if (!tool) return
setRunning(true); setResult(null); setError(null)
try {
const parsedArgs: Record<string, unknown> = {}
for (const p of tool.params) {
const raw = args[p.name]
if (!raw && !p.required) continue
if (p.type === 'number') parsedArgs[p.name] = Number(raw)
else if (p.type === 'boolean') parsedArgs[p.name] = raw === 'true'
else if (p.type === 'object' || p.type === 'array') parsedArgs[p.name] = JSON.parse(raw)
else parsedArgs[p.name] = raw
}
const res = await callTool(tool.name, parsedArgs)
setResult(res)
} catch (e) {
setError(String(e))
} finally {
setRunning(false)
}
}
const CAT_COLORS: Record<string, string> = { cms: 'text-indigo-400', scorm: 'text-purple-400', admin: 'text-green-400' }
const cats = ['all', 'cms', 'scorm', 'admin'] as const
const filteredTools = catFilter === 'all' ? TOOLS : TOOLS.filter(t => t.category === catFilter)
return (
<div className="flex h-full overflow-hidden">
{/* Tool list */}
<div className="w-48 flex-shrink-0 border-r border-zinc-800 overflow-y-auto">
<div className="flex gap-0.5 p-2 flex-wrap">
{cats.map(c => (
<button key={c} onClick={() => setCatFilter(c)}
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wide transition-colors ${catFilter === c ? 'bg-zinc-700 text-white' : 'text-zinc-600 hover:text-zinc-400'}`}>
{c}
</button>
))}
</div>
{filteredTools.map(t => (
<button key={t.name} onClick={() => selectTool(t.name)}
className={`w-full text-left px-3 py-2 text-xs font-mono transition-colors border-b border-zinc-800/60 ${selected === t.name ? 'bg-indigo-900/40 text-indigo-300' : 'text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/30'}`}>
<span className={`mr-1 text-[9px] ${CAT_COLORS[t.category]}`}>●</span>
{t.name}
</button>
))}
</div>
{/* Tool detail */}
<div className="flex-1 overflow-y-auto p-3">
{!tool ? (
<div className="flex flex-col items-center justify-center h-32 text-zinc-700">
<Wrench size={20} className="mb-2 opacity-40" />
<p className="text-xs">Select a tool to run it</p>
</div>
) : (
<div className="space-y-3">
<div>
<div className="text-xs font-mono text-indigo-300 mb-1">{tool.name}</div>
<p className="text-xs text-zinc-500 leading-relaxed">{tool.description}</p>
</div>
{tool.params.length > 0 && (
<div className="space-y-2">
{tool.params.map(p => (
<div key={p.name}>
<label className="text-xs text-zinc-500 block mb-1">
<span className="font-mono text-zinc-400">{p.name}</span>
{p.required && <span className="text-red-400 ml-1">*</span>}
<span className="ml-2 text-zinc-700">({p.type})</span>
{' '}{p.description}
</label>
<input value={args[p.name] ?? ''} onChange={e => setArgs(prev => ({ ...prev, [p.name]: e.target.value }))}
placeholder={p.type === 'object' ? '{"key":"value"}' : p.type === 'array' ? '[...]' : p.description}
className="w-full text-xs font-mono px-2 py-1.5 bg-zinc-900 border border-zinc-700 rounded text-zinc-300 outline-none focus:border-indigo-500 placeholder-zinc-700" />
</div>
))}
</div>
)}
<button onClick={run} disabled={running}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg transition-colors">
<Play size={11} />{running ? 'Running…' : 'Run'}
</button>
{error && (
<div className="bg-red-900/30 border border-red-800 rounded p-2 text-xs font-mono text-red-300">{error}</div>
)}
{result !== null && (
<div>
<div className="text-xs text-zinc-600 mb-1">Result</div>
<pre className="text-xs font-mono text-zinc-300 bg-zinc-900 rounded p-2 overflow-x-auto whitespace-pre-wrap break-all">
{JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
</div>
)
}
// ── Webhooks Tab ───────────────────────────────────────────────
const ALL_EVENTS: MCPEventName[] = [...MCP_EVENTS]
function WebhooksTab() {
const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [showAdd, setShowAdd] = useState(false)
const [newName, setNewName] = useState('')
const [newUrl, setNewUrl] = useState('')
const [newEvents, setNewEvents] = useState<Set<MCPEventName>>(new Set())
const [evFilter, setEvFilter] = useState('all')
const [copied, setCopied] = useState<string | null>(null)
useEffect(() => {
let mounted = true
const bootstrap = async () => {
try {
const loaded = await loadWebhooks()
if (mounted) setWebhooks(loaded)
} catch (error) {
console.error('failed to load webhooks', error)
}
}
void bootstrap()
return () => { mounted = false }
}, [])
function save(next: Webhook[]) {
setWebhooks(next)
void saveWebhooks(next)
}
function addWebhook() {
if (!newName.trim() || !newUrl.trim() || newEvents.size === 0) return
const wh = createWebhook(newName, newUrl, [...newEvents])
save([...webhooks, wh])
setShowAdd(false); setNewName(''); setNewUrl(''); setNewEvents(new Set())
}
function toggleEnabled(id: string) {
save(webhooks.map(w => w.id === id ? { ...w, enabled: !w.enabled } : w))
}
function deleteWebhook(id: string) { save(webhooks.filter(w => w.id !== id)) }
async function copySecret(secret: string) {
await navigator.clipboard.writeText(secret)
setCopied(secret); setTimeout(() => setCopied(null), 1500)
}
const eventsFiltered = evFilter === 'all' ? ALL_EVENTS : ALL_EVENTS.filter(e => e.startsWith(evFilter + ':'))
return (
<div className="flex-1 overflow-y-auto p-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-500">Outgoing HTTP POST webhooks fired on events.</p>
<button onClick={() => setShowAdd(p => !p)}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors">
<Plus size={10} />Add
</button>
</div>
{showAdd && (
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-3 space-y-3">
<div>
<label className="text-xs text-zinc-500 block mb-1">Name</label>
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="My webhook"
className="w-full text-xs font-mono px-2 py-1.5 bg-zinc-800 border border-zinc-700 rounded text-zinc-300 outline-none focus:border-indigo-500" />
</div>
<div>
<label className="text-xs text-zinc-500 block mb-1">URL</label>
<input value={newUrl} onChange={e => setNewUrl(e.target.value)} placeholder="https://example.com/webhook"
className="w-full text-xs font-mono px-2 py-1.5 bg-zinc-800 border border-zinc-700 rounded text-zinc-300 outline-none focus:border-indigo-500" />
</div>
<div>
<label className="text-xs text-zinc-500 block mb-1">Events</label>
<div className="flex gap-1 mb-2 flex-wrap">
{['all','cms','scorm','admin'].map(f => (
<button key={f} onClick={() => setEvFilter(f)}
className={`px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wide transition-colors ${evFilter === f ? 'bg-zinc-600 text-white' : 'text-zinc-600 hover:text-zinc-400'}`}>{f}</button>
))}
</div>
<div className="max-h-32 overflow-y-auto space-y-0.5">
{eventsFiltered.map(e => (
<label key={e} className="flex items-center gap-2 px-1.5 py-0.5 rounded hover:bg-zinc-800 cursor-pointer">
<input type="checkbox" checked={newEvents.has(e)}
onChange={ev => { const n = new Set(newEvents); ev.target.checked ? n.add(e) : n.delete(e); setNewEvents(n) }}
className="accent-indigo-500 w-3 h-3" />
<span className="text-[9px] font-mono text-zinc-400">{e}</span>
</label>
))}
</div>
<button onClick={() => setNewEvents(new Set(ALL_EVENTS))}
className="mt-1 text-[9px] text-indigo-400 hover:text-indigo-300 transition-colors">select all</button>
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowAdd(false)} className="px-2 py-1 text-xs text-zinc-500 hover:text-zinc-300 border border-zinc-700 rounded-lg">Cancel</button>
<button onClick={addWebhook} disabled={!newName.trim() || !newUrl.trim() || newEvents.size === 0}
className="px-2 py-1 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-lg">Save</button>
</div>
</div>
)}
{webhooks.length === 0 && !showAdd && (
<div className="flex flex-col items-center justify-center h-24 text-zinc-700">
<WebhookIcon size={20} className="mb-2 opacity-40" />
<p className="text-xs">No webhooks configured</p>
</div>
)}
{webhooks.map(w => (
<div key={w.id} className={`bg-zinc-900 border rounded-xl p-3 ${w.enabled ? 'border-zinc-700' : 'border-zinc-800 opacity-60'}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-zinc-300">{w.name}</span>
{w.lastStatus !== null && (
<span className={`text-[9px] font-mono px-1 rounded ${w.lastStatus >= 200 && w.lastStatus < 300 ? 'bg-green-900/40 text-green-400' : 'bg-red-900/40 text-red-400'}`}>
{w.lastStatus || 'ERR'}
</span>
)}
</div>
<div className="flex items-center gap-2">
<button onClick={() => toggleEnabled(w.id)} className="text-zinc-500 hover:text-zinc-300 transition-colors">
{w.enabled ? <ToggleRight size={14} className="text-indigo-400" /> : <ToggleLeft size={14} />}
</button>
<button onClick={() => deleteWebhook(w.id)} className="text-zinc-600 hover:text-red-400 transition-colors"><Trash2 size={11} /></button>
</div>
</div>
<p className="text-[9px] font-mono text-zinc-500 truncate mb-1.5">{w.url}</p>
<div className="flex items-center gap-1 mb-1.5">
<span className="text-[9px] text-zinc-600 font-mono">secret:</span>
<span className="text-[9px] font-mono text-zinc-600 flex-1 truncate">{w.secret.slice(0,12)}…</span>
<button onClick={() => copySecret(w.secret)} className="text-zinc-600 hover:text-zinc-400 transition-colors">
{copied === w.secret ? <Check size={10} className="text-green-400" /> : <Copy size={10} />}
</button>
</div>
<div className="flex flex-wrap gap-1">
{w.events.slice(0, 4).map(e => (
<span key={e} className="text-[8px] font-mono bg-zinc-800 text-zinc-500 px-1 rounded">{e}</span>
))}
{w.events.length > 4 && <span className="text-[8px] text-zinc-600">+{w.events.length - 4} more</span>}
</div>
</div>
))}
</div>
)
}
// ── Main Panel ─────────────────────────────────────────────────
type McpTab = 'events' | 'tools' | 'webhooks'
export function McpPanel({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<McpTab>('events')
const [liveCount, setLiveCount] = useState(0)
useEffect(() => {
setLiveCount(EventBus.log.length)
const unsub = EventBus.onLog(() => setLiveCount(c => c + 1))
return unsub
}, [])
const TABS: { id: McpTab; label: string; icon: React.ReactNode }[] = [
{ id: 'events', label: 'Events', icon: <Zap size={11} /> },
{ id: 'tools', label: 'Tools', icon: <Wrench size={11} /> },
{ id: 'webhooks', label: 'Webhooks', icon: <WebhookIcon size={11} /> },
]
return (
<div
className="fixed top-0 right-0 bottom-0 z-50 flex flex-col shadow-2xl"
style={{ width: 400, background: '#18181b', borderLeft: '1px solid rgba(255,255,255,0.08)' }}>
{/* Header */}
<div style={{ background: '#09090b', borderBottom: '1px solid rgba(255,255,255,0.06)' }}
className="flex items-center justify-between px-4 py-3 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center">
<Zap size={10} color="#fff" />
</div>
<span className="text-xs font-semibold text-zinc-100">MCP</span>
<span className="text-[9px] text-zinc-600 font-mono">v1.0</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" />
<span className="text-xs text-zinc-500">{liveCount} fired</span>
</div>
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors"><X size={14} /></button>
</div>
</div>
{/* Tabs */}
<div className="flex flex-shrink-0" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)', background: '#09090b' }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium transition-colors flex-1 justify-center ${tab === t.id ? 'text-indigo-300 border-b border-indigo-500' : 'text-zinc-600 hover:text-zinc-400'}`}>
{t.icon}{t.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{tab === 'events' && <EventLogTab />}
{tab === 'tools' && <ToolsTab />}
{tab === 'webhooks' && <WebhooksTab />}
</div>
{/* Footer */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: '#09090b' }}
className="px-4 py-2 flex-shrink-0">
<p className="text-[9px] text-zinc-700 font-mono">window.__CMS_MCP__ · {TOOLS.length} tools · DOM: cms:mcp:event</p>
</div>
</div>
)
}
// ── Trigger button ─────────────────────────────────────────────
export function McpTriggerButton({ onClick }: { onClick: () => void }) {
const [pulse, setPulse] = useState(false)
useEffect(() => {
const unsub = EventBus.onLog(() => { setPulse(true); setTimeout(() => setPulse(false), 600) })
return unsub
}, [])
return (
<button onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border rounded-md transition-all ${pulse ? 'border-indigo-400 text-indigo-500 bg-indigo-50' : 'border-zinc-200 text-zinc-500 hover:text-zinc-700 hover:border-zinc-300'}`}>
<Zap size={12} className={pulse ? 'text-indigo-500' : ''} />MCP
</button>
)
}

View File

@ -0,0 +1,178 @@
import { useState } from 'react'
import { Globe, Palette } from 'lucide-react'
import type { PageSEO, ThemeConfig } from '../types'
import { ThemeBuilder } from './ThemeBuilder'
type PanelTab = 'theme' | 'seo'
function TextInput({
label,
value,
onChange,
placeholder,
type = 'text',
testId,
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
type?: 'text' | 'url'
testId?: string
}) {
return (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-zinc-600">{label}</span>
<input
data-testid={testId}
type={type}
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
className="w-full rounded-md border border-zinc-200 bg-white px-2.5 py-1.5 text-xs outline-none transition-colors focus:border-indigo-400"
/>
</label>
)
}
function TextArea({
label,
value,
onChange,
placeholder,
testId,
}: {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
testId?: string
}) {
return (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-zinc-600">{label}</span>
<textarea
data-testid={testId}
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
className="min-h-20 w-full resize-none rounded-md border border-zinc-200 bg-white px-2.5 py-1.5 text-xs outline-none transition-colors focus:border-indigo-400"
/>
</label>
)
}
function Toggle({
label,
checked,
onChange,
hint,
testId,
}: {
label: string
checked: boolean
onChange: (checked: boolean) => void
hint: string
testId?: string
}) {
return (
<label className="flex items-start justify-between gap-3 rounded-md border border-zinc-200 px-3 py-2.5">
<span>
<span className="block text-xs font-medium text-zinc-700">{label}</span>
<span className="mt-0.5 block text-[11px] text-zinc-500">{hint}</span>
</span>
<input data-testid={testId} type="checkbox" checked={checked} onChange={event => onChange(event.target.checked)} className="mt-0.5 h-4 w-4 rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500" />
</label>
)
}
export function PageSettingsPanel({
pageName,
slug,
seo,
theme,
onSlugChange,
onSEOChange,
onThemeChange,
}: {
pageName: string
slug: string
seo: PageSEO
theme: ThemeConfig
onSlugChange: (slug: string) => void
onSEOChange: (seo: PageSEO) => void
onThemeChange: (theme: ThemeConfig) => void
}) {
const [tab, setTab] = useState<PanelTab>('theme')
const setSEO = (patch: Partial<PageSEO>) => onSEOChange({ ...seo, ...patch })
const setOpenGraph = (patch: NonNullable<PageSEO['openGraph']>) => onSEOChange({ ...seo, openGraph: patch })
const setRobots = (patch: NonNullable<PageSEO['robots']>) => onSEOChange({ ...seo, robots: patch })
return (
<div data-testid="cms-page-settings-panel" className="flex h-full flex-col">
<div className="border-b border-zinc-100 px-4 pt-4 pb-3">
<h2 className="mb-0.5 text-xs font-semibold uppercase tracking-widest text-zinc-400">Page Settings</h2>
<p className="truncate text-sm font-semibold text-zinc-800">{pageName}</p>
</div>
<div className="flex gap-0.5 border-b border-zinc-100 px-3 pt-2">
{[
{ id: 'theme' as PanelTab, label: 'Theme', icon: Palette },
{ id: 'seo' as PanelTab, label: 'SEO', icon: Globe },
].map(({ id, label, icon: Icon }) => (
<button
data-testid={`cms-settings-tab-${id}`}
key={id}
onClick={() => setTab(id)}
className={`-mb-px flex items-center gap-1.5 rounded-t-md border-b-2 px-3 py-1.5 text-xs font-medium transition-all ${
tab === id ? 'border-indigo-500 bg-indigo-50/50 text-indigo-600' : 'border-transparent text-zinc-500 hover:text-zinc-700'
}`}
>
<Icon size={11} />
{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto">
{tab === 'theme' ? (
<ThemeBuilder theme={theme} onChange={onThemeChange} />
) : (
<div className="space-y-4 p-4">
<div className="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-2 text-[11px] text-indigo-700">
Leave the slug empty to let the backend generate it from the page name on save.
</div>
<TextInput testId="cms-seo-slug" label="Slug" value={slug} onChange={onSlugChange} placeholder="landing-page" />
<TextInput testId="cms-seo-title" label="SEO Title" value={seo.title ?? ''} onChange={value => setSEO({ title: value })} placeholder="Marketing landing page" />
<TextArea testId="cms-seo-description" label="SEO Description" value={seo.description ?? ''} onChange={value => setSEO({ description: value })} placeholder="Short description for search and social cards" />
<TextInput testId="cms-seo-canonical-url" label="Canonical URL" type="url" value={seo.canonicalUrl ?? ''} onChange={value => setSEO({ canonicalUrl: value })} placeholder="https://example.com/landing-page" />
<div className="border-t border-zinc-100 pt-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-widest text-zinc-500">Open Graph</h3>
<div className="space-y-3">
<TextInput testId="cms-seo-og-title" label="OG Title" value={seo.openGraph?.title ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), title: value })} placeholder="Social preview title" />
<TextArea testId="cms-seo-og-description" label="OG Description" value={seo.openGraph?.description ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), description: value })} placeholder="Social preview description" />
<TextInput testId="cms-seo-og-image" label="OG Image URL" type="url" value={seo.openGraph?.image ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), image: value })} placeholder="https://example.com/og-image.png" />
<TextInput testId="cms-seo-og-url" label="OG URL" type="url" value={seo.openGraph?.url ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), url: value })} placeholder="https://example.com/landing-page" />
<TextInput testId="cms-seo-og-type" label="OG Type" value={seo.openGraph?.type ?? ''} onChange={value => setOpenGraph({ ...(seo.openGraph ?? {}), type: value })} placeholder="website" />
</div>
</div>
<div className="border-t border-zinc-100 pt-4">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-widest text-zinc-500">Robots</h3>
<div className="space-y-2">
<Toggle testId="cms-seo-robots-index" label="Index" checked={seo.robots?.index ?? true} onChange={value => setRobots({ ...(seo.robots ?? {}), index: value })} hint="Allow search engines to index this page." />
<Toggle testId="cms-seo-robots-follow" label="Follow" checked={seo.robots?.follow ?? true} onChange={value => setRobots({ ...(seo.robots ?? {}), follow: value })} hint="Allow crawlers to follow links on this page." />
<Toggle testId="cms-seo-robots-noarchive" label="No Archive" checked={seo.robots?.noarchive ?? false} onChange={value => setRobots({ ...(seo.robots ?? {}), noarchive: value })} hint="Prevent cached copies in search results." />
<Toggle testId="cms-seo-robots-nosnippet" label="No Snippet" checked={seo.robots?.nosnippet ?? false} onChange={value => setRobots({ ...(seo.robots ?? {}), nosnippet: value })} hint="Prevent text snippets in search results." />
</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,190 @@
import { useState } from 'react'
import { X, Plus, Trash2, FileText, Clock, Layers, ChevronRight } from 'lucide-react'
import type { Page } from '../App'
function formatDate(ts: number) {
const d = new Date(ts)
const now = Date.now()
const diff = now - ts
if (diff < 60_000) return 'Just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined })
}
function countBlocksDeep(blocks: Page['blocks']): number {
let n = 0
for (const b of blocks) {
n++
if (b.slots) for (const slot of b.slots) n += countBlocksDeep(slot)
}
return n
}
export function PagesModal({
pages,
currentPageId,
onLoad,
onNew,
onDelete,
onClose,
}: {
pages: Page[]
currentPageId: string | null
onLoad: (page: Page) => void
onNew: () => void
onDelete: (id: string) => Promise<void>
onClose: () => void
}) {
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
return (
<div
data-testid="cms-pages-modal"
role="dialog"
aria-modal="true"
aria-labelledby="cms-pages-title"
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)' }}
onClick={onClose}
>
<div
style={{ background: '#fff', borderRadius: '16px', width: '640px', maxWidth: '95vw', maxHeight: '80vh', display: 'flex', flexDirection: 'column', boxShadow: '0 24px 64px rgba(0,0,0,0.2)', overflow: 'hidden' }}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1.25rem 1.5rem', borderBottom: '1px solid #f1f5f9' }}>
<div>
<h2 id="cms-pages-title" style={{ margin: 0, fontSize: '1rem', fontWeight: 700, color: '#0f172a' }}>Pages</h2>
<p style={{ margin: '2px 0 0', fontSize: '0.75rem', color: '#94a3b8' }}>{pages.length} saved page{pages.length !== 1 ? 's' : ''}</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
data-testid="cms-pages-create"
onClick={onNew}
style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
>
<Plus size={13} />New page
</button>
<button
onClick={onClose}
aria-label="Close pages"
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: '#f1f5f9', borderRadius: '8px', cursor: 'pointer', color: '#64748b' }}
>
<X size={15} />
</button>
</div>
</div>
{/* Page list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem' }}>
{pages.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', color: '#94a3b8' }}>
<FileText size={36} style={{ opacity: 0.3, marginBottom: '0.75rem' }} />
<p style={{ margin: 0, fontSize: '0.9rem', fontWeight: 500 }}>No saved pages yet</p>
<p style={{ margin: '0.25rem 0 1rem', fontSize: '0.8rem' }}>Save your current canvas to get started</p>
<button
data-testid="cms-pages-create-first"
onClick={onNew}
style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.5rem 1rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
>
<Plus size={13} />Create first page
</button>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{pages.map(page => {
const isCurrent = page.id === currentPageId
const blockCount = countBlocksDeep(page.blocks)
const isDeleting = confirmDelete === page.id
return (
<div
data-testid={`cms-page-row-${page.id}`}
key={page.id}
style={{
display: 'flex', alignItems: 'center', gap: '0.875rem',
padding: '0.875rem 1rem', borderRadius: '10px',
border: isCurrent ? '2px solid #6366f1' : '2px solid #f1f5f9',
background: isCurrent ? '#fafafe' : '#fafafa',
cursor: 'pointer', transition: 'all 0.12s',
}}
onClick={() => { if (!isDeleting) { onLoad(page); onClose() } }}
>
{/* Theme swatch */}
<div style={{ flexShrink: 0, width: 44, height: 44, borderRadius: '8px', border: '1px solid #e2e8f0', overflow: 'hidden', display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr' }}>
{[page.theme.background, page.theme.primary, page.theme.muted, page.theme.accent].map((c, i) => (
<div key={i} style={{ background: /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : '#e2e8f0' }} />
))}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{page.name}
</span>
{isCurrent && (
<span style={{ fontSize: '0.65rem', fontWeight: 700, background: '#6366f1', color: '#fff', padding: '1px 6px', borderRadius: '4px', letterSpacing: '0.04em', flexShrink: 0 }}>
OPEN
</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.2rem' }}>
<span style={{ fontSize: '0.72rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Layers size={10} />{blockCount} block{blockCount !== 1 ? 's' : ''}
</span>
<span style={{ fontSize: '0.72rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<Clock size={10} />Updated {formatDate(page.updatedAt)}
</span>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.375rem', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{isDeleting ? (
<>
<span style={{ fontSize: '0.72rem', color: '#ef4444', fontWeight: 600, display: 'flex', alignItems: 'center' }}>Delete?</span>
<button
data-testid={`cms-page-delete-confirm-${page.id}`}
onClick={() => { void onDelete(page.id).then(() => setConfirmDelete(null)).catch(() => undefined) }}
style={{ padding: '0.25rem 0.625rem', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600 }}
>
Yes
</button>
<button
onClick={() => setConfirmDelete(null)}
style={{ padding: '0.25rem 0.625rem', background: '#f1f5f9', color: '#64748b', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem' }}
>
No
</button>
</>
) : (
<>
<button
data-testid={`cms-page-delete-${page.id}`}
aria-label={`Delete ${page.name}`}
onClick={() => setConfirmDelete(page.id)}
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', color: '#94a3b8' }}
>
<Trash2 size={12} />
</button>
<button
data-testid={`cms-page-open-${page.id}`}
aria-label={`Open ${page.name}`}
onClick={() => { onLoad(page); onClose() }}
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9', border: 'none', borderRadius: '6px', cursor: 'pointer', color: '#6366f1' }}
>
<ChevronRight size={14} />
</button>
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,195 @@
import { useEffect, useRef, useState } from 'react'
import { X, Monitor, Tablet, Smartphone } from 'lucide-react'
import type { ThemeConfig, CanvasBlock, CustomBlockDef, DataCacheEntry } from '../App'
import { PageRenderer } from '../page-renderer'
const VIEWPORTS = [
{ id: 'desktop', label: 'Desktop', icon: Monitor, width: '100%' },
{ id: 'tablet', label: 'Tablet', icon: Tablet, width: '768px' },
{ id: 'mobile', label: 'Mobile', icon: Smartphone, width: '390px' },
] as const
type ViewportId = (typeof VIEWPORTS)[number]['id']
export function PreviewModal({
blocks,
customBlocks,
theme,
dataCache,
onClose,
}: {
blocks: CanvasBlock[]
customBlocks: CustomBlockDef[]
theme: ThemeConfig
dataCache: Record<string, DataCacheEntry>
onClose: () => void
}) {
const [viewport, setViewport] = useState<ViewportId>('desktop')
const dialogRef = useRef<HTMLDivElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
if (e.key !== 'Tab' || !dialogRef.current) return
const focusable = [...dialogRef.current.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
.filter(element => !element.hasAttribute('disabled'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', handler)
return () => {
window.removeEventListener('keydown', handler)
previouslyFocused?.focus()
}
}, [onClose])
const activeViewport = VIEWPORTS.find((v) => v.id === viewport)!
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="cms-preview-title"
style={{
position: 'fixed',
inset: 0,
zIndex: 50,
display: 'flex',
flexDirection: 'column',
background: '#0f172a',
overflow: 'hidden',
}}
>
{/* Preview toolbar */}
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-2 px-2 sm:px-5" style={{
alignItems: 'center',
height: '48px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span id="cms-preview-title" style={{ fontSize: '0.8rem', fontWeight: 600, color: 'rgba(255,255,255,0.6)', letterSpacing: '0.05em', textTransform: 'uppercase' }}>
Preview
</span>
<span className="hidden sm:inline-block" style={{ width: 1, height: 16, background: 'rgba(255,255,255,0.12)', margin: '0 0.25rem' }} />
<span className="hidden sm:inline" style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.35)' }}>{blocks.length} block{blocks.length !== 1 ? 's' : ''}</span>
</div>
{/* Viewport switcher */}
<div style={{ display: 'flex', gap: '2px', background: 'rgba(255,255,255,0.06)', borderRadius: '8px', padding: '3px' }}>
{VIEWPORTS.map((v) => {
const Icon = v.icon
const active = viewport === v.id
return (
<button
key={v.id}
onClick={() => setViewport(v.id)}
title={v.label}
aria-label={`${v.label} preview`}
aria-pressed={active}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.3rem 0.45rem',
borderRadius: '6px',
border: 'none',
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: 500,
transition: 'all 0.15s',
background: active ? 'rgba(255,255,255,0.12)' : 'transparent',
color: active ? '#fff' : 'rgba(255,255,255,0.4)',
}}
>
<Icon size={13} />
<span className="hidden sm:inline">{v.label}</span>
</button>
)
})}
</div>
<button
ref={closeRef}
onClick={onClose}
aria-label="Close preview"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.3rem 0.75rem',
borderRadius: '6px',
border: '1px solid rgba(255,255,255,0.12)',
background: 'transparent',
color: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: 500,
}}
>
<X size={13} />
<span className="hidden sm:inline">Close</span>
</button>
</div>
{/* Preview area */}
<div style={{
flex: 1,
overflow: 'auto',
display: 'flex',
justifyContent: 'center',
padding: viewport === 'desktop' ? '0' : '2rem 1rem',
background: viewport === 'desktop' ? 'transparent' : '#0f172a',
}}>
<div
style={{
width: activeViewport.width,
maxWidth: '100%',
background: theme.background,
boxShadow: viewport !== 'desktop' ? '0 8px 48px rgba(0,0,0,0.5)' : 'none',
borderRadius: viewport !== 'desktop' ? `${theme.radius * 2}rem` : 0,
overflow: 'hidden',
minHeight: '100%',
transition: 'width 0.25s ease',
}}
>
<PageRenderer blocks={blocks} customBlocks={customBlocks} theme={theme} dataCache={dataCache} />
</div>
</div>
{/* Viewport width badge */}
{viewport !== 'desktop' && (
<div style={{
position: 'absolute',
bottom: '1.25rem',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(255,255,255,0.08)',
color: 'rgba(255,255,255,0.5)',
fontSize: '0.7rem',
padding: '4px 10px',
borderRadius: '9999px',
fontFamily: 'monospace',
pointerEvents: 'none',
}}>
{activeViewport.width}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,272 @@
import { useState } from 'react'
import { Download, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react'
import type { ThemeConfig } from '../App'
import { PRESET_THEMES } from '../App'
type ColorField = {
key: keyof ThemeConfig
label: string
}
const COLOR_FIELDS: ColorField[] = [
{ key: 'background', label: 'Background' },
{ key: 'foreground', label: 'Foreground' },
{ key: 'primary', label: 'Primary' },
{ key: 'primaryForeground', label: 'Primary Foreground' },
{ key: 'muted', label: 'Muted' },
{ key: 'mutedForeground', label: 'Muted Foreground' },
{ key: 'border', label: 'Border' },
{ key: 'accent', label: 'Accent' },
]
const FONTS = [
{ label: 'Inter', value: 'Inter, ui-sans-serif, system-ui, sans-serif' },
{ label: 'Geist', value: 'Geist, ui-sans-serif, system-ui, sans-serif' },
{ label: 'Roboto', value: 'Roboto, ui-sans-serif, system-ui, sans-serif' },
{ label: 'Plus Jakarta Sans', value: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif' },
{ label: 'Space Grotesk', value: '"Space Grotesk", ui-sans-serif, system-ui, sans-serif' },
{ label: 'DM Sans', value: '"DM Sans", ui-sans-serif, system-ui, sans-serif' },
{ label: 'Playfair Display', value: '"Playfair Display", ui-serif, Georgia, serif' },
{ label: 'Lora', value: 'Lora, ui-serif, Georgia, serif' },
{ label: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, "Courier New", monospace' },
]
function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="border-b border-zinc-100">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-zinc-50 transition-colors"
>
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-widest">{title}</span>
{open ? <ChevronDown size={13} className="text-zinc-400" /> : <ChevronRight size={13} className="text-zinc-400" />}
</button>
{open && <div className="px-4 pb-4">{children}</div>}
</div>
)
}
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
const [hex, setHex] = useState(value)
const handleHexChange = (v: string) => {
setHex(v)
if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v)
}
const handlePickerChange = (v: string) => {
setHex(v)
onChange(v)
}
// Sync when external value changes (preset applied)
if (hex !== value && /^#[0-9a-fA-F]{6}$/.test(value)) {
setHex(value)
}
return (
<div className="flex items-center justify-between py-1.5 gap-2">
<span className="text-xs text-zinc-600 flex-1 min-w-0 truncate">{label}</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
<input
type="color"
aria-label={`${label} color picker`}
value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : '#888888'}
onChange={(e) => handlePickerChange(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border border-zinc-200 p-0.5 bg-white"
style={{ appearance: 'none' }}
/>
<input
type="text"
aria-label={`${label} color value`}
value={hex}
onChange={(e) => handleHexChange(e.target.value)}
className="w-20 text-xs font-mono px-2 py-1 border border-zinc-200 rounded outline-none focus:border-indigo-400"
placeholder="#000000"
maxLength={7}
spellCheck={false}
/>
</div>
</div>
)
}
function exportCSS(theme: ThemeConfig): string {
return `:root {
--background: ${theme.background};
--foreground: ${theme.foreground};
--primary: ${theme.primary};
--primary-foreground: ${theme.primaryForeground};
--muted: ${theme.muted};
--muted-foreground: ${theme.mutedForeground};
--border: ${theme.border};
--accent: ${theme.accent};
--radius: ${theme.radius}rem;
font-family: ${theme.fontFamily};
}`
}
export function ThemeBuilder({ theme, onChange }: { theme: ThemeConfig; onChange: (t: ThemeConfig) => void }) {
const set = <K extends keyof ThemeConfig>(key: K, value: ThemeConfig[K]) => {
onChange({ ...theme, [key]: value })
}
const applyPreset = (presetKey: string) => {
const preset = PRESET_THEMES[presetKey]
if (!preset) return
const { name: _name, ...rest } = preset
onChange(rest)
}
const handleExport = () => {
const css = exportCSS(theme)
const blob = new Blob([css], { type: 'text/css' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'theme.css'
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 pt-4 pb-3 border-b border-zinc-100">
<h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-widest mb-3">Theme Builder</h2>
{/* Preset chips */}
<div className="flex flex-wrap gap-1.5">
{Object.entries(PRESET_THEMES).map(([key, preset]) => (
<button
key={key}
onClick={() => applyPreset(key)}
title={preset.name}
className="flex items-center gap-1.5 px-2 py-1 rounded-md border border-zinc-200 hover:border-zinc-400 text-xs text-zinc-600 transition-colors hover:bg-zinc-50"
>
<div
className="w-3 h-3 rounded-full border border-zinc-300 flex-shrink-0"
style={{ background: preset.primary }}
/>
{preset.name}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{/* Colors */}
<Section title="Colors">
<div className="space-y-0.5">
{COLOR_FIELDS.map((field) => (
<ColorRow
key={field.key}
label={field.label}
value={theme[field.key] as string}
onChange={(v) => set(field.key, v)}
/>
))}
</div>
</Section>
{/* Typography */}
<Section title="Typography">
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-zinc-600 block mb-1.5">Font Family</label>
<select
aria-label="Font family"
value={theme.fontFamily}
onChange={(e) => set('fontFamily', e.target.value)}
className="w-full text-xs border border-zinc-200 rounded-md px-2.5 py-1.5 outline-none focus:border-indigo-400 bg-white"
>
{FONTS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
</div>
</Section>
{/* Shape */}
<Section title="Shape">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-zinc-600">Border Radius</label>
<span className="text-xs font-mono text-zinc-500">{theme.radius}rem</span>
</div>
<input
type="range"
aria-label="Border radius"
min={0}
max={2}
step={0.125}
value={theme.radius}
onChange={(e) => set('radius', parseFloat(e.target.value))}
className="w-full accent-indigo-500"
/>
<div className="flex justify-between mt-1">
<span className="text-xs text-zinc-400">Sharp</span>
<span className="text-xs text-zinc-400">Rounded</span>
</div>
</div>
{/* Preview swatch */}
<div className="mt-3 flex gap-2">
{[theme.primary, theme.muted, theme.accent].map((color, i) => (
<div
key={i}
style={{
width: 40,
height: 32,
background: /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#888',
borderRadius: `${theme.radius}rem`,
border: '1px solid rgba(0,0,0,0.08)',
transition: 'border-radius 0.2s, background 0.2s',
}}
/>
))}
<div style={{
width: 40,
height: 32,
background: theme.background,
borderRadius: `${theme.radius}rem`,
border: `1px solid ${theme.border}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.6rem', color: theme.foreground, fontWeight: 600,
transition: 'all 0.2s',
}}>
Aa
</div>
</div>
</Section>
{/* CSS Preview */}
<Section title="CSS Variables" defaultOpen={false}>
<pre className="text-xs font-mono bg-zinc-900 text-zinc-100 rounded-md p-3 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{exportCSS(theme)}
</pre>
</Section>
</div>
{/* Footer actions */}
<div className="border-t border-zinc-100 px-4 py-3 flex gap-2">
<button
onClick={() => onChange(PRESET_THEMES.zinc)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-zinc-200 rounded-md hover:bg-zinc-50 text-zinc-600 transition-colors"
>
<RotateCcw size={12} />
Reset
</button>
<button
onClick={handleExport}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors font-medium"
>
<Download size={12} />
Export CSS
</button>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
import React, { useState } from 'react'
const ERROR_IMG_SRC =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [didError, setDidError] = useState(false)
const handleError = () => {
setDidError(true)
}
const { src, alt, style, className, ...rest } = props
return didError ? (
<div
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
style={style}
>
<div className="flex items-center justify-center w-full h-full">
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
</div>
</div>
) : (
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
)
}

View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "./utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,11 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@ -0,0 +1,53 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "./utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,109 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "./utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@ -0,0 +1,58 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export { Calendar };