352 lines
12 KiB
Go
352 lines
12 KiB
Go
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
|
|
} |