diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4665f0f..c5b19a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,27 @@ permissions: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + CMS_TEST_DATABASE_URL: postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Verify exact tested source + env: + EXPECTED_TEST_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_TEST_SHA" - uses: actions/setup-go@v6 with: go-version-file: go.mod diff --git a/adminui/static/admin.js b/adminui/static/admin.js index 526d9a9..54b4164 100644 --- a/adminui/static/admin.js +++ b/adminui/static/admin.js @@ -14,6 +14,10 @@ const api = { }); if (!res.ok) { const text = await res.text(); + if (res.status === 409) { + let error; try { error = JSON.parse(text); } catch {} + if (error?.error === "version_conflict") throw new Error("This page changed. Your draft is still here. Keep a copy of your changes, then reload to review the latest page before saving."); + } throw new Error(method + " " + path + " → " + res.status + ": " + text); } if (res.status === 204) return null; @@ -29,7 +33,7 @@ const api = { getPage(tid, pid) { return this.req("GET", "/tenants/" + tid + "/pages/" + pid); }, createPage(tid, b) { return this.req("POST", "/tenants/" + tid + "/pages", b); }, updatePage(tid, pid, b) { return this.req("PUT", "/tenants/" + tid + "/pages/" + pid, b); }, - deletePage(tid, pid) { return this.req("DELETE", "/tenants/" + tid + "/pages/" + pid); }, + deletePage(tid, pid, version) { return this.req("DELETE", "/tenants/" + tid + "/pages/" + pid, {expected_version:version}); }, permissions(tid, pid) { return this.req("GET", "/tenants/" + tid + "/pages/permissions" + (pid ? "?page_id="+pid : "")); }, templates(tid) { return this.req("GET", "/tenants/" + tid + "/pages/templates"); }, async preview(tid, body) { @@ -121,7 +125,7 @@ async function applyView(route){ const form=$("#page-editor-form");form.reset();pageField(form,"id").value=p.ID||""; for(const [field,key]of Object.entries({path:"Path",title:"Title",status:"Status",template_id:"TemplateID"}))pageField(form,field).value=p[key]|| (field==="status"?"draft":""); pageField(form,"publish_at").value=isoToDateTimeLocal(p.PublishAt);pageField(form,"unpublish_at").value=isoToDateTimeLocal(p.UnpublishAt); - const body=p.RenderedBodyHTML??p.BodyHTML??"";setRichEditorHTML(form,body);savedPageBodies.set(form,{html:body,blocks:p.BodyBlocks});pagePermissions=permissions;draftKey=pageKey(route);baselineDraft=JSON.stringify(pagePayload(form)); + const body=p.RenderedBodyHTML??p.BodyHTML??"";setRichEditorHTML(form,body);savedPageBodies.set(form,{html:body,blocks:p.BodyBlocks,version:p.Version});pagePermissions=permissions;draftKey=pageKey(route);baselineDraft=JSON.stringify(pagePayload(form)); $("#page-templates").replaceChildren(...(templates.templates||[]).map(name=>new Option(name,name))); $("#editor-tenant").textContent=(tenant.Label||tenant.Slug)+" · "+tenant.Slug;$("#editor-title").textContent=p.Title||"New page"; const pages=$("#pages-link");pages.dataset.route="/tenants/"+tenant.ID+"/pages";pages.href=cmsBase+pages.dataset.route; @@ -497,14 +501,14 @@ document.addEventListener("DOMContentLoaded",()=>{ if(!form.checkValidity()){await navigate(pagePath(route,"publishing"));form.reportValidity();return} const payload=pagePayload(form);saving=true;freezeDraft(true);$("#btn-save-page").disabled=true;updateSaveState(); try{ - const saved=route.pid==="new"?await api.createPage(tid,payload):await api.updatePage(tid,route.pid,payload); + const saved=route.pid==="new"?await api.createPage(tid,payload):await api.updatePage(tid,route.pid,{...payload,expected_version:savedPageBodies.get(form)?.version}); if(key!==draftKey||tid!==currentTenant?.ID||generation!==navigationGeneration)return; - savedPageBodies.set(form,{html:payload.body_html,blocks:payload.body_blocks});baselineDraft=JSON.stringify(payload);$("#editor-title").textContent=payload.title;toast("Page saved", "ok"); + savedPageBodies.set(form,{html:payload.body_html,blocks:payload.body_blocks,version:saved.Version});baselineDraft=JSON.stringify(payload);$("#editor-title").textContent=payload.title;toast("Page saved", "ok"); if(route.pid==="new"){freezeDraft(false);saving=false;discardDraft();await navigate("/tenants/"+tid+"/pages/"+saved.ID+"/content",true)} }catch(e){if(key===draftKey&&generation===navigationGeneration)fail(e)}finally{freezeDraft(false);saving=false;$("#btn-save-page").disabled=false;updateSaveState()} }; $("#btn-page-preview").onclick=()=>refreshPreview().catch(fail); - $("#btn-delete-page").onclick=async()=>{if(saving||!pagePermissions.delete||!confirm("Delete this page?"))return;const tid=currentTenant.ID,pid=currentRoute.pid; saving=true;try{await api.deletePage(tid,pid);discardDraft();saving=false;await navigate("/tenants/"+tid+"/pages")}catch(e){fail(e)}finally{saving=false;updateSaveState()}}; + $("#btn-delete-page").onclick=async()=>{if(saving||!pagePermissions.delete||!confirm("Delete this page?"))return;const tid=currentTenant.ID,pid=currentRoute.pid; saving=true;try{await api.deletePage(tid,pid,savedPageBodies.get($("#page-editor-form"))?.version);discardDraft();saving=false;await navigate("/tenants/"+tid+"/pages")}catch(e){fail(e)}finally{saving=false;updateSaveState()}}; }else{ $("#new-tenant-form").onsubmit=async ev=>{ev.preventDefault();const fd=new FormData(ev.target);try{await api.createTenant({slug:fd.get("slug"),label:fd.get("label"),theme_id:fd.get("theme_id")});ev.target.reset();toast("Tenant created","ok");await navigate("/tenants")}catch(e){fail(e)}}; $("#new-domain-form").onsubmit=async ev=>{ev.preventDefault();const tid=currentTenant.ID,generation=navigationGeneration,fd=new FormData(ev.target);try{await api.createDomain(tid,{host:fd.get("host"),subsite_label:fd.get("subsite_label"),kind:fd.get("kind")});if(generation!==navigationGeneration)return;ev.target.reset();toast("Domain added","ok");await refreshDomains(tid,generation)}catch(e){fail(e)}}; diff --git a/docs/plans/2026-10-08-content-promotion-amendment.md b/docs/plans/2026-10-08-content-promotion-amendment.md new file mode 100644 index 0000000..b80d8ba --- /dev/null +++ b/docs/plans/2026-10-08-content-promotion-amendment.md @@ -0,0 +1,114 @@ +# Content snapshot promotion implementation amendment + +This additive CMS implementation follows the parent-authorized Tina publication +assessment in multisite `docs/runbook/tina-content-promotion.md`. It preserves +the locked Tina plan, CMS PR31 at `0ceed5592a335e16b2eb6ddc5148ef45f248d0a7`, +CMS main's separate editor changes, all consumer pins and active worktrees. + +## Scope and boundaries + +1. Typed content-only snapshot export/strict decoding, explicit target mappings, + deterministic dry-run diffs and immutable bundle verification. +2. Whole-tenant page baseline guarded atomic batches and content-only before/after + receipts. Guarded rollback refuses any intervening page mutation. +3. All ordinary CMS creates/updates/deletes serialize with batches. Update uses + the caller's loaded `Page.Version`; API save/delete requires the client's + `expected_version`, and the editor retains its draft on conflict. +4. Memory/API tests plus a real isolated Postgres schema prove stale saves, + racing writes, all-or-nothing failure, mappings, omission and rollback. + +There is no promotion HTTP route, production write, credential configuration, +host deployment, consumer-pin change or storage purchase in this amendment. +Passwords, hashes, tokens, domains, roles, memberships, sessions and repository +mapping are outside every promotion struct. Unknown JSON fields and malformed +payloads yield fixed errors without printing supplied values. + +`PageStore.Delete` gains a required expected-version argument. This source API +change and the mandatory HTTP precondition require a coordinated host/plugin +release; consumers must not discard the browser's version or fall back to an +unconditional write. The historical PR31 editor remains unchanged in its own +worktree. + +## Concurrency contract + +Postgres page mutations take one transaction-scoped advisory lock per tenant. +Batches read and compare the complete tenant page ID/version/content baseline +under that same lock and commit all mutations in one transaction. The lock is +cooperative, not an atomic cloud-spec CAS or a promise covering arbitrary direct +SQL writers. Per-row version predicates protect stale ordinary saves/deletes +after promotion releases the lock. No omitted page is deleted. + +Rollback compares the complete recorded post-state and restores only affected +content, retaining page IDs and increasing versions. Backups are content only; +their durable archive is the authorized publication operator's responsibility. + +## Publication remains disabled + +CMS database transactions cannot atomically switch filesystem bundles or cloud +bootstrap configuration. A live publication endpoint remains blocked on a host +write/publication fence, staged immutable bundle, verified bootstrap source, +durable backup, superadmin authority, approval digest and restart/redeploy proof. +Uploaded tenant media references are refused until durable copy/rewrite/retrieval +exists. Static files shadowing selected CMS routes refuse validation. + +## Verification and resource ownership + +Use the existing module/build caches, `GOWORK=off`, package-targeted tests with +`-p 2` at most. Parent authorized one focused local allocation and a fresh +`promotion_20261008_*` schema on existing task Postgres port 55432. No Docker, +new cache, full local race build, existing-schema mutation or process restart. +CI may run its existing full checks plus an isolated Postgres service. Record +exact-head checks and obtain independent source review before merge. + +## Independent-review correction: durable mutation generation + +Review of `f7fd874` found a delete/restore/edit/delete ABA hole: surviving page +rows alone cannot detect a changed-then-empty tenant. This additive correction +introduces a durable `cms_page_revisions` ledger with a unique content-scope +identifier and monotonic tenant revision. Every successful ordinary page write, +batch and rollback advances it under the same lock. Atomic `ReadPageState` returns +scope/revision plus rows; plans/receipts bind them and refuse replay or scope drift. +The scope is a content namespace identifier, not an authority grant or credential. + +The explicit SQL migration is a **new required host schema prerequisite**. It +initializes existing tenants and new-tenant ledger rows. No automatic migration, +existing-schema mutation or consumer adoption is authorized by this source PR. +Before adoption, the host must review/apply the migration and fence writes while +replacing every older writer: mixed old/new plugin versions would bypass the +ledger. A new store instance against the same isolated database must retain the +rollback/replay guard. Canonical block links, absolute reference exclusions and +collision-free temporary paths receive regression coverage in the same correction. + +## Independent-review correction: nested HTML resources + +The full `afc9dc9` review found unchecked nested/resource HTML attributes. +Preparation now refuses inline nested documents and object/plugin embeddings, +validates form override URLs and the other supported resource attributes, and +refuses unsupported document base/refresh overrides. Regressions exercise page +bodies, selected CMS templates and bundled HTML, including private upload/API +URLs. Public HTTPS iframe sources and validated form overrides remain supported. +This does not add a general active-content sandbox or change the host publication +gates, schema prerequisite, consumer pins or approval boundary. + +## Consumer-boundary correction: selected public media index + +The unchanged reviewed-source PostgreSQL consumer completed the generic frozen +snapshot → explicit mapping/dryrun → apply → safe rollback path, including +cross-tenant/scope refusals, monotonic versions/revisions and newer-edit refusal. +The optional private six-page bundle then exposed a public `/media` navigation +collision with the upload namespace refusal. The failed optional run and all +completed generic evidence remain preserved outside this public repository. + +Parent authorizes a narrow exception for relative hyperlinks to exactly the +selected public CMS route `/media`. Resource sources, upload children, absolute +private URLs, unselected routes and other private namespaces still refuse. +Acceptance covers body/template/static HTML and canonical block hrefs, encoded +and dot-normalized upload refusals, plus the private bundle consumer in a fresh +PostgreSQL schema. New exact-head tests and full independent source review are +required. No hosted write, migration/adoption, source pin or live apply route +is added; the prior locked plan and release gates stay intact. + +The early source review tightens this exception to canonical raw `/media` or +relative `media` path spelling. Encoded, dot-segment and trailing-slash aliases +remain refused even when normalization would resolve to the selected route. +The same negative cases cover all HTML surfaces and canonical block hrefs. diff --git a/docs/promotion.md b/docs/promotion.md new file mode 100644 index 0000000..f9aaf3f --- /dev/null +++ b/docs/promotion.md @@ -0,0 +1,96 @@ +# Preparing approved content publication + +The `promotion` package prepares frozen, content-only snapshots and explicit +production diffs. It has no network client, background synchronization or live +apply endpoint. Review edits stay in their review tenant. An approval must name +the frozen snapshot digest and production diff; later drafts require a new +approval. + +Export selected page IDs from an authorized review tenant using +`promotion.Export(ctx, pages, reviewID, sourceSite, ids, immutableBundleDir, now)`. +Archive the returned snapshot and exact bundle bytes together. The snapshot +includes source page ID/version keys, content and SHA-256 file inventory. Strict +`promotion.Decode` refuses unknown/duplicate JSON keys, trailing documents and +oversize input using fixed errors. It never imports tenant settings or authority. + +Read the production tenant atomically using `ReadPageState` and inventory its immutable bundle. The returned durable content scope/revision is part of the batch baseline. Review +`promotion.SuggestedMapping` path matches, then supply an explicit mapping for +every selected page to `promotion.DryRun`. Target page IDs are distinct from source +IDs. A zero target ID explicitly creates; moves require `AllowMove`; deletion +requires a separate `DeleteSelection`. Omission preserves existing pages. The plan +contains the complete target page baseline, both bundle digests and content diffs. +Dry run never writes. + +`store.PageBatchStore` supplies persistence primitives, not publication authority. +`ApplyPageBatch` atomically compares the entire page baseline and applies content +in one tenant. Ordinary creates, saves and deletes share its per-tenant lock. +Update requires the caller's loaded `Page.Version`. API PUT and DELETE require +`expected_version`; missing preconditions fail and stale versions return 409. +The editor keeps an unsaved draft on conflict and refreshes its version after a +successful save. Existing external API consumers must upgrade before this plugin +is adopted. Every successful page write also advances the durable tenant content +revision; receipts bind that revision and the content scope, preventing receipt +replay even after create/delete history leaves the same surviving rows. `PageStore.Delete` also gains a required expected-version argument. + +Archive the content-only batch receipt before completing host publication. Its +before/after rows, explicit mapping and integrity digest support +`RollbackPageBatch`, which refuses any change to the recorded post-state and +restores affected content with increasing versions. The receipt digest detects +corruption; it is neither a signature nor approval. Durable backup storage and +operator authorization belong to the host publication workflow. + +`VerifyBundle` re-hashes regular files, verifies template/assets references and +refuses symlinks, missing assets, uploaded tenant media URLs, admin API references +and static files shadowing selected CMS routes. Bundled photos retain relative +references and exact hashes. The operator must stage those verified immutable +bytes in the target bundle. The library does not configure media storage, copy +uploaded objects, fetch third-party embeds or claim rights to external media. + +HTML checks cover page bodies, CMS templates and bundled HTML. Inline nested +documents (`srcdoc`), object/plugin embeddings (`object`, `embed`), reference base +overrides and meta refresh are unsupported and refuse validation. Public HTTPS +iframe sources remain supported; form action/override URLs, image source sets +and other resource attributes use the same private-path and asset checks. +This is reference validation, not a JavaScript sandbox. The host must review the +exact approved bundle's scripts and runtime behavior separately. + +The public CMS index route `/media` may be a relative hyperlink only when that +exact route is in the selected snapshot pages. The exception covers navigation, +including canonical block links; it does not permit media resource URLs, upload +children, unselected routes, absolute private URLs or other private namespaces. +Only canonical `/media` or relative `media` path spelling qualifies; encoded, +dot-segment and trailing-slash aliases remain refused. + +The SQL prerequisite `store/postgres/migrations/0001_page_content_revision.up.sql` +must be reviewed and applied by the host before adoption. It initializes tenant +content scopes/revisions and new-tenant initialization. This library never applies +it automatically. Fence writes while replacing all older writers; mixed versions +bypass the ledger. Preserve ledger history across restart/backup, and never drop +or reset it to force a conflicted rollback. + +Live application remains blocked on host integration: current superadmin +publication authority, a named approval digest, an exclusive publication/write +fence, staged immutable assets, durable content/bundle backups, approved production +bootstrap source and actual restart/redeploy proof. A database transaction cannot +atomically swap a filesystem bundle or cloud bootstrap setting. Preserve the +existing domains, credentials, roles, memberships and repository mapping. If the +host cannot provide the fence and restart-safe bundle, use a separately reviewed +maintenance procedure and do not enable partial publication. + +## Verification + +`go test ./store ./promotion ./internal ./adminui ./store/postgres` runs focused +unit/API checks. Set `CMS_TEST_DATABASE_URL` to an isolated authorized test server +to run the real Postgres contract; each test creates and cleans only a fresh +`promotion_20261008_*` schema. CI provisions a disposable Postgres service and +checks the exact PR head before its existing vet/race checks. + +The optional actual-editor test uses already installed tools. Set +`CMS_TEST_PLAYWRIGHT_PATH` to the local Playwright package and +`CMS_TEST_BROWSER_EXECUTABLE` to the browser binary; `CMS_TEST_SCREENSHOT` is an +optional screenshot destination. `TestPostgresEditorVersionBrowser` launches +only a temporary browser context and ephemeral loopback listener. Its authority +and promotion helper exist only in the test binary. It demonstrates repeated +save version refresh, stale save/delete conflicts after a batch, retained draft, +wrong-tenant denial and save/reload through the real editor/API/Postgres boundary. +It does not prove hosted authentication or host bundle activation. diff --git a/docs/qa/content-promotion.md b/docs/qa/content-promotion.md new file mode 100644 index 0000000..2490662 --- /dev/null +++ b/docs/qa/content-promotion.md @@ -0,0 +1,43 @@ +# Content promotion verification + +This verifies CMS persistence/preparation primitives, not hosted publication. +There is no live apply endpoint, consumer-pin update or production content write. +Host bundle fencing, durable backup/bootstrap integration and restart/redeploy +proof remain required before live publication can be enabled. + +## Local behavior demonstrated + +Focused tests use Go 1.27.1, existing shared module/native build caches, +`GOWORK=off`, offline dependencies and concurrency two. Real Postgres uses only +fresh `promotion_20261008_*` schemas on the existing task-owned local server; +each schema is cleaned by its test. No runtime tables, processes or ports change. + +| Boundary | Observed result | +| --- | --- | +| Snapshot export → strict decode | Frozen selection survives decode; later source edits do not affect approved content; authority fields, duplicate keys, trailing documents and malformed fields refuse with fixed errors | +| Source → target mapping/dryrun | IDs differ; explicit mapping required; implicit moves refused; dry run writes nothing; omitted target page retained | +| Batch → real Postgres → reload | Update/create/delete persist atomically; mapping names target identities; complete page baseline checked | +| Ordinary save/delete → batch | Stale loaded versions conflict; racing save/promotion and delete/promotion each admit one winner | +| Database failure mid-batch | Isolated trigger refuses the second item; earlier update rolls back and complete baseline remains unchanged | +| Durable revision → rollback replay | Deleted/restore/edit/delete ABA and create/delete history refuse replay; fresh Postgres connections retain the guard | +| Path moves → Postgres | Occupied legacy staging paths do not block title-only updates; two-page path swap persists | +| Canonical blocks → references | Internal/relative/anchor/mail/HTTPS links export through the actual renderer; arbitrary slash text is preserved; absolute/encoded admin/API/media references refuse | +| Receipt → rollback | Content restored with increasing versions; created page removed; deleted page restored; later edit refuses whole rollback | +| Bundle → validator | Changed bytes, missing assets, static homepage shadow, symlink, admin reference and encoded tenant-upload references refused | +| HTML resource surfaces → validator | Page bodies, templates and bundled HTML refuse nested `srcdoc`, object/plugin embeddings and private resource/form overrides; public HTTPS iframe sources and verified local overrides remain usable | +| Actual Chrome editor → API → Postgres | Repeated saves send versions 1/2; fixture promotion changes version to 4; stale save/delete send 3 and return 409; unsaved draft retained; reload then version-4 save persists as version 5; wrong tenant returns 403 | + +The browser test launches an ephemeral loopback test server and a temporary +Chrome context using already installed Playwright. Authority and promotion helper +exist only in the test binary. It is not a hosted authentication or domain proof. +The task evidence directory retains the browser conflict screenshot and test logs. + +## Release limits + +The explicit durable-revision SQL migration and a fenced rollout replacing every +older writer are mandatory new host schema prerequisites. No automatic migration +or host schema deployment occurred. `PageStore.Delete` now requires expected version; external API clients must send +`expected_version` for PUT/DELETE. The CMS host must consume this change in a +separate reviewed release. The historical PR31 worktree and current consumer +pin are preserved. Independent review and exact committed-head CI are recorded +separately; this document does not imply those gates passed before observation. diff --git a/internal/admin_http.go b/internal/admin_http.go index fd032ca..bf6bc5b 100644 --- a/internal/admin_http.go +++ b/internal/admin_http.go @@ -1,9 +1,11 @@ package internal import ( + "bytes" "context" "encoding/json" "errors" + "io" "net/http" "strconv" "strings" @@ -379,15 +381,16 @@ type pageBody struct { } type pageUpdateBody struct { - Subsite *string `json:"subsite"` - Path *string `json:"path"` - Title *string `json:"title"` - BodyHTML *string `json:"body_html"` - BodyBlocks json.RawMessage `json:"body_blocks"` - Status *string `json:"status"` - TemplateID *string `json:"template_id"` - PublishAt json.RawMessage `json:"publish_at"` - UnpublishAt json.RawMessage `json:"unpublish_at"` + ExpectedVersion int `json:"expected_version"` + Subsite *string `json:"subsite"` + Path *string `json:"path"` + Title *string `json:"title"` + BodyHTML *string `json:"body_html"` + BodyBlocks json.RawMessage `json:"body_blocks"` + Status *string `json:"status"` + TemplateID *string `json:"template_id"` + PublishAt json.RawMessage `json:"publish_at"` + UnpublishAt json.RawMessage `json:"unpublish_at"` } func (a *AdminAPI) createPage(w http.ResponseWriter, r *http.Request, tenantID int64) { @@ -477,6 +480,14 @@ func (a *AdminAPI) updatePage(w http.ResponseWriter, r *http.Request, tenantID, writeJSONError(w, http.StatusBadRequest, "bad_request", err.Error()) return } + if body.ExpectedVersion <= 0 { + writeJSONError(w, http.StatusPreconditionRequired, "precondition_required", "expected_version required; reload the page") + return + } + if body.ExpectedVersion != existing.Version { + statusFromPageErr(w, store.ErrVersionConflict) + return + } if body.Path != nil { existing.Path = *body.Path } @@ -540,7 +551,18 @@ func (a *AdminAPI) deletePage(w http.ResponseWriter, r *http.Request, tenantID, writeJSONError(w, http.StatusServiceUnavailable, "unavailable", "page store not configured") return } - if err := a.pages.Delete(r.Context(), tenantID, pageID); err != nil { + var body struct { + ExpectedVersion int `json:"expected_version"` + } + if err := decodeJSON(r, &body); err != nil { + writeJSONError(w, http.StatusBadRequest, "bad_request", "invalid JSON request") + return + } + if body.ExpectedVersion <= 0 { + writeJSONError(w, http.StatusPreconditionRequired, "precondition_required", "expected_version required; reload the page") + return + } + if err := a.pages.Delete(r.Context(), tenantID, pageID, body.ExpectedVersion); err != nil { statusFromPageErr(w, err) return } @@ -729,6 +751,8 @@ func statusFromPageErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, store.ErrNotFound): writeJSONError(w, http.StatusNotFound, "not_found", err.Error()) + case errors.Is(err, store.ErrVersionConflict): + writeJSONError(w, http.StatusConflict, "version_conflict", err.Error()) case errors.Is(err, store.ErrPathConflict): writeJSONError(w, http.StatusConflict, "conflict", err.Error()) default: @@ -740,9 +764,19 @@ func statusFromPageErr(w http.ResponseWriter, err error) { func decodeJSON(r *http.Request, dst any) error { defer r.Body.Close() - dec := json.NewDecoder(r.Body) + b, err := io.ReadAll(io.LimitReader(r.Body, (8<<20)+1)) + if err != nil || len(b) > 8<<20 { + return errors.New("invalid JSON request") + } + dec := json.NewDecoder(bytes.NewReader(b)) dec.DisallowUnknownFields() - return dec.Decode(dst) + if err := dec.Decode(dst); err != nil { + return errors.New("invalid JSON request") + } + if err := dec.Decode(new(any)); err != io.EOF { + return errors.New("invalid JSON request") + } + return nil } func writeJSON(w http.ResponseWriter, status int, body any) { diff --git a/internal/admin_http_test.go b/internal/admin_http_test.go index 4c7ed3b..f98f3a5 100644 --- a/internal/admin_http_test.go +++ b/internal/admin_http_test.go @@ -48,7 +48,7 @@ func TestAdmin_PageTemplateAndScheduleFieldsPersist(t *testing.T) { pid := int64(gotP["ID"].(float64)) rec, gotP = doJSON(t, api, http.MethodPut, "/api/v1/admin/tenants/"+strconv.FormatInt(tid, 10)+"/pages/"+strconv.FormatInt(pid, 10), - map[string]any{"template_id": "alt-shell", "status": "published"}) + map[string]any{"expected_version": gotP["Version"], "template_id": "alt-shell", "status": "published"}) if rec.Code != http.StatusOK { t.Fatalf("update scheduled page: %d %v", rec.Code, gotP) } @@ -363,7 +363,7 @@ func TestAdmin_PageCRUD(t *testing.T) { // Update. rec, gotU := doJSON(t, api, http.MethodPut, "/api/v1/admin/tenants/"+strconv.FormatInt(tid, 10)+"/pages/"+strconv.FormatInt(pid, 10), - map[string]string{"title": "Updated"}) + map[string]any{"expected_version": gotP["Version"], "title": "Updated"}) if rec.Code != http.StatusOK { t.Fatalf("update page: %d %v", rec.Code, gotU) } @@ -373,7 +373,7 @@ func TestAdmin_PageCRUD(t *testing.T) { rec, gotU = doJSON(t, api, http.MethodPut, "/api/v1/admin/tenants/"+strconv.FormatInt(tid, 10)+"/pages/"+strconv.FormatInt(pid, 10), - map[string]string{"body_html": "", "subsite": ""}) + map[string]any{"expected_version": gotU["Version"], "body_html": "", "subsite": ""}) if rec.Code != http.StatusOK { t.Fatalf("clear page fields: %d %v", rec.Code, gotU) } @@ -404,7 +404,7 @@ func TestAdmin_PageCRUD(t *testing.T) { // Delete. rec, _ = doJSON(t, api, http.MethodDelete, - "/api/v1/admin/tenants/"+strconv.FormatInt(tid, 10)+"/pages/"+strconv.FormatInt(pid, 10), nil) + "/api/v1/admin/tenants/"+strconv.FormatInt(tid, 10)+"/pages/"+strconv.FormatInt(pid, 10), map[string]any{"expected_version": gotG["Version"]}) if rec.Code != http.StatusNoContent { t.Errorf("delete: %d want 204", rec.Code) } diff --git a/internal/editor_regression_test.go b/internal/editor_regression_test.go index 004872e..3bd3b59 100644 --- a/internal/editor_regression_test.go +++ b/internal/editor_regression_test.go @@ -13,7 +13,7 @@ func TestHTMLUpdateClearsCanonicalBlocksAndSchedule(t *testing.T) { _, tenant := doJSON(t, a, "POST", "/api/v1/admin/tenants", map[string]any{"slug": "review"}) base := "/api/v1/admin/tenants/" + strconv.Itoa(int(tenant["ID"].(float64))) + "/pages" _, p := doJSON(t, a, "POST", base, map[string]any{"path": "/", "title": "Home", "body_blocks": map[string]any{"type": "doc", "content": []any{map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "old"}}}}}, "publish_at": "2026-01-01T00:00:00Z"}) - rec, p := doJSON(t, a, "PUT", base+"/"+strconv.Itoa(int(p["ID"].(float64))), map[string]any{"body_html": "
Edited
", "body_blocks": nil, "publish_at": nil, "unpublish_at": nil}) + rec, p := doJSON(t, a, "PUT", base+"/"+strconv.Itoa(int(p["ID"].(float64))), map[string]any{"expected_version": p["Version"], "body_html": "Edited
", "body_blocks": nil, "publish_at": nil, "unpublish_at": nil}) if rec.Code != http.StatusOK || p["BodyBlocks"] != nil || p["PublishAt"] != nil { t.Fatalf("stale canonical content or schedule: %d %#v", rec.Code, p) } diff --git a/internal/page_concurrency_test.go b/internal/page_concurrency_test.go new file mode 100644 index 0000000..805d2e1 --- /dev/null +++ b/internal/page_concurrency_test.go @@ -0,0 +1,68 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-cms/store" +) + +func TestAdminPageSaveDeleteClientPreconditions(t *testing.T) { + pages := store.NewMemoryPageStore() + api := NewAdminAPI(store.NewMemoryTenantAdminStore(), pages) + p := &store.Page{Path: "/", Title: "Initial", Status: store.StatusDraft} + if pages.Create(context.Background(), 1, p) != nil { + t.Fatal("create failed") + } + url := "/api/v1/admin/tenants/1/pages/" + strconv.FormatInt(p.ID, 10) + for _, method := range []string{"PUT", "DELETE"} { + w, _ := doJSON(t, api, method, url, map[string]any{}) + if w.Code != http.StatusPreconditionRequired { + t.Fatal("missing loaded version accepted") + } + } + content := store.ContentOf(p) + content.Title = "Approved promotion" + state, _ := pages.ReadPageState(context.Background(), 1) + _, err := pages.ApplyPageBatch(context.Background(), 1, store.PageBatch{TargetScope: state.Scope, BaselineRevision: state.Revision, Baseline: store.Baseline(state.Pages), Mutations: []store.PageMutation{{Key: "approved", Kind: "update", TargetID: p.ID, ExpectedVersion: p.Version, Content: &content}}}) + if err != nil { + t.Fatal(err) + } + for _, method := range []string{"PUT", "DELETE"} { + payload := map[string]any{"expected_version": p.Version} + if method == "PUT" { + payload["title"] = "Stale browser draft" + } + w, _ := doJSON(t, api, method, url, payload) + if w.Code != http.StatusConflict { + t.Fatal("stale browser operation accepted") + } + } + got, _ := pages.Get(context.Background(), 1, p.ID) + if got.Title != content.Title { + t.Fatal("API used freshly fetched version to overwrite approved content") + } + w, saved := doJSON(t, api, "PUT", url, map[string]any{"expected_version": got.Version, "title": "Fresh browser save"}) + if w.Code != http.StatusOK { + t.Fatal("fresh version refused") + } + w, _ = doJSON(t, api, "DELETE", url, map[string]any{"expected_version": saved["Version"]}) + if w.Code != http.StatusNoContent { + t.Fatal("fresh delete refused") + } +} + +func TestAdminJSONRejectedFieldsNeverEchoValues(t *testing.T) { + api := newAdminTestAPI() + for _, body := range []string{`{"path":"/","title":"Page","rejected_marker":"credential_fixture"}`, `{"path":"/","title":"Page"} {"rejected_marker":"credential_fixture"}`} { + w := httptest.NewRecorder() + api.ServeHTTP(w, httptest.NewRequest("POST", "/api/v1/admin/tenants/1/pages", strings.NewReader(body))) + if w.Code != http.StatusBadRequest || strings.Contains(w.Body.String(), "credential_fixture") || strings.Contains(w.Body.String(), "rejected_marker") { + t.Fatal("invalid request leaked rejected fields") + } + } +} diff --git a/internal/page_document.go b/internal/page_document.go index d9b0284..8b0972f 100644 --- a/internal/page_document.go +++ b/internal/page_document.go @@ -72,6 +72,10 @@ func renderPageBody(page *store.Page) (string, error) { return page.BodyHTML, nil } +// RenderPageBody shares the actual canonical serve-time renderer with content +// preparation. Link validation follows rendered semantics, not arbitrary text. +func RenderPageBody(page *store.Page) (string, error) { return renderPageBody(page) } + type blockNode struct { Type string `json:"type"` Text string `json:"text"` diff --git a/promotion/bundle.go b/promotion/bundle.go new file mode 100644 index 0000000..d2c1b5f --- /dev/null +++ b/promotion/bundle.go @@ -0,0 +1,375 @@ +package promotion + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + + cms "github.com/GoCodeAlone/workflow-plugin-cms/internal" + "github.com/GoCodeAlone/workflow-plugin-cms/store" + "golang.org/x/net/html" +) + +type BundleFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` +} +type BundleManifest struct { + Files []BundleFile `json:"files"` + Digest string `json:"digest"` +} + +func (m BundleManifest) contentDigest() string { m.Digest = ""; return sumJSON(m) } +func cleanFile(p string) bool { + return p != "" && !strings.HasPrefix(p, "/") && path.Clean(p) == p && p != "." && p != ".." && !strings.HasPrefix(p, "../") && !strings.ContainsAny(p, "\\\x00?#") && !strings.Contains(p, "/.") && !strings.HasPrefix(p, ".") +} +func (m BundleManifest) Validate() error { + if len(m.Files) == 0 || len(m.Files) > 4096 || m.Digest != m.contentDigest() { + return ErrBundle + } + previous := "" + var total int64 + for _, f := range m.Files { + b, err := hex.DecodeString(f.SHA256) + if !cleanFile(f.Path) || f.Path <= previous || f.Size < 0 || f.Size > 64<<20 || err != nil || len(b) != sha256.Size || strings.ToLower(f.SHA256) != f.SHA256 { + return ErrBundle + } + previous = f.Path + total += f.Size + if total > 64<<20 { + return ErrBundle + } + } + return nil +} + +// InventoryBundle hashes exact regular files only. Call with an immutable +// version directory, not a current symlink. No file content is logged. +func InventoryBundle(root string) (BundleManifest, error) { + info, err := os.Lstat(root) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return BundleManifest{}, ErrBundle + } + m := BundleManifest{Files: []BundleFile{}} + var total int64 + err = filepath.WalkDir(root, func(name string, d fs.DirEntry, walkErr error) error { + if walkErr != nil || d.Type()&os.ModeSymlink != 0 { + return ErrBundle + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil || !info.Mode().IsRegular() || info.Size() > 64<<20 { + return ErrBundle + } + rel, err := filepath.Rel(root, name) + if err != nil { + return ErrBundle + } + rel = filepath.ToSlash(rel) + if !cleanFile(rel) { + return ErrBundle + } + total += info.Size() + if total > 64<<20 || len(m.Files) >= 4096 { + return ErrBundle + } + file, err := os.Open(name) + if err != nil { + return ErrBundle + } + h := sha256.New() + n, err := io.Copy(h, io.LimitReader(file, 64<<20+1)) + closeErr := file.Close() + if err != nil || closeErr != nil || n != info.Size() { + return ErrBundle + } + m.Files = append(m.Files, BundleFile{rel, hex.EncodeToString(h.Sum(nil)), n}) + return nil + }) + if err != nil { + return BundleManifest{}, ErrBundle + } + sort.Slice(m.Files, func(i, j int) bool { return m.Files[i].Path < m.Files[j].Path }) + m.Digest = m.contentDigest() + if err = m.Validate(); err != nil { + return BundleManifest{}, err + } + return m, nil +} + +var cssReferences = regexp.MustCompile(`(?i)url\(\s*["']?([^\s"')]+)["']?\s*\)|@import\s+["']([^"']+)["']`) + +// VerifyBundle re-hashes the supplied directory, validates local references and +// rejects exact static route shadowing. Uploaded tenant media and admin paths +// remain unsupported; copying their URLs would leak review tenant identity. +func VerifyBundle(root string, manifest BundleManifest, pages []store.PageContent) error { + actual, err := InventoryBundle(root) + if err != nil || manifest.Validate() != nil || sumJSON(actual) != sumJSON(manifest) { + return ErrBundle + } + files := map[string]bool{} + routes := map[string]bool{} + for _, f := range manifest.Files { + files["/"+f.Path] = true + } + for _, p := range pages { + if p.Validate() != nil { + return ErrBundle + } + routes[p.Path] = true + exact := strings.TrimPrefix(p.Path, "/") + if exact == "" { + exact = "index.html" + } + if files["/"+exact] { + return ErrBundle + } + if p.TemplateID != "" && (!cleanFile(p.TemplateID) || strings.Contains(p.TemplateID, "/") || !files["/cms/templates/"+p.TemplateID+".html"]) { + return ErrBundle + } + } + type referenceKind uint8 + const ( + resourceReference referenceKind = iota + navigationReference + descriptionReference + ) + checkRef := func(raw, base string, kind referenceKind) error { + link := kind != resourceReference + if raw == "" || strings.HasPrefix(raw, "#") { + return nil + } + u, err := url.Parse(raw) + if err != nil || u.User != nil { + return ErrBundle + } + if strings.ContainsAny(u.Path, "\\\x00") { + return ErrBundle + } + resolved := path.Clean(u.Path) + if u.Scheme == "" && !strings.HasPrefix(u.Path, "/") { + resolved = path.Join(path.Dir(base), u.Path) + } + // /media is also a valid public CMS index route. Only navigation to + // that exact selected route may use it; uploads, resource requests, + // absolute private URLs and other private namespaces remain refused. + canonicalMediaPath := u.RawPath == "" && (u.Path == "/media" || u.Path == "media") + selectedMediaLink := kind == navigationReference && u.Scheme == "" && u.Host == "" && canonicalMediaPath && resolved == "/media" && routes[resolved] + if forbiddenPath(resolved) && !selectedMediaLink { + return ErrBundle + } + if u.Scheme != "" { + if link && (u.Scheme == "mailto" || u.Scheme == "tel") { + return nil + } + if u.Scheme != "https" || u.Host == "" || strings.Contains(strings.ToLower(u.Hostname()), "preview.") || strings.Contains(strings.ToLower(u.Hostname()), "-review.") { + return ErrBundle + } + return nil + } + if u.Host != "" { + return ErrBundle + } + if files[resolved] || link && routes[resolved] { + return nil + } + return ErrBundle + } + checkCSS := func(css, base string) error { + for _, m := range cssReferences.FindAllStringSubmatch(css, -1) { + ref := m[1] + if ref == "" { + ref = m[2] + } + if err := checkRef(ref, base, resourceReference); err != nil { + return err + } + } + return nil + } + checkHTML := func(source, base string) error { + doc, err := html.Parse(strings.NewReader(source)) + if err != nil { + return ErrBundle + } + var visit func(*html.Node) error + visit = func(n *html.Node) error { + if n.Type == html.ElementNode { + switch n.Data { + case "base", "object", "embed": + // These elements can change reference resolution or load + // nested/plugin documents outside the checked HTML tree. + return ErrBundle + } + } + for _, a := range n.Attr { + switch a.Key { + case "srcdoc", "xml:base": + // srcdoc is decoded into an attribute value, not visited + // children. Nested inline documents and XML reference + // base overrides are unsupported. + return ErrBundle + case "base": + if a.Namespace == "xml" { + return ErrBundle + } + case "http-equiv": + if n.Data == "meta" && strings.EqualFold(strings.TrimSpace(a.Val), "refresh") { + return ErrBundle + } + case "href", "src", "poster", "action", "formaction", "background", "manifest": + kind := resourceReference + if a.Key == "href" && (n.Data == "a" || n.Data == "area") { + kind = navigationReference + } + if err := checkRef(a.Val, base, kind); err != nil { + return err + } + case "cite", "longdesc": + if err := checkRef(a.Val, base, descriptionReference); err != nil { + return err + } + case "ping": + for _, ref := range strings.Fields(a.Val) { + if err := checkRef(ref, base, resourceReference); err != nil { + return err + } + } + case "srcset", "imagesrcset": + for _, ref := range strings.Split(a.Val, ",") { + parts := strings.Fields(ref) + if len(parts) == 0 { + return ErrBundle + } + if err := checkRef(parts[0], base, resourceReference); err != nil { + return err + } + } + case "style": + if err := checkCSS(a.Val, base); err != nil { + return err + } + } + } + if n.Type == html.ElementNode && n.Data == "style" { + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.TextNode { + if err := checkCSS(c.Data, base); err != nil { + return err + } + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if err := visit(c); err != nil { + return err + } + } + return nil + } + return visit(doc) + } + for _, p := range pages { + canonical, err := cms.RenderPageBody(p.Page(1, 1, 1)) + if err != nil { + return ErrBundle + } + if err = checkHTML(canonical, p.Path); err != nil { + return err + } + if len(p.BodyBlocks) > 0 { + var blocks any + if json.Unmarshal(p.BodyBlocks, &blocks) != nil { + return ErrBundle + } + var checkBlock func(any) error + checkBlock = func(value any) error { + switch v := value.(type) { + case []any: + for _, item := range v { + if err := checkBlock(item); err != nil { + return err + } + } + case map[string]any: + for key, item := range v { + if ref, ok := item.(string); ok { + switch key { + case "href", "src", "url", "poster": + kind := resourceReference + if key == "href" { + kind = navigationReference + } + if err := checkRef(ref, p.Path, kind); err != nil { + return err + } + } + } + + if err := checkBlock(item); err != nil { + return err + } + } + } + return nil + } + if err := checkBlock(blocks); err != nil { + return err + } + } + if err := checkHTML(p.BodyHTML, p.Path); err != nil { + return err + } + } + for _, f := range manifest.Files { + if strings.HasSuffix(f.Path, ".css") || strings.HasSuffix(f.Path, ".html") { + b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(f.Path))) + if err != nil { + return ErrBundle + } + if strings.HasSuffix(f.Path, ".css") { + if err := checkCSS(string(b), "/"+f.Path); err != nil { + return err + } + } else { + if strings.HasPrefix(f.Path, "cms/templates/") && strings.Count(string(b), "") != 1 { + return ErrBundle + } + // Shell relative references are interpreted at public page paths. + if strings.HasPrefix(f.Path, "cms/templates/") { + for _, p := range pages { + if p.TemplateID+".html" == path.Base(f.Path) { + if err := checkHTML(string(b), p.Path); err != nil { + return err + } + } + } + } else if err := checkHTML(string(b), "/"+f.Path); err != nil { + return err + } + } + } + } + return nil +} + +func forbiddenPath(p string) bool { + for _, prefix := range []string{"/media", "/api", "/admin", "/cms"} { + if p == prefix || strings.HasPrefix(p, prefix+"/") { + return true + } + } + return false +} diff --git a/promotion/plan.go b/promotion/plan.go new file mode 100644 index 0000000..8464493 --- /dev/null +++ b/promotion/plan.go @@ -0,0 +1,138 @@ +package promotion + +import ( + "github.com/GoCodeAlone/workflow-plugin-cms/store" +) + +type Mapping struct { + Key string `json:"key"` + TargetID int64 `json:"target_id"` // zero explicitly creates a new page + AllowMove bool `json:"allow_move"` +} +type DeleteSelection struct { + Key string `json:"key"` + TargetID int64 `json:"target_id"` +} +type Diff struct { + Key string `json:"key"` + TargetID int64 `json:"target_id"` + Action string `json:"action"` + OldPath string `json:"old_path,omitempty"` + NewPath string `json:"new_path,omitempty"` + BeforeDigest string `json:"before_digest,omitempty"` + AfterDigest string `json:"after_digest,omitempty"` +} +type Plan struct { + Digest string `json:"digest"` + SnapshotDigest string `json:"snapshot_digest"` + BundleDigest string `json:"bundle_digest"` + BundleBaselineDigest string `json:"bundle_baseline_digest"` + Batch store.PageBatch `json:"batch"` + Diffs []Diff `json:"diffs"` +} + +func (p Plan) ContentDigest() string { p.Digest = ""; return sumJSON(p) } + +// DryRun requires an explicit mapping for every selected page. SuggestedMapping +// offers reviewable path matches, but never silently approves a rename/deletion. +// The caller must separately verify both on-disk bundles before publication. +func DryRun(s Snapshot, current store.PageSet, mappings []Mapping, deletes []DeleteSelection, targetBundle BundleManifest) (Plan, error) { + if err := s.Validate(); err != nil { + return Plan{}, err + } + if targetBundle.Validate() != nil { + return Plan{}, ErrBundle + } + byID := map[int64]store.PageState{} + for _, p := range current.Pages { + if p.ID <= 0 || byID[p.ID].ID != 0 { + return Plan{}, ErrMapping + } + byID[p.ID] = p + } + byKey := map[string]Mapping{} + targets := map[int64]bool{} + for _, m := range mappings { + if m.Key == "" || m.TargetID < 0 { + return Plan{}, ErrMapping + } + if _, ok := byKey[m.Key]; ok { + return Plan{}, ErrMapping + } + byKey[m.Key] = m + } + if len(mappings) != len(s.Pages) { + return Plan{}, ErrMapping + } + plan := Plan{SnapshotDigest: s.Digest, BundleDigest: s.Bundle.Digest, BundleBaselineDigest: targetBundle.Digest, Batch: store.BatchFor(current, nil), Diffs: []Diff{}} + for _, page := range s.Pages { + m, ok := byKey[page.Key] + if !ok { + return Plan{}, ErrMapping + } + content := page.Content + diff := Diff{Key: page.Key, TargetID: m.TargetID, NewPath: content.Path, AfterDigest: content.Digest()} + mutation := store.PageMutation{Key: page.Key, TargetID: m.TargetID, Content: &content} + if m.TargetID == 0 { + diff.Action = "create" + mutation.Kind = "create" + } else { + p := byID[m.TargetID] + if p.ID == 0 || targets[p.ID] { + return Plan{}, ErrMapping + } + targets[p.ID] = true + if (p.Content.Path != content.Path || p.Content.Subsite != content.Subsite) && !m.AllowMove { + return Plan{}, ErrMapping + } + diff.Action = "update" + diff.OldPath = p.Content.Path + diff.BeforeDigest = p.Content.Digest() + mutation.Kind = "update" + mutation.ExpectedVersion = p.Version + if diff.BeforeDigest == diff.AfterDigest { + diff.Action = "unchanged" + } + } + plan.Diffs = append(plan.Diffs, diff) + if diff.Action != "unchanged" { + plan.Batch.Mutations = append(plan.Batch.Mutations, mutation) + } + } + for _, d := range deletes { + p := byID[d.TargetID] + if d.Key == "" || p.ID == 0 || targets[p.ID] { + return Plan{}, ErrMapping + } + targets[p.ID] = true + plan.Batch.Mutations = append(plan.Batch.Mutations, store.PageMutation{Key: d.Key, Kind: "delete", TargetID: p.ID, ExpectedVersion: p.Version}) + plan.Diffs = append(plan.Diffs, Diff{Key: d.Key, TargetID: p.ID, Action: "delete", OldPath: p.Content.Path, BeforeDigest: p.Content.Digest()}) + } + if len(plan.Batch.Mutations) > 0 { + if err := store.CheckPageBatch(current, plan.Batch, false); err != nil { + return Plan{}, err + } + } + plan.Digest = plan.ContentDigest() + return plan, nil +} + +func SuggestedMapping(s Snapshot, current store.PageSet) ([]Mapping, error) { + if s.Validate() != nil { + return nil, ErrInvalid + } + out := []Mapping{} + for _, page := range s.Pages { + m := Mapping{Key: page.Key} + for _, p := range current.Pages { + if p.Content.Subsite == page.Content.Subsite && p.Content.Path == page.Content.Path { + if m.TargetID != 0 { + return nil, ErrMapping + } + m.TargetID = p.ID + } + } + out = append(out, m) + } + return out, nil +} diff --git a/promotion/snapshot.go b/promotion/snapshot.go new file mode 100644 index 0000000..5c9f178 --- /dev/null +++ b/promotion/snapshot.go @@ -0,0 +1,186 @@ +// Package promotion prepares content-only publication artifacts. It deliberately +// has no network client or apply endpoint: host authority, bundle fencing and +// restart-safe activation are separate mandatory publication gates. +package promotion + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "regexp" + "sort" + "strconv" + "time" + + "github.com/GoCodeAlone/workflow-plugin-cms/store" +) + +var ErrInvalid = errors.New("content snapshot invalid") +var ErrBundle = errors.New("content bundle invalid or unsupported reference") +var ErrMapping = errors.New("content mapping invalid or ambiguous") + +const SchemaVersion = 1 +const maxSnapshotBytes = 8 << 20 + +var sitePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +type SnapshotPage struct { + Key string `json:"key"` + SourceID int64 `json:"source_id"` + SourceVersion int `json:"source_version"` + Content store.PageContent `json:"content"` +} +type Snapshot struct { + Schema int `json:"schema"` + SourceSite string `json:"source_site"` + CreatedAt time.Time `json:"created_at"` + Pages []SnapshotPage `json:"pages"` + Bundle BundleManifest `json:"bundle"` + Digest string `json:"digest"` +} + +func sumJSON(value any) string { + b, _ := json.Marshal(value) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} +func (s Snapshot) ContentDigest() string { s.Digest = ""; return sumJSON(s) } + +func (s Snapshot) Validate() error { + if s.Schema != SchemaVersion || !sitePattern.MatchString(s.SourceSite) || s.CreatedAt.IsZero() || s.CreatedAt.Location() != time.UTC || len(s.Pages) == 0 || len(s.Pages) > 1000 || s.Digest != s.ContentDigest() { + return ErrInvalid + } + keys := map[string]bool{} + paths := map[string]bool{} + for _, p := range s.Pages { + if p.SourceID <= 0 || p.SourceVersion <= 0 || p.Key != s.SourceSite+":"+strconv.FormatInt(p.SourceID, 10) || keys[p.Key] { + return ErrInvalid + } + keys[p.Key] = true + if err := p.Content.Validate(); err != nil { + return ErrInvalid + } + key := p.Content.Subsite + "\x00" + p.Content.Path + if paths[key] { + return ErrInvalid + } + paths[key] = true + } + if err := s.Bundle.Validate(); err != nil { + return err + } + return nil +} + +// Export freezes selected rows. It reads the supplied authorized tenant scope; +// it never consults a domain, credential, tenant setting or editor membership. +func Export(ctx context.Context, pages store.PageStore, sourceTenantID int64, sourceSite string, ids []int64, bundleRoot string, now time.Time) (Snapshot, error) { + if !sitePattern.MatchString(sourceSite) || sourceTenantID <= 0 || len(ids) == 0 { + return Snapshot{}, ErrInvalid + } + all, err := pages.List(ctx, sourceTenantID, "") + if err != nil { + return Snapshot{}, ErrInvalid + } + available := map[int64]*store.Page{} + for _, p := range all { + available[p.ID] = p + } + s := Snapshot{Schema: SchemaVersion, SourceSite: sourceSite, CreatedAt: now.UTC(), Pages: []SnapshotPage{}} + seen := map[int64]bool{} + for _, id := range ids { + p := available[id] + if p == nil || seen[id] { + return Snapshot{}, ErrInvalid + } + seen[id] = true + s.Pages = append(s.Pages, SnapshotPage{sourceSite + ":" + strconv.FormatInt(id, 10), id, p.Version, store.ContentOf(p)}) + } + sort.Slice(s.Pages, func(i, j int) bool { return s.Pages[i].SourceID < s.Pages[j].SourceID }) + s.Bundle, err = InventoryBundle(bundleRoot) + if err != nil { + return Snapshot{}, err + } + contents := []store.PageContent{} + for _, p := range s.Pages { + contents = append(contents, p.Content) + } + if err = VerifyBundle(bundleRoot, s.Bundle, contents); err != nil { + return Snapshot{}, err + } + s.Digest = s.ContentDigest() + if err = s.Validate(); err != nil { + return Snapshot{}, err + } + return s, nil +} + +// Decode rejects unknown fields, duplicate JSON keys, trailing documents and +// oversize input. Errors are fixed; rejected keys/values are never returned. +func Decode(r io.Reader) (Snapshot, error) { + b, err := io.ReadAll(io.LimitReader(r, maxSnapshotBytes+1)) + if err != nil || len(b) > maxSnapshotBytes { + return Snapshot{}, ErrInvalid + } + if !uniqueJSON(b) { + return Snapshot{}, ErrInvalid + } + var s Snapshot + d := json.NewDecoder(bytes.NewReader(b)) + d.DisallowUnknownFields() + if d.Decode(&s) != nil || d.Decode(new(any)) != io.EOF { + return Snapshot{}, ErrInvalid + } + if s.Validate() != nil { + return Snapshot{}, ErrInvalid + } + return s, nil +} + +func uniqueJSON(b []byte) bool { + d := json.NewDecoder(bytes.NewReader(b)) + d.UseNumber() + var value func() bool + value = func() bool { + t, err := d.Token() + if err != nil { + return false + } + delim, ok := t.(json.Delim) + if !ok { + return true + } + switch delim { + case '{': + keys := map[string]bool{} + for d.More() { + key, err := d.Token() + s, ok := key.(string) + if err != nil || !ok || keys[s] { + return false + } + keys[s] = true + if !value() { + return false + } + } + end, err := d.Token() + return err == nil && end == json.Delim('}') + case '[': + for d.More() { + if !value() { + return false + } + } + end, err := d.Token() + return err == nil && end == json.Delim(']') + default: + return false + } + } + return value() && func() bool { _, err := d.Token(); return err == io.EOF }() +} diff --git a/promotion/snapshot_test.go b/promotion/snapshot_test.go new file mode 100644 index 0000000..5515772 --- /dev/null +++ b/promotion/snapshot_test.go @@ -0,0 +1,384 @@ +package promotion + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-cms/store" +) + +func fixture(t *testing.T) (Snapshot, string, *store.MemoryPageStore, *store.Page) { + t.Helper() + root := t.TempDir() + write := func(p, s string) { + name := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name, []byte(s), 0600); err != nil { + t.Fatal(err) + } + } + write("assets/site.css", "body{color:#111;background:#fff}") + write("assets/photo.jpg", "owned-photo-bytes") + write("cms/templates/main.html", ``) + pages := store.NewMemoryPageStore() + p := &store.Page{Path: "/", Title: "Approved home", BodyHTML: `
Home`, Status: store.StatusPublished, TemplateID: "main"}
+ if err := pages.Create(context.Background(), 10, p); err != nil {
+ t.Fatal(err)
+ }
+ s, err := Export(context.Background(), pages, 10, "test-review", []int64{p.ID}, root, time.Date(2026, 10, 8, 18, 0, 0, 0, time.UTC))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s, root, pages, p
+}
+
+func TestSnapshotFreezeExplicitMappingAndDryRun(t *testing.T) {
+ s, root, pages, review := fixture(t)
+ b, _ := json.Marshal(s)
+ decoded, err := Decode(bytes.NewReader(b))
+ if err != nil || decoded.Digest != s.Digest {
+ t.Fatal("strict roundtrip failed")
+ }
+ for _, needle := range []string{`"tenant_id"`, `"password"`, `"domains"`, `"editors"`, `"repository"`, `"token"`} {
+ if bytes.Contains(b, []byte(needle)) {
+ t.Fatal("authority field leaked into snapshot")
+ }
+ }
+ review.Title = "Later unapproved draft"
+ if err := pages.Update(context.Background(), 10, review); err != nil {
+ t.Fatal(err)
+ }
+ if s.Pages[0].Content.Title == review.Title {
+ t.Fatal("export was not frozen")
+ }
+ target := &store.Page{Path: "/", Title: "Old live home", Status: store.StatusPublished}
+ if err := pages.Create(context.Background(), 20, target); err != nil {
+ t.Fatal(err)
+ }
+ keep := &store.Page{Path: "/keep", Title: "Keep", Status: store.StatusDraft}
+ if err := pages.Create(context.Background(), 20, keep); err != nil {
+ t.Fatal(err)
+ }
+ current, _ := pages.ReadPageState(context.Background(), 20)
+ mappings, err := SuggestedMapping(s, current)
+ if err != nil || mappings[0].TargetID != target.ID || target.ID == s.Pages[0].SourceID {
+ t.Fatal("path suggestion/mapping failed")
+ }
+ plan, err := DryRun(s, current, mappings, nil, s.Bundle)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(plan.Batch.Mutations) != 1 || len(plan.Batch.Baseline) != 2 || plan.Diffs[0].Action != "update" {
+ t.Fatal("dry run omitted baseline or updated omission")
+ }
+ before, _ := pages.Get(context.Background(), 20, target.ID)
+ if before.Title != target.Title {
+ t.Fatal("dry run wrote content")
+ }
+ receipt, err := pages.ApplyPageBatch(context.Background(), 20, plan.Batch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, _ := pages.Get(context.Background(), 20, target.ID)
+ if got.Title != s.Pages[0].Content.Title {
+ t.Fatal("apply used unapproved source edit")
+ }
+ if err := VerifyBundle(root, s.Bundle, []store.PageContent{store.ContentOf(got)}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pages.RollbackPageBatch(context.Background(), 20, receipt); err != nil {
+ t.Fatal(err)
+ }
+ move := s
+ move.Pages = append([]SnapshotPage(nil), s.Pages...)
+ move.Pages[0].Content.Path = "/moved"
+ move.Digest = move.ContentDigest()
+ if _, err := DryRun(move, current, mappings, nil, s.Bundle); !errors.Is(err, ErrMapping) {
+ t.Fatal("implicit move accepted")
+ }
+ if _, err := DryRun(s, current, nil, nil, s.Bundle); !errors.Is(err, ErrMapping) {
+ t.Fatal("implicit target mapping accepted")
+ }
+}
+
+func TestDecodeRejectsAuthorityUnknownDuplicateAndTrailingWithoutValues(t *testing.T) {
+ s, _, _, _ := fixture(t)
+ b, _ := json.Marshal(s)
+ base := string(b)
+ for _, payload := range []string{strings.Replace(base, `"schema":1`, `"schema":1,"credentials":"rejected_marker"`, 1), strings.Replace(base, `"schema":1`, `"schema":1,"schema":1`, 1), base + base, strings.Replace(base, `"title":"Approved home"`, `"title":"Approved home","domains":["rejected_marker"]`, 1), strings.Replace(base, `"size":`, `"extra":"rejected_marker","size":`, 1)} {
+ _, err := Decode(strings.NewReader(payload))
+ if err != ErrInvalid || strings.Contains(err.Error(), "rejected_marker") {
+ t.Fatal("strict decoder leaked or accepted invalid payload")
+ }
+ }
+}
+
+func TestBundleHashesReferencesStaticCollisionsAndSymlinks(t *testing.T) {
+ s, root, _, _ := fixture(t)
+ contents := []store.PageContent{s.Pages[0].Content}
+ if err := os.WriteFile(filepath.Join(root, "assets/photo.jpg"), []byte("changed"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if VerifyBundle(root, s.Bundle, contents) != ErrBundle {
+ t.Fatal("changed bundle bytes accepted")
+ }
+ manifest, _ := InventoryBundle(root)
+ for _, ref := range []string{`
`, `
`, `
`, `
`, `Review`} {
+ p := contents[0]
+ p.BodyHTML = ref
+ if VerifyBundle(root, manifest, []store.PageContent{p}) != ErrBundle {
+ t.Fatal("unsupported reference accepted")
+ }
+ }
+ for _, ref := range []string{"https://admin.gocodealone.tech/api/v1/admin/tenants/10/pages", "https://example.com/admin", "https://example.com/cms/templates/main.html", "https://example.com/%61pi/private", "https://example.com/assets/../api/private", "https://example.com/assets/%2e%2e/media/10/hash.jpg"} {
+ p := contents[0]
+ p.BodyHTML = `Private dependency`
+ if VerifyBundle(root, manifest, []store.PageContent{p}) != ErrBundle {
+ t.Fatal("absolute private path accepted")
+ }
+ }
+ p := contents[0]
+ p.BodyBlocks = json.RawMessage(`{"type":"doc","url":"\u002fmedia/10/hash.jpg"}`)
+ if VerifyBundle(root, manifest, []store.PageContent{p}) != ErrBundle {
+ t.Fatal("escaped upload reference accepted")
+ }
+ if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("Static shadow"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ manifest, _ = InventoryBundle(root)
+ if VerifyBundle(root, manifest, contents) != ErrBundle {
+ t.Fatal("static homepage shadow accepted")
+ }
+ if err := os.Remove(filepath.Join(root, "index.html")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join(root, "assets/photo.jpg"), filepath.Join(root, "assets/alias.jpg")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := InventoryBundle(root); err != ErrBundle {
+ t.Fatal("bundle symlink accepted")
+ }
+}
+
+func TestCanonicalBlockLinksUseActualRendererSemantics(t *testing.T) {
+ s, root, pages, home := fixture(t)
+ teaching := &store.Page{Path: "/teaching", Title: "Teaching", Status: store.StatusDraft, TemplateID: "main"}
+ if pages.Create(context.Background(), 10, teaching) != nil {
+ t.Fatal("teaching fixture failed")
+ }
+ links := []any{}
+ for _, href := range []string{"/teaching", "teaching", "#voice", "mailto:tina@example.com", "https://example.com/music"} {
+ links = append(links, map[string]any{"type": "link", "attrs": map[string]any{"href": href}, "content": []any{map[string]any{"type": "text", "text": "Navigation"}}})
+ }
+ links = append(links, map[string]any{"type": "text", "text": "/this is ordinary text, not an asset"})
+ home.BodyBlocks, _ = json.Marshal(map[string]any{"type": "doc", "content": links})
+ if pages.Update(context.Background(), 10, home) != nil {
+ t.Fatal("blocks update failed")
+ }
+ if _, err := Export(context.Background(), pages, 10, "test-review", []int64{home.ID, teaching.ID}, root, s.CreatedAt); err != nil {
+ t.Fatalf("canonical link export refused: %v", err)
+ }
+ for _, href := range []string{"/media/10/hash.jpg", "https://admin.gocodealone.tech/api/private", "javascript:alert(1)"} {
+ home.BodyBlocks, _ = json.Marshal(map[string]any{"type": "doc", "content": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": href}}}})
+ if pages.Update(context.Background(), 10, home) != nil {
+ t.Fatal("unsafe fixture failed")
+ }
+ if _, err := Export(context.Background(), pages, 10, "test-review", []int64{home.ID, teaching.ID}, root, s.CreatedAt); err != ErrBundle {
+ t.Fatal("canonical private reference accepted")
+ }
+ }
+}
+
+func TestBundleHTMLResourceSurfacesRefusePrivateAndNestedDocuments(t *testing.T) {
+ cases := []struct {
+ name string
+ html string
+ }{
+ {"iframe-srcdoc-upload", ``},
+ {"iframe-srcdoc-api", ``},
+ {"iframe-srcdoc-nested", ``},
+ {"iframe-srcdoc-public-unsupported", ``},
+ {"object-upload", ``},
+ {"object-api", ``},
+ {"object-bundle-unsupported", ``},
+ {"embed-admin", `