473 lines
14 KiB
Go
473 lines
14 KiB
Go
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
|
|
}
|
|
} |