diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 429be93..4665f0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version-file: go.mod - run: go vet ./... - run: go test -race ./... diff --git a/README.md b/README.md index b773fc7..0df0c55 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,47 @@ until mirrored into site-owned storage. The `cms.engine` module exposes the strict service method `CMSEngine.AdminContribution`. Hosts such as `gocodealone-multisite` can call it -to register the CMS site manager inside the extensible admin shell. +to register the site editor inside the extensible admin shell. Site content and +platform administration use separate documents and mounts: + +```go +editor := adminui.HandlerWithOptions(adminui.Options{ + Mode: adminui.Editor, BasePath: "/admin/cms/sites", +}) +platform := adminui.HandlerWithOptions(adminui.Options{ + Mode: adminui.Platform, BasePath: "/admin/cms/platform", +}) +``` + +`adminui.Handler()` defaults to the editor at `/admin`. Protect every editor +request with the host's current identity and tenant membership, and every +platform request with current platform authority. The handlers only render UI; +they do not replace authorization. `host.Config.AdminPlatformAccess` is required +for tenant mutations, domain reads/writes and global cache reload. Those API +operations fail closed when the callback is absent, including when `AdminAPI` +is called directly. `AdminAuth` and `AdminTenantAccess` remain required for the +private page permission projection at +`GET /api/v1/admin/tenants/:tid/pages/permissions[?page_id=:pid]`. This endpoint +probes the configured callbacks without dispatching writes and returns create, +edit and delete capabilities for the specific tenant/page. UI visibility follows +that projection; write handlers still enforce authorization independently. + +Each page has Content, Appearance, Publishing, HTML and Preview routes. Content +routes select one top-level body section; plain top-level text uses a whole-body +fallback. Section switching preserves the canonical body. The editor warns +before leaving an unsaved page and freezes the captured draft during Save. +Platform tenant/domain/cache controls are absent from the editor document. The contribution requires the multisite admin scopes: - `admin:multisite.sites:read` -- `admin:multisite.sites:update` - `admin:multisite.pages:read` - `admin:multisite.pages:update` - `admin:multisite.publish:update` -- `admin:multisite.onboarding:plan` + +Platform contributions belong to the host and require its platform-specific +permission plus current platform authority. The CMS content contribution no +longer advertises tenant creation, domain controls or onboarding operations. API route metadata is returned only when the caller passes `authorized: true`. That keeps route discovery behind the host's authz check while preserving the diff --git a/adminui/adminui.go b/adminui/adminui.go index 85beff4..02800fe 100644 --- a/adminui/adminui.go +++ b/adminui/adminui.go @@ -1,34 +1,148 @@ -// Package adminui serves the minimum-viable admin web app for the -// multisite host. Embedded static assets — no JS build chain required. -// -// Per gocodealone-multisite SPEC T15. The shipped surface covers: -// -// - Tenant list + create -// - Domain list per tenant -// - Page list + create + edit + delete per tenant -// - Reload tenants cache -// -// The host is responsible for wrapping this handler with AdminAuth before -// exposing it in production. +// Package adminui provides separately rendered tenant editing and platform +// administration surfaces. The host must authorize each mount and tenant route. package adminui import ( "embed" + "html/template" "io/fs" "net/http" + "strconv" + "strings" ) //go:embed static var staticFS embed.FS -// Handler returns an http.Handler that serves the admin UI under the -// path prefix passed to http.StripPrefix. Pass "/admin" to mount under -// /admin/*. -func Handler() http.Handler { +type Mode string + +const ( + Editor Mode = "editor" + Platform Mode = "platform" +) + +type Options struct { + Mode Mode + BasePath string +} + +// Route is the canonical UI context, also used by the host's authorization gate. +type Route struct { + TenantID, PageID int64 + View string + Section int + NewPage bool +} + +// ParseRoute accepts only declared view routes. Numeric IDs must be canonical; +// assets and arbitrary suffixes are never interpreted as tenant views. +func ParseRoute(mode Mode, path string) (Route, bool) { + if path == "/" || path == "" { + return Route{View: "tenants"}, true + } + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if mode == Platform { + if path == "/tenants" || path == "/tenants/new" || path == "/cache" { + return Route{View: strings.TrimPrefix(path, "/")}, true + } + if len(parts) == 3 && parts[0] == "tenants" && parts[2] == "domains" { + id, ok := canonicalID(parts[1]) + return Route{TenantID: id, View: "domains"}, ok + } + return Route{}, false + } + if mode != Editor || len(parts) < 3 || parts[0] != "tenants" || parts[2] != "pages" { + return Route{}, false + } + tid, ok := canonicalID(parts[1]) + if !ok { + return Route{}, false + } + rt := Route{TenantID: tid, View: "pages"} + if len(parts) == 3 { + return rt, true + } + if len(parts) < 5 || len(parts) > 6 { + return Route{}, false + } + if parts[3] == "new" { + rt.NewPage = true + } else { + rt.PageID, ok = canonicalID(parts[3]) + if !ok { + return Route{}, false + } + } + switch parts[4] { + case "content", "appearance", "publishing", "html", "preview": + rt.View = parts[4] + default: + return Route{}, false + } + if len(parts) == 6 { + if rt.View != "content" { + return Route{}, false + } + n, valid := canonicalID(parts[5]) + if !valid || n > 10000 { + return Route{}, false + } + rt.Section = int(n) + } + return rt, true +} +func canonicalID(value string) (int64, bool) { + id, err := strconv.ParseInt(value, 10, 64) + return id, err == nil && id > 0 && strconv.FormatInt(id, 10) == value +} + +// Handler defaults to content editing. Platform controls require an explicit +// Platform mode mount; they are absent from the editor document entirely. +func Handler() http.Handler { return HandlerWithOptions(Options{Mode: Editor, BasePath: "/admin"}) } +func HandlerWithOptions(options Options) http.Handler { + if options.Mode == "" { + options.Mode = Editor + } + if options.Mode != Editor && options.Mode != Platform { + panic("invalid CMS UI mode") + } + options.BasePath = strings.TrimSuffix(options.BasePath, "/") + if options.BasePath == "" || !strings.HasPrefix(options.BasePath, "/") || strings.HasPrefix(options.BasePath, "//") || strings.ContainsAny(options.BasePath, "?\\#\"<>") { + panic("invalid CMS UI base path") + } sub, err := fs.Sub(staticFS, "static") if err != nil { - // Embed errors are compile-time — this branch is unreachable. panic(err) } - return http.FileServer(http.FS(sub)) + name := "index.html" + if options.Mode == Platform { + name = "platform.html" + } + raw, err := fs.ReadFile(sub, name) + if err != nil { + panic(err) + } + index := template.Must(template.New(name).Parse(string(raw))) + files := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(405) + return + } + switch r.URL.Path { + case "/admin.css", "/admin.js", "/purify.min.js": + files.ServeHTTP(w, r) + return + } + if _, ok := ParseRoute(options.Mode, r.URL.Path); !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.Method != http.MethodHead { + _ = index.Execute(w, options) + } + }) } diff --git a/adminui/adminui_test.go b/adminui/adminui_test.go index 5871c20..b26aac8 100644 --- a/adminui/adminui_test.go +++ b/adminui/adminui_test.go @@ -1,12 +1,31 @@ package adminui import ( + "crypto/sha256" + "fmt" "net/http" "net/http/httptest" "strings" "testing" ) +func TestHandler_ServesPinnedSanitizerBeforeEditor(t *testing.T) { + h := http.StripPrefix("/admin", Handler()) + index := httptest.NewRecorder() + h.ServeHTTP(index, httptest.NewRequest("GET", "/admin/", nil)) + if sanitizer, editor := strings.Index(index.Body.String(), `src="/admin/purify.min.js"`), strings.Index(index.Body.String(), `src="/admin/admin.js"`); sanitizer < 0 || editor <= sanitizer { + t.Fatal("sanitizer must load locally before editor") + } + r := httptest.NewRecorder() + h.ServeHTTP(r, httptest.NewRequest("GET", "/admin/purify.min.js", nil)) + if r.Code != http.StatusOK || !strings.Contains(r.Header().Get("Content-Type"), "javascript") { + t.Fatalf("sanitizer asset unavailable: %d %q", r.Code, r.Header().Get("Content-Type")) + } + if got := fmt.Sprintf("%x", sha256.Sum256(r.Body.Bytes())); got != "2c90a9b46d6463f26038a29b686e82bc91de01fdac9d5229e7cfe3b360134ea2" { + t.Fatalf("vendored distribution differs from reviewed release: %s", got) + } +} + func TestHandler_ServesIndex(t *testing.T) { h := http.StripPrefix("/admin", Handler()) req := httptest.NewRequest(http.MethodGet, "/admin/", nil) @@ -15,7 +34,7 @@ func TestHandler_ServesIndex(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("index: %d", rec.Code) } - if !strings.Contains(rec.Body.String(), "Multisite Admin") { + if !strings.Contains(rec.Body.String(), "Site editor") { t.Errorf("body did not contain title; got prefix %q", rec.Body.String()[:120]) } body := rec.Body.String() @@ -69,7 +88,7 @@ func TestHandler_ServesJS(t *testing.T) { "renderPreviewDocument", "sourceAuthoritative", "safeEditorURL", - `setAttribute("sandbox", "")`, + `setAttribute("sandbox", "allow-same-origin")`, } { if !strings.Contains(rec.Body.String(), want) { t.Errorf("js body missing %q", want) diff --git a/adminui/routes_test.go b/adminui/routes_test.go new file mode 100644 index 0000000..a7126bc --- /dev/null +++ b/adminui/routes_test.go @@ -0,0 +1,33 @@ +package adminui + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSurfacesAreSeparateAndDeepLinksLoadAbsoluteAssets(t *testing.T) { + editor := HandlerWithOptions(Options{Mode: Editor, BasePath: "/admin/cms/sites"}) + platform := HandlerWithOptions(Options{Mode: Platform, BasePath: "/admin/cms/platform"}) + for _, tc := range []struct { + h http.Handler + path, include, exclude string + }{{editor, "/tenants/2/pages/4/content/2", "Content sections", "new-tenant-form"}, {platform, "/tenants/2/domains", "new-domain-form", "data-rich-editor"}} { + w := httptest.NewRecorder() + tc.h.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil)) + if w.Code != 200 || !strings.Contains(w.Body.String(), tc.include) || strings.Contains(w.Body.String(), tc.exclude) { + t.Fatalf("wrong surface %s: %d", tc.path, w.Code) + } + if strings.Contains(w.Body.String(), `src="admin.js"`) { + t.Fatal("deep route has relative asset") + } + } + for _, path := range []string{"/tenants/02/pages", "/tenants/2/pages/4/content/0", "/tenants/2/domains", "/tenants/2/pages/4/preview/2", "/tenants/2/pages/4/content/2/escape"} { + w := httptest.NewRecorder() + editor.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) + if w.Code != 404 { + t.Errorf("invalid route %s: %d", path, w.Code) + } + } +} diff --git a/adminui/static/DOMPurify-LICENSE b/adminui/static/DOMPurify-LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/adminui/static/DOMPurify-LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/adminui/static/DOMPurify-NOTICE.txt b/adminui/static/DOMPurify-NOTICE.txt new file mode 100644 index 0000000..9291296 --- /dev/null +++ b/adminui/static/DOMPurify-NOTICE.txt @@ -0,0 +1,6 @@ +DOMPurify3.4.16, locally vendored from official signed release tag. +Source: https://github.com/cure53/DOMPurify/releases/tag/3.4.16 +Distribution: https://raw.githubusercontent.com/cure53/DOMPurify/3.4.16/dist/purify.min.js +SHA256: 2c90a9b46d6463f26038a29b686e82bc91de01fdac9d5229e7cfe3b360134ea2 +License: Apache2.0 OR MPL2.0; see DOMPurify-LICENSE. +No CDN or external script/runtime dependency. Update only from a reviewed release. diff --git a/adminui/static/admin.css b/adminui/static/admin.css index 33670f0..4e0a8cc 100644 --- a/adminui/static/admin.css +++ b/adminui/static/admin.css @@ -162,3 +162,43 @@ details summary { cursor: pointer; padding: 0.5rem 0; } border: 1px solid #ddd; background: white; } + +[data-event-tool] { display:grid; gap:.6rem; padding:1rem; border:1px solid #bbb; } +[data-event-tool][hidden] { display:none; } +[data-event-tool] label { display:grid; gap:.2rem; } +[data-event-tool] input { font:inherit; padding:.5rem; width:100%; } +button:focus-visible, a:focus-visible { outline:3px solid #154f9d; outline-offset:3px; } +@media(max-width:640px) { body{padding:.6rem} section{padding:.8rem} form{display:block} form label{display:block;margin:.8rem 0} form .form-row{display:block} table{font-size:.85rem} th,td{padding:.4rem} header{gap:.5rem;flex-wrap:wrap} } +/* Distinct workspaces and routed page panes. None of these view styles enter + the canonical content surface or saved HTML. */ +body { max-width: 1280px; background:#f5f6f8; color:#192638; padding:1.5rem 2rem; } +[hidden] { display:none !important; } +a { color:#1856a8; text-underline-offset:3px; } +button, a { touch-action:manipulation; } +button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible { outline:3px solid #2563b8; outline-offset:3px; } +.app-header { margin-bottom:1.5rem; } +.eyebrow { color:#4c6076; font-size:.78rem; font-weight:650; letter-spacing:.08em; text-transform:uppercase; margin:0 0 .4rem; } +h1 { font-size:1.6rem; } h2 { margin-top:0; font-size:1.45rem; } h3 { margin-top:0; } +.view { border:1px solid #dce2ea; padding:1.75rem; box-shadow:none; border-radius:12px; } +.view-heading,.editor-heading { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1.5rem; } +#save-state { color:#4c6076; margin:.4rem 0 0; font-size:.875rem; } +#page-tabs,.page-tabs { display:flex; flex-wrap:wrap; gap:.4rem; padding:.6rem 0; margin:0 0 1.5rem; border-bottom:1px solid #dce2ea; } +#page-tabs a,.page-tabs a { padding:.65rem .85rem; border-radius:6px; text-decoration:none; color:#3c526d; font-weight:550; } +#page-tabs a[aria-current],.page-tabs a[aria-current] { background:#e7eefb; color:#174985; } +#page-editor-form { display:block; margin:0; } +.settings-pane { max-width:640px; display:block; } +.settings-pane label { display:block; font-weight:550; margin:1rem 0; } +.settings-pane label input,.settings-pane label select { display:block; margin-top:.4rem; padding:.65rem; } +.content-layout { display:grid; grid-template-columns:210px minmax(0,1fr); gap:1.5rem; } +#section-tabs { display:flex; flex-direction:column; gap:.25rem; border-right:1px solid #dce2ea; padding-right:.8rem; } +#section-tabs a { padding:.65rem .75rem; border-radius:6px; text-decoration:none; line-height:1.4; overflow-wrap:anywhere; } +#section-tabs a[aria-current] { background:#e7eefb; color:#174985; font-weight:600; } +.content-editor { min-width:0; }.content-editor .form-row { display:block; }.content-editor .rich-editor { display:grid; } +.editor-surface { min-height:20rem; border-color:#bac7d7; padding:1rem; line-height:1.6; } +.editor-surface section { padding:0; box-shadow:none; border:0; margin:0; background:none; } +.editor-surface img { max-height:330px; object-fit:contain; } +[data-editor-source] { width:100%; min-height:24rem; font:13px/1.55 ui-monospace,monospace; padding:1rem; border:1px solid #bac7d7; border-radius:6px; } +[data-source-notice] { font-size:.875rem; color:#664300; background:#fff5db; padding:.8rem; border-radius:6px; } +.table-wrap { overflow-x:auto; } th,td { padding:.8rem; }.sr-only { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); } +#page-preview { width:100%; min-height:650px; border:1px solid #dce2ea; margin-top:1rem; background:white; } +@media(max-width:700px) { body { padding:1rem; }.view { padding:1rem; }.editor-heading,.view-heading { align-items:flex-start; flex-direction:column; }.content-layout { grid-template-columns:1fr; }#section-tabs { flex-direction:row; flex-wrap:wrap; border-right:0; border-bottom:1px solid #dce2ea; padding:0 0 .8rem; }#section-tabs a { font-size:.85rem; }.editor-surface { min-height:16rem; }#page-preview { min-height:550px; } } diff --git a/adminui/static/admin.js b/adminui/static/admin.js index 849fda4..526d9a9 100644 --- a/adminui/static/admin.js +++ b/adminui/static/admin.js @@ -30,248 +30,324 @@ const api = { 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); }, + 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) { + const res = await fetch(this.base + "/tenants/" + tid + "/pages/preview", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + if (!res.ok) throw new Error("Preview unavailable: " + res.status); + return res.text(); + }, reload() { return this.req("POST", "/reload"); }, }; -let currentTenant = null; +const cmsMode = document.body.dataset.cmsMode; +const cmsBase = document.body.dataset.cmsBase; +let currentTenant = null, currentRoute = null, draftKey = "", baselineDraft = ""; +let navigationGeneration = 0, historyPosition = 0, saving = false, restoringHistory = null; +let frozenDraftControls = null; +let pagePermissions = {create:false, edit:false, delete:false}; +const savedPageBodies = new WeakMap(); +const pageField = (form,name) => form.querySelector(':scope > input[data-page-field][name="'+name+'"], :scope > .settings-pane > label > [data-page-field][name="'+name+'"]'); function pagePayload(form) { syncRichEditors(form); - const fd = new FormData(form); - const payload = { - path: fd.get("path"), - title: fd.get("title"), - status: fd.get("status"), - body_html: fd.get("body_html"), - template_id: fd.get("template_id") || "", - }; - const publishAt = dateTimeLocalToISO(fd.get("publish_at")); - if (publishAt) payload.publish_at = publishAt; - const unpublishAt = dateTimeLocalToISO(fd.get("unpublish_at")); - if (unpublishAt) payload.unpublish_at = unpublishAt; + const value = name => pageField(form,name).value; + const payload = {path:value("path"),title:value("title"),status:value("status"),template_id:value("template_id")||"",body_html:$("[data-rich-editor] > [data-editor-source]",form).value,body_blocks:null}; + const saved=savedPageBodies.get(form); + if(saved&&saved.html===payload.body_html)payload.body_blocks=saved.blocks||null; + payload.publish_at=dateTimeLocalToISO(value("publish_at"))||null; + payload.unpublish_at=dateTimeLocalToISO(value("unpublish_at"))||null; return payload; } - -function dateTimeLocalToISO(value) { - if (!value) return ""; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return ""; - return d.toISOString(); -} - -function isoToDateTimeLocal(value) { - if (!value) return ""; - const d = new Date(value); - if (Number.isNaN(d.getTime())) return ""; - const pad = n => String(n).padStart(2, "0"); - return ( - d.getFullYear() + "-" + - pad(d.getMonth() + 1) + "-" + - pad(d.getDate()) + "T" + - pad(d.getHours()) + ":" + - pad(d.getMinutes()) - ); -} - -function toast(msg, kind) { - const t = $("#toast"); - t.textContent = msg; - t.className = kind || ""; - t.hidden = false; - setTimeout(() => { t.hidden = true; }, 3000); -} - -function fail(err) { - console.error(err); - toast(err.message, "error"); -} - -async function refreshTenants() { - try { - const data = await api.listTenants(); - const tbody = $("#tenants-body"); - tbody.innerHTML = ""; - for (const t of data.tenants || []) { - const tr = document.createElement("tr"); - tr.innerHTML = - "" + t.ID + "" + - "" + escapeHTML(t.Slug) + "" + - "" + escapeHTML(t.Label) + "" + - "" + escapeHTML(t.ThemeID || "") + "" + - ""; - tbody.appendChild(tr); - } - tbody.addEventListener("click", onTenantOpen, { once: true }); - } catch (e) { fail(e); } -} - -function onTenantOpen(ev) { - const btn = ev.target.closest("button[data-tenant]"); - if (!btn) return; - openTenant(parseInt(btn.dataset.tenant, 10)); -} - -async function openTenant(tid) { - try { - const list = await api.listTenants(); - const t = (list.tenants || []).find(x => x.ID === tid); - if (!t) throw new Error("tenant " + tid + " not found"); - currentTenant = t; - - $("#tenants-section").hidden = true; - $("#tenant-detail-section").hidden = false; - $("#td-slug").textContent = t.Slug; - - await refreshDomains(); - await refreshPages(); - } catch (e) { fail(e); } -} - -async function refreshDomains() { - try { - const data = await api.listDomains(currentTenant.ID); - const tbody = $("#domains-body"); - tbody.innerHTML = ""; - for (const d of data.domains || []) { - const tr = document.createElement("tr"); - tr.innerHTML = - "" + escapeHTML(d.Host) + "" + - "" + escapeHTML(d.SubsiteLabel || "(root)") + "" + - "" + escapeHTML(d.Kind) + "" + - ""; - tbody.appendChild(tr); - } - tbody.addEventListener("click", async (ev) => { - const btn = ev.target.closest("button[data-domain]"); - if (!btn) return; - if (!confirm("Delete this domain?")) return; - try { - await api.deleteDomain(currentTenant.ID, parseInt(btn.dataset.domain, 10)); - refreshDomains(); - } catch (e) { fail(e); } - }, { once: true }); - } catch (e) { fail(e); } -} - -async function refreshPages() { - try { - const data = await api.listPages(currentTenant.ID); - const tbody = $("#pages-body"); - tbody.innerHTML = ""; - for (const p of data.pages || []) { - const tr = document.createElement("tr"); - tr.innerHTML = - "" + p.ID + "" + - "" + escapeHTML(p.Path) + "" + - "" + escapeHTML(p.Title) + "" + - "" + escapeHTML(p.Status) + "" + - " " + - ""; - tbody.appendChild(tr); - } - tbody.addEventListener("click", async (ev) => { - const edit = ev.target.closest("button[data-edit-page]"); - if (edit) { - await openPageEditor(parseInt(edit.dataset.editPage, 10)); - return; - } - const btn = ev.target.closest("button[data-delete-page]"); - if (!btn || !confirm("Delete this page?")) return; - try { - await api.deletePage(currentTenant.ID, parseInt(btn.dataset.deletePage, 10)); - closePageEditor(); - refreshPages(); - } catch (e) { fail(e); } - }, { once: true }); - } catch (e) { fail(e); } +function dateTimeLocalToISO(value){if(!value)return "";const d=new Date(value);return Number.isNaN(d.getTime())?"":d.toISOString()} +function isoToDateTimeLocal(value){if(!value)return "";const d=new Date(value);if(Number.isNaN(d.getTime()))return "";const pad=n=>String(n).padStart(2,"0");return d.getFullYear()+"-"+pad(d.getMonth()+1)+"-"+pad(d.getDate())+"T"+pad(d.getHours())+":"+pad(d.getMinutes())} +function toast(msg,kind){const t=$("#toast");t.textContent=msg;t.className=kind||"";t.hidden=false;setTimeout(()=>{t.hidden=true},3500)} +function fail(err){console.error(err);toast(err.message,"error")} +function escapeHTML(s){return String(s||"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")} +function canonicalID(s){return /^[1-9][0-9]*$/.test(s)&&Number.isSafeInteger(Number(s))?Number(s):0} +function parseView(path){ + if(cmsMode==="platform"){ + if(["/","/tenants","/tenants/new","/cache"].includes(path))return{view:path==="/"?"tenants":path.slice(1)}; + const m=/^\/tenants\/([1-9][0-9]*)\/domains$/.exec(path);if(m&&canonicalID(m[1]))return{tid:Number(m[1]),view:"domains"};return null; + } + if(path==="/")return{view:"tenants"}; + const m=/^\/tenants\/([1-9][0-9]*)\/pages(?:\/([1-9][0-9]*|new)\/(content|appearance|publishing|html|preview)(?:\/([1-9][0-9]*))?)?$/.exec(path); + if(!m||!canonicalID(m[1])||(m[2]&&m[2]!=="new"&&!canonicalID(m[2]))||(m[4]&&(m[3]!=="content"||!canonicalID(m[4])||Number(m[4])>10000)))return null; + return{tid:Number(m[1]),pid:m[2]==="new"?"new":Number(m[2]||0),view:m[3]||"pages",section:Number(m[4]||0)}; } - -async function openPageEditor(pid) { - try { - const p = await api.getPage(currentTenant.ID, pid); - const form = $("#page-editor-form"); - form.elements.id.value = p.ID; - form.elements.path.value = p.Path || ""; - form.elements.title.value = p.Title || ""; - form.elements.status.value = p.Status || "draft"; - form.elements.template_id.value = p.TemplateID || ""; - form.elements.publish_at.value = isoToDateTimeLocal(p.PublishAt); - form.elements.unpublish_at.value = isoToDateTimeLocal(p.UnpublishAt); - setRichEditorHTML(form, p.BodyHTML || ""); - $("#page-preview").hidden = true; - $("#page-editor-section").hidden = false; - } catch (e) { fail(e); } +function viewPath(){const p=location.pathname.slice(cmsBase.length);return p||"/"} +function pagePath(route,pane=route.view,section=0){return "/tenants/"+route.tid+"/pages/"+route.pid+"/"+pane+(section?"/"+section:"")} +function pageKey(route){return route?.pid?route.tid+":"+route.pid:""} +function isDirty(){return !!draftKey&&JSON.stringify(pagePayload($("#page-editor-form")))!==baselineDraft} +function updateSaveState(){if(cmsMode!=="editor"||!draftKey)return;$("#save-state").textContent=saving?"Saving…":isDirty()?"Unsaved changes":"Saved"} +function mayLeave(next){if(saving){toast("Wait for Save to finish before changing views.","error");return false}return pageKey(next)===draftKey||!isDirty()||confirm("Discard unsaved changes to this page?")} +async function navigate(path,replace=false){if(restoringHistory){toast("Wait for Back navigation to finish.","error");return}const next=parseView(path);if(!next){showRouteError("This view does not exist.");return}if(!mayLeave(next))return; + if(replace)history.replaceState({cmsPosition:historyPosition},"",cmsBase+path);else{historyPosition++;history.pushState({cmsPosition:historyPosition},"",cmsBase+path)} + await applyView(next); } - -function closePageEditor() { - $("#page-editor-section").hidden = true; - $("#page-preview").hidden = true; - $("#page-editor-form").reset(); - setRichEditorHTML($("#page-editor-form"), ""); +function showView(id){$$("main > .view").forEach(el=>el.hidden=el.id!==id)} +function showRouteError(message){showView("route-error");$("#route-error-message").textContent=message;const recovery=$("#route-content");if(recovery){recovery.hidden=!draftKey;if(draftKey){recovery.dataset.route=pagePath(currentRoute,"content");recovery.href=cmsBase+recovery.dataset.route}}} +function resetEditingContext(){window.getSelection()?.removeAllRanges();$$("[data-event-tool]").forEach(el=>{el.hidden=true;$$("[data-event-field]",el).forEach(input=>input.value="");const picker=$("[data-event-picker]",el);if(picker)picker.value=""})} +function discardDraft(){draftKey="";baselineDraft="";savedPageBodies.delete($("#page-editor-form"));$("#section-view-style")?.replaceChildren();resetEditingContext()} +function routeLink(path,text){const a=document.createElement("a");a.href=cmsBase+path;a.dataset.route=path;a.textContent=text;return a} +function linkCell(path,text){const td=document.createElement("td");td.append(routeLink(path,text));return td} +async function applyView(route){ + const generation=++navigationGeneration, valid=()=>generation===navigationGeneration; + currentRoute=route;resetEditingContext(); + if(cmsMode==="editor"&&pageKey(route)&&pageKey(route)===draftKey){try{showView("page-editor-section");await showPagePane(route,generation);const focus=$("[data-page-pane]:not([hidden]) h3",$("#page-editor-form"));if(focus){focus.tabIndex=-1;focus.focus({preventScroll:true})}}catch(e){if(valid())showRouteError(e.message)}return} + if(cmsMode==="editor")discardDraft(); + showView("route-error");$("#route-error-message").textContent="Loading…"; + try{ + if(cmsMode==="platform"&&route.view==="tenants/new"){showView("new-tenant-section");return} + if(cmsMode==="platform"&&route.view==="cache"){showView("cache-section");return} + const list=await api.listTenants();if(!valid())return; + if(route.view==="tenants"){ + currentTenant=null;const rows=$("#tenants-body");rows.replaceChildren(); + for(const t of list.tenants||[]){const tr=document.createElement("tr");tr.innerHTML=""+escapeHTML(t.Label||t.Slug)+""+escapeHTML(t.Slug)+""+(cmsMode==="platform"?""+escapeHTML(t.ThemeID||"")+"":"");tr.append(linkCell("/tenants/"+t.ID+(cmsMode==="platform"?"/domains":"/pages"),cmsMode==="platform"?"Manage domains":"Edit pages"));rows.append(tr)} + showView("tenants-section");return; + } + const tenant=(list.tenants||[]).find(t=>t.ID===route.tid);if(!tenant)throw new Error("Site unavailable or access denied.");currentTenant=tenant; + if(cmsMode==="platform"){await refreshDomains(tenant.ID,generation);if(!valid())return;$("#td-slug").textContent=tenant.Label+" · "+tenant.Slug;showView("domains-section");return} + if(route.view==="pages"){ + const [data,permissions]=await Promise.all([api.listPages(tenant.ID),api.permissions(tenant.ID)]);if(!valid())return; + $("#td-slug").textContent=tenant.Slug;$("#tenant-title").textContent=(tenant.Label||tenant.Slug)+" · Pages"; + const add=$("#new-page-link");add.hidden=!permissions.create;add.href=cmsBase+"/tenants/"+tenant.ID+"/pages/new/publishing";add.dataset.route="/tenants/"+tenant.ID+"/pages/new/publishing"; + const rows=$("#pages-body");rows.replaceChildren();for(const p of data.pages||[]){const tr=document.createElement("tr");tr.innerHTML=""+escapeHTML(p.Title)+""+escapeHTML(p.Path)+""+escapeHTML(p.Status)+"";tr.append(linkCell("/tenants/"+tenant.ID+"/pages/"+p.ID+"/content","Open page"));rows.append(tr)}showView("tenant-detail-section");return; + } + const [p,permissions,templates]=await Promise.all([route.pid==="new"?Promise.resolve({Status:"draft"}):api.getPage(tenant.ID,route.pid),api.permissions(tenant.ID,route.pid==="new"?0:route.pid),api.templates(tenant.ID)]);if(!valid())return; + if(route.pid==="new"&&!permissions.create)throw new Error("Page creation is not permitted for this site."); + 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)); + $("#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; + const writable=route.pid==="new"?permissions.create:permissions.edit;$("#btn-save-page").hidden=!writable;$("#btn-delete-page").hidden=route.pid==="new"||!permissions.delete; + $("#editor-permission-note").textContent=writable?"":"You have read-only access to this page."; + $$("[data-page-field]",form).forEach(el=>el.disabled=!writable);$("[data-editor-source]",form).readOnly=!writable;$("[data-editor-surface]",form).contentEditable=String(writable);$(".editor-toolbar",form).hidden=!writable; + showView("page-editor-section");await showPagePane(route,generation);updateSaveState(); + }catch(e){if(valid())showRouteError(e.message)}finally{ + if(valid()){const focus=$("main > .view:not([hidden]) h3")||$("main > .view:not([hidden]) h2");if(focus){focus.tabIndex=-1;focus.focus({preventScroll:true})}} + } } - -function escapeHTML(s) { - return String(s || "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +async function refreshDomains(tid=currentTenant?.ID,generation=navigationGeneration){const data=await api.listDomains(tid);if(generation!==navigationGeneration||currentTenant?.ID!==tid)return;const tbody=$("#domains-body");tbody.replaceChildren();for(const d of data.domains||[]){const tr=document.createElement("tr");tr.innerHTML=""+escapeHTML(d.Host)+""+escapeHTML(d.SubsiteLabel||"(root)")+""+escapeHTML(d.Kind)+"";tbody.append(tr)}} +async function showPagePane(route,generation){ + resetEditingContext();const form=$("#page-editor-form"),editor=$("[data-rich-editor]",form),surface=$("[data-editor-surface]",editor),source=$("[data-editor-source]",editor); + syncRichEditors(form); + const plainBody=[...surface.childNodes].some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim()); + if(route.view==="content"&&route.section>(surface.children.length>1&&!plainBody?surface.children.length:1))throw new Error("This content section no longer exists. Open Content to choose a section."); + $("#section-view-style").textContent=""; + const tabs=$("#page-tabs");tabs.replaceChildren();for(const [pane,label]of Object.entries({content:"Content",appearance:"Appearance",publishing:"Publishing",html:"HTML",preview:"Preview"})){const a=routeLink(pagePath(route,pane),label);if(route.view===pane)a.setAttribute("aria-current","page");tabs.append(a)} + $$("[data-page-pane]",form).forEach(el=>el.hidden=el.dataset.pagePane!==(route.view==="html"?"content":route.view)); + if(route.view==="content"||route.view==="html"){ + const isHTML=route.view==="html",advanced=editor.dataset.advanced==="true"; + source.hidden=!isHTML;surface.hidden=isHTML;$(".editor-toolbar",editor).hidden=isHTML||advanced||!(route.pid==="new"?pagePermissions.create:pagePermissions.edit); + surface.contentEditable=String(!advanced&&(route.pid==="new"?pagePermissions.create:pagePermissions.edit)); + const sections=$("#section-tabs");sections.hidden=isHTML;sections.replaceChildren(); + const children=[...surface.children],plain=[...surface.childNodes].some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim()); + const split=children.length>1&&!plain; + if(!isHTML&&split){const selected=route.section||1;if(selected>children.length)throw new Error("This content section no longer exists. Open Content to choose a section.");children.forEach((el,i)=>{const title=el.querySelector("h1,h2,h3")?.textContent?.trim()||("Section "+(i+1));const a=routeLink(pagePath(route,"content",i+1),title);if(i+1===selected){a.setAttribute("aria-current","page");$("#section-title").textContent=title}sections.append(a)});$("#section-view-style").textContent="#page-editor-form [data-editor-surface] > :not(:nth-child("+selected+")){display:none!important}"; + }else{if(!isHTML&&route.section>1)throw new Error("This page has one content view.");sections.append(routeLink(pagePath(route,"content"),"Page content"));$("#section-title").textContent=isHTML?"Page HTML source":"Page content"} + if(advanced&&!isHTML){$("#section-title").textContent+=" · source editing required";surface.contentEditable="false"} + }else if(route.view==="preview"){await refreshPreview(generation)} } +async function refreshPreview(generation=navigationGeneration){const tid=currentTenant.ID,key=draftKey,form=$("#page-editor-form"),body=pagePayload(form),frame=$("#page-preview");frame.hidden=true;frame.setAttribute("sandbox", "allow-same-origin");frame.setAttribute("referrerpolicy","no-referrer");const html=await api.preview(tid,body);if(generation!==navigationGeneration||key!==draftKey||tid!==currentTenant?.ID)return;frame.srcdoc=html;frame.hidden=false} function initRichEditors(root = document) { $$("[data-rich-editor]", root).forEach(editor => { - const surface = $("[data-editor-surface]", editor); - const source = $("[data-editor-source]", editor); + const surface = $(":scope > [data-editor-surface]", editor); + const source = $(":scope > [data-editor-source]", editor); + const toolbar = $(":scope > .editor-toolbar", editor); + installEventTool(editor); editor.addEventListener("click", ev => { const command = ev.target.closest("[data-editor-command]"); - if (command) { + if (command && toolbar.contains(command)) { ev.preventDefault(); - surface.focus(); + if (!source.hidden) { toast("Switch to visual editing before using formatting tools.", "error"); return; } + if (editor.dataset.advanced === "true") { toast("Edit advanced markup in HTML source to preserve it.", "error"); return; } + surface.focus(); ensureVisibleSelection(surface); document.execCommand(command.dataset.editorCommand, false, command.dataset.editorValue || null); editor.dataset.sourceAuthoritative = "false"; - source.value = surface.innerHTML; + source.value = editorHTML(surface); return; } const action = ev.target.closest("[data-editor-action]"); - if (!action) return; + if (!action || !toolbar.contains(action)) return; ev.preventDefault(); + if (["link","event"].includes(action.dataset.editorAction) && (!source.hidden || editor.dataset.advanced === "true")) { toast("Switch to visual editing before using this tool.", "error"); return; } if (action.dataset.editorAction === "link") { const url = prompt("Link URL"); if (url && safeEditorURL(url)) { - surface.focus(); + surface.focus(); ensureVisibleSelection(surface); document.execCommand("createLink", false, url); editor.dataset.sourceAuthoritative = "false"; - source.value = surface.innerHTML; + source.value = editorHTML(surface); } else if (url) { toast("Unsupported link URL", "error"); } return; } if (action.dataset.editorAction === "source") { - toggleSourceMode(editor); + if (cmsMode === "editor" && currentRoute) navigate(pagePath(currentRoute,"html")).catch(fail); + else toggleSourceMode(editor); + } + if (action.dataset.editorAction === "event") { + const tool = $(":scope > [data-event-tool]", editor); + syncRichEditors(editor); + const picker = $("[data-event-picker]", tool); + picker.replaceChildren(new Option("New event", ""), ...[...activeContentSection(surface).querySelectorAll("[data-event]")].map(el => new Option(el.dataset.eventTitle || el.dataset.eventId, el.dataset.eventId))); + $$("[data-event-field]",tool).forEach(input=>input.value=""); + tool.hidden = !tool.hidden; } }); surface.addEventListener("input", () => { editor.dataset.sourceAuthoritative = "false"; - source.value = surface.innerHTML; + source.value = editorHTML(surface); + }); + surface.addEventListener("keydown", ev => { + if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "a" && currentRoute?.view === "content") { + ev.preventDefault(); const section = activeContentSection(surface); + if (section) { const range=document.createRange(); range.selectNodeContents(section); const selection=window.getSelection(); selection?.removeAllRanges(); selection?.addRange(range); } + } + }); + surface.addEventListener("beforeinput", ev => { + if (editor.dataset.advanced === "true" || surface.contentEditable !== "true") { ev.preventDefault(); return; } + ensureVisibleSelection(surface); + const context=activeContentSection(surface),selection=window.getSelection(),range=selection?.rangeCount?selection.getRangeAt(0):null; + if(context!==surface&&range?.collapsed&&ev.inputType.startsWith("delete")&&(ev.inputType.endsWith("Backward")||ev.inputType.endsWith("Forward"))){ + if(atContentBoundary(range,context,ev.inputType.endsWith("Backward")))ev.preventDefault(); + } + }); + surface.addEventListener("cut",ev=>{ + const context=activeContentSection(surface),selection=window.getSelection(),range=selection?.rangeCount?selection.getRangeAt(0):null; + if(!context||!range||!context.contains(range.startContainer)||!context.contains(range.endContainer)){ev.preventDefault();toast("Select content within the current section.","error")} + }); + surface.addEventListener("paste", ev => { + if (surface.contentEditable !== "true") { ev.preventDefault(); return; } + ev.preventDefault(); + try { + const html = ev.clipboardData?.getData("text/html"); + const fragment = html ? sanitizeEditorDOM(html) : document.createDocumentFragment(); + if (!html) fragment.append(document.createTextNode(ev.clipboardData?.getData("text/plain") || "")); + scopeEditorImages(fragment); + const selection = window.getSelection(); + let range = selection?.rangeCount ? selection.getRangeAt(0) : null; + const context=activeContentSection(surface); + if(!context)throw new Error("Choose a valid content section before pasting."); + if (!range || !context.contains(range.startContainer) || !context.contains(range.endContainer)) { + range = document.createRange(); range.selectNodeContents(context); range.collapse(false); + } + range.deleteContents(); + const last = fragment.lastChild; + range.insertNode(fragment); + if (last) range.setStartAfter(last); + range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); + editor.dataset.sourceAuthoritative = "false"; + source.value = editorHTML(surface); + updateSaveState(); + } catch (e) { toast(e.message, "error"); } + }); + surface.addEventListener("dragover", ev => ev.preventDefault()); + surface.addEventListener("drop", ev => { + ev.preventDefault(); + toast("Paste content or use the HTML source editor to add it.", "error"); }); }); } +function installEventTool(editor) { + const toolbar = $(".editor-toolbar", editor); + const button = document.createElement("button"); + button.type = "button"; button.dataset.editorAction = "event"; button.textContent = "Event"; + toolbar.append(button); + const tool = document.createElement("fieldset"); tool.dataset.eventTool = ""; tool.hidden = true; + const fields = { + id: "Stable event ID", title: "Event title", project: "Project / ensemble", persona: "Persona (classical, studio, metal)", + start: "Start (ISO with UTC offset)", end: "End (ISO with UTC offset)", timezone: "Venue IANA timezone", + venue: "Venue", city: "City", status: "Status (confirmed, postponed, cancelled)", + ticket: "Ticket URL (optional)", source: "Source URL", verified: "Last verified (YYYY-MM-DD)", + }; + tool.innerHTML = 'Calendar event

Use a confirmed source. Dates include an offset; the venue timezone is shown to visitors.

'; + Object.entries(fields).forEach(([key, label]) => { + const wrap = document.createElement("label"); wrap.textContent = label; + const input = document.createElement("input"); input.dataset.eventField = key; + wrap.append(input); tool.append(wrap); + }); + const apply = document.createElement("button"); apply.type = "button"; apply.textContent = "Apply event to page"; tool.append(apply); editor.append(tool); + $("[data-event-picker]", tool).onchange = ev => { + if (!$(":scope > [data-editor-source]", editor).hidden || editor.dataset.advanced === "true") { tool.hidden = true; return; } + syncRichEditors(editor); + const el = [...activeContentSection($(":scope > [data-editor-surface]", editor)).querySelectorAll("[data-event]")].find(el => el.dataset.eventId === ev.target.value); + $$("[data-event-field]", tool).forEach(input => { + input.value = el ? el.dataset["event" + input.dataset.eventField[0].toUpperCase() + input.dataset.eventField.slice(1)] || "" : ""; + }); + }; + apply.onclick = () => { + if (!$(":scope > [data-editor-source]", editor).hidden || editor.dataset.advanced === "true") { tool.hidden = true; toast("Switch to visual editing before using this tool.", "error"); return; } + const values = Object.fromEntries($$("[data-event-field]", tool).map(input => [input.dataset.eventField, input.value.trim()])); + try { + if (!/^[a-z0-9-]+$/.test(values.id) || !values.title || !values.project || !values.venue || !values.city) throw new Error("Complete the event identity, title, project and location."); + const validDate = validEventDate; + if (!validDate(values.start) || !validDate(values.end) || Date.parse(values.end) <= Date.parse(values.start)) throw new Error("Start and end are required: use valid ISO dates with offsets and an end after the start."); + new Intl.DateTimeFormat("en", { timeZone: values.timezone }).format(new Date()); + if (!["classical", "studio", "metal"].includes(values.persona) || !["confirmed", "postponed", "cancelled"].includes(values.status)) throw new Error("Choose a supported persona and event status."); + if (!/^https:\/\//.test(values.source) || !safeEditorURL(values.source) || (values.ticket && (!/^https:\/\//.test(values.ticket) || !safeEditorURL(values.ticket))) || !validEventDate(values.verified+"T00:00:00Z")) throw new Error("Use HTTPS source/ticket links and a real verification date."); + syncRichEditors(editor); + const source = $(":scope > [data-editor-source]", editor); + const doc = $(":scope > [data-editor-surface]", editor).cloneNode(true); + const selected = $("[data-event-picker]", tool).value; + const prior = [...doc.querySelectorAll("[data-event]")].find(el => el.dataset.eventId === selected); + if ([...doc.querySelectorAll("[data-event]")].some(el => el !== prior && el.dataset.eventId === values.id)) throw new Error("This event ID already exists."); + const article = document.createElement("article"); article.dataset.event = ""; article.className = "event-card"; + Object.entries(values).forEach(([key, value]) => { article.dataset["event" + key[0].toUpperCase() + key.slice(1)] = value; }); + const title = document.createElement("h3"); title.textContent = values.title; + const time = document.createElement("time"); time.dateTime = values.start; time.textContent = values.start + " · " + values.timezone; + const location = document.createElement("p"); location.textContent = values.project + " · " + values.venue + ", " + values.city; + const link = document.createElement("a"); link.href = values.ticket || values.source; link.textContent = values.ticket ? "Tickets & details" : "Event source"; + const verified = document.createElement("p"); verified.className = "small"; verified.textContent = "Verified " + values.verified + " · " + values.status; + article.append(title, time, location, link, verified); + const context = activeContentSection(doc); + if (!context || (prior && !context.contains(prior))) throw new Error("Choose an event in the current content section."); + if (prior) prior.replaceWith(article); else (context.querySelector("[data-event-list]") || context).append(article); + setRichEditorHTML(editor, editorHTML(doc)); tool.hidden = true; + if (currentRoute) showPagePane(currentRoute, navigationGeneration).then(updateSaveState).catch(fail); + toast("Event updated in page. Save to persist.", "ok"); + } catch (e) { toast(e.message, "error"); } + }; +} + +function validEventDate(value) { + const match=/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})$/.exec(value); + if(!match)return false; + const [year,month,day,hour,minute,second]=match.slice(1,7).map(Number); + const days=new Date(Date.UTC(year,month,0)).getUTCDate(); + if(year<1900||month<1||month>12||day<1||day>days||hour>23||minute>59||(second||0)>59)return false; + if(match[7]!=="Z"){const [offsetHour,offsetMinute]=match[7].slice(1).split(":").map(Number);if(offsetHour>14||offsetMinute>59||(offsetHour===14&&offsetMinute!==0))return false} + return Number.isFinite(Date.parse(value)); +} + function toggleSourceMode(editor) { - const surface = $("[data-editor-surface]", editor); - const source = $("[data-editor-source]", editor); + const eventTool = $(":scope > [data-event-tool]", editor); + if (eventTool) eventTool.hidden = true; + const surface = $(":scope > [data-editor-surface]", editor); + const source = $(":scope > [data-editor-source]", editor); if (source.hidden) { if (editor.dataset.sourceAuthoritative !== "true") { - source.value = surface.innerHTML; + source.value = editorHTML(surface); } surface.hidden = true; source.hidden = false; source.focus(); return; } - surface.textContent = source.value; + const safe = sanitizeEditorDOM(source.value); + if (hasAdvancedMarkup(source.value, safe)) { setSourceNotice(editor, true); return; } + surface.replaceChildren(safe); + setSourceNotice(editor, false); + scopeEditorImages(surface); editor.dataset.sourceAuthoritative = "true"; source.hidden = true; surface.hidden = false; @@ -279,34 +355,99 @@ function toggleSourceMode(editor) { } function syncRichEditors(root) { - $$("[data-rich-editor]", root).forEach(editor => { - const surface = $("[data-editor-surface]", editor); - const source = $("[data-editor-source]", editor); + richEditors(root).forEach(editor => { + const surface = $(":scope > [data-editor-surface]", editor); + const source = $(":scope > [data-editor-source]", editor); if (source.hidden) { if (editor.dataset.sourceAuthoritative !== "true") { - source.value = surface.innerHTML; + source.value = editorHTML(surface); } } else { - surface.textContent = source.value; + const safe = sanitizeEditorDOM(source.value); + const advanced = hasAdvancedMarkup(source.value, safe); + surface.replaceChildren(safe); + setSourceNotice(editor, advanced); + scopeEditorImages(surface); editor.dataset.sourceAuthoritative = "true"; } }); } function setRichEditorHTML(root, html) { - $$("[data-rich-editor]", root).forEach(editor => { - const surface = $("[data-editor-surface]", editor); - const source = $("[data-editor-source]", editor); - surface.textContent = html || ""; - surface.hidden = false; + richEditors(root).forEach(editor => { + const eventTool = $(":scope > [data-event-tool]", editor); + if (eventTool) { + eventTool.hidden = true; + $("[data-event-picker]", eventTool).replaceChildren(new Option("New event", "")); + $$("[data-event-field]", eventTool).forEach(input => { input.value = ""; }); + } + const surface = $(":scope > [data-editor-surface]", editor); + const source = $(":scope > [data-editor-source]", editor); + const safe = sanitizeEditorDOM(html || ""); + const advanced = hasAdvancedMarkup(html || "", safe); + surface.replaceChildren(safe); source.value = html || ""; - editor.dataset.sourceAuthoritative = html ? "true" : "false"; - source.hidden = true; + scopeEditorImages(surface); + editor.dataset.sourceAuthoritative = "true"; + setSourceNotice(editor, advanced); + surface.hidden = advanced; + source.hidden = !advanced; }); } +function hasAdvancedMarkup(raw, safe) { + // Conservative comparison keeps canonical source authoritative. Never parse + // unsanitized source again merely to decide which editing mode to display. + return /]/i.test(raw) || String(raw).trim() !== serializeEditorDOM(safe).trim(); +} + +function serializeEditorDOM(fragment) { + const holder = document.createElement("div"); + holder.append(fragment.cloneNode(true)); + return holder.innerHTML; +} + +function setSourceNotice(editor, advanced) { + editor.dataset.advanced = String(advanced); + let note = $(":scope > [data-source-notice]", editor); + if (!note) { note = document.createElement("p"); note.dataset.sourceNotice = ""; editor.append(note); } + note.textContent = "This page contains advanced HTML. Edit its source to preserve all markup; visual formatting is unavailable."; + note.hidden = !advanced; +} + +function richEditors(root) { + return root.matches?.("[data-rich-editor]") ? [root] : $$("[data-rich-editor]", root).filter(editor => !editor.closest("[data-editor-surface]")); +} + +function scopeEditorImages(surface) { + if (!currentTenant) return; + surface.querySelectorAll('img[src^="/assets/"]').forEach(img => { + img.src = "/api/v1/admin/tenants/" + currentTenant.ID + "/pages/assets" + img.getAttribute("src"); + }); + // Form controls are content here, not editor inputs. Keep them inert so their + // required fields cannot block Save, and strip only our marker before saving. + surface.querySelectorAll("input,select,textarea,button").forEach(control => { + if (!control.disabled) { control.disabled = true; control.dataset.cmsInert = "true"; } + }); +} + +function editorHTML(surface) { + const clone = surface.cloneNode(true); + clone.querySelectorAll('[data-cms-inert="true"]').forEach(control => { + control.removeAttribute("disabled"); control.removeAttribute("data-cms-inert"); + }); + if (currentTenant) { + const prefix = "/api/v1/admin/tenants/" + currentTenant.ID + "/pages/assets"; + clone.querySelectorAll("img[src]").forEach(img => { + if (img.getAttribute("src").startsWith(prefix + "/assets/")) img.setAttribute("src", img.getAttribute("src").slice(prefix.length)); + }); + } + return clone.innerHTML; +} + function safeEditorURL(value) { const trimmed = String(value || "").trim().toLowerCase(); + if (/[\u0000-\u0020\\]/.test(trimmed) || trimmed.startsWith("//")) return false; return trimmed.startsWith("/") || trimmed.startsWith("#") || trimmed.startsWith("https://") || @@ -315,95 +456,118 @@ function safeEditorURL(value) { trimmed.startsWith("tel:"); } -function renderPreviewDocument(form) { - syncRichEditors(form); - const title = escapeHTML(form.elements.title.value || "Untitled"); - const body = form.elements.body_html.value || ""; - return "" + title + "
" + body + "
"; -} - -// Event wiring. -document.addEventListener("DOMContentLoaded", () => { - initRichEditors(); - refreshTenants(); - - $("#new-tenant-form").addEventListener("submit", 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"); - refreshTenants(); - } catch (e) { fail(e); } +function sanitizeEditorDOM(value) { + if (!window.DOMPurify || !DOMPurify.isSupported) throw new Error("Safe editor unavailable. Reload before editing."); + // Pinned local sanitizer returns inert HTML nodes. Insert nodes directly; + // never reparse DOM text or sanitized strings through innerHTML/DOMParser. + const fragment = DOMPurify.sanitize(String(value || ""), { + USE_PROFILES: {html:true}, RETURN_DOM_FRAGMENT:true, + ADD_ATTR:["target"], + FORBID_TAGS:["script","style","link","meta","base","iframe","object","embed","svg","math","template","form","input","textarea","select","option","video","audio","source"], + FORBID_ATTR:["srcdoc","style","action","formaction","srcset","autofocus","autoplay","poster","background","ping","lowsrc","dynsrc","archive","codebase","data"], }); - - $("#new-domain-form").addEventListener("submit", async (ev) => { - ev.preventDefault(); - const fd = new FormData(ev.target); - try { - await api.createDomain(currentTenant.ID, { - host: fd.get("host"), - subsite_label: fd.get("subsite_label"), - kind: fd.get("kind"), - }); - ev.target.reset(); - toast("Domain added", "ok"); - refreshDomains(); - } catch (e) { fail(e); } - }); - - $("#new-page-form").addEventListener("submit", async (ev) => { - ev.preventDefault(); - try { - await api.createPage(currentTenant.ID, pagePayload(ev.target)); - ev.target.reset(); - setRichEditorHTML(ev.target, ""); - toast("Page created", "ok"); - refreshPages(); - } catch (e) { fail(e); } + fragment.querySelectorAll("*").forEach(el => { + [...el.attributes].forEach(attr => { + const name = attr.name.toLowerCase(); + if (name.startsWith("on") || + (name === "target" && !["_blank", "_self"].includes(attr.value)) || + (name === "href" && !safeEditorURL(attr.value)) || + (name === "src" && (el.tagName !== "IMG" || !/^\/assets\/[a-zA-Z0-9._/-]+$/.test(attr.value) || attr.value.split("/").some(part => part === "." || part === "..") || !safeEditorURL(attr.value)))) el.removeAttribute(attr.name); + }); + if (el.tagName === "A" && el.target === "_blank") el.rel = "noopener noreferrer"; }); + return fragment; +} - $("#page-editor-form").addEventListener("submit", async (ev) => { - ev.preventDefault(); - const fd = new FormData(ev.target); - try { - const pid = parseInt(fd.get("id"), 10); - await api.updatePage(currentTenant.ID, pid, pagePayload(ev.target)); - toast("Page saved", "ok"); - refreshPages(); - } catch (e) { fail(e); } - }); +function renderPreviewDocument(form) { + return api.preview(currentTenant.ID, pagePayload(form)); +} - $("#btn-page-preview").addEventListener("click", () => { - const form = $("#page-editor-form"); - const frame = $("#page-preview"); - frame.setAttribute("sandbox", ""); - frame.setAttribute("referrerpolicy", "no-referrer"); - frame.srcdoc = renderPreviewDocument(form); - frame.hidden = false; - }); +// Navigation is owned by the current surface. Paths never carry credentials. +document.addEventListener("DOMContentLoaded",()=>{ + historyPosition=Number.isInteger(history.state?.cmsPosition)?history.state.cmsPosition:0;history.replaceState({cmsPosition:historyPosition},"",location.href); + if(cmsMode==="editor"){ + initRichEditors();$("#schedule-zone").textContent="Publishing times use your browser timezone: "+Intl.DateTimeFormat().resolvedOptions().timeZone+". Saved dates use UTC."; + document.addEventListener("submit",ev=>{if(ev.target.closest("[data-editor-surface]"))ev.preventDefault()}); + document.addEventListener("input",()=>updateSaveState());document.addEventListener("click",()=>queueMicrotask(updateSaveState)); + window.addEventListener("beforeunload",ev=>{if(saving||isDirty()){ev.preventDefault();ev.returnValue=""}}); + $("#page-editor-form").onsubmit=async ev=>{ + ev.preventDefault();if(saving)return;const form=ev.target,route={...currentRoute},tid=currentTenant.ID,key=draftKey,generation=navigationGeneration; + const writable=route.pid==="new"?pagePermissions.create:pagePermissions.edit;if(!writable){toast("Page editing is not permitted.","error");return} + 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); + 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"); + 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()}}; + }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)}}; + $("#domains-body").onclick=async ev=>{const btn=ev.target.closest("button[data-domain]");if(!btn||!confirm("Delete this domain?"))return;const tid=currentTenant.ID,generation=navigationGeneration;try{await api.deleteDomain(tid,Number(btn.dataset.domain));await refreshDomains(tid,generation)}catch(e){fail(e)}}; + $("#btn-reload").onclick=async()=>{try{await api.reload();toast("Tenant cache reloaded","ok")}catch(e){fail(e)}}; + } + document.addEventListener("click",ev=>{ + const link=ev.target.closest("a[data-route]");if(!link||ev.ctrlKey||ev.metaKey||ev.shiftKey||ev.altKey||ev.button!==0)return;ev.preventDefault();navigate(link.dataset.route).catch(fail); + }); + window.addEventListener("popstate",ev=>{ + const next=parseView(viewPath()),target=Number.isInteger(ev.state?.cmsPosition)?ev.state.cmsPosition:0; + if(restoringHistory){ + const expected=restoringHistory; + if(target===expected.position&&viewPath()===expected.path){historyPosition=target;restoringHistory=null;return} + const delta=expected.position-target; + if(delta)history.go(delta);else{history.replaceState({cmsPosition:expected.position},"",cmsBase+expected.path);restoringHistory=null} + return; + } + if(!next||!mayLeave(next)){ + const path=currentRoute?currentRoutePath():"/",delta=historyPosition-target; + if(delta){restoringHistory={position:historyPosition,path};history.go(delta)}else history.replaceState({cmsPosition:historyPosition},"",cmsBase+path); + return; + } + historyPosition=target;applyView(next).catch(fail); + }); + const initial=parseView(viewPath());if(initial)applyView(initial).catch(fail);else showRouteError("This view does not exist."); +}); +function currentRoutePath(){const r=currentRoute;if(r.pid)return pagePath(r,r.view,r.section);if(r.tid)return "/tenants/"+r.tid+(cmsMode==="platform"?"/domains":"/pages");return r.view==="tenants"?"/":"/"+r.view} +function ensureVisibleSelection(surface){ + const selected=activeContentSection(surface); + if(!selected)return;const selection=window.getSelection(),range=selection?.rangeCount?selection.getRangeAt(0):null; + if(!range||!selected.contains(range.commonAncestorContainer)){const visibleRange=document.createRange();visibleRange.selectNodeContents(selected);visibleRange.collapse(false);selection?.removeAllRanges();selection?.addRange(visibleRange)} +} - $("#btn-page-editor-close").addEventListener("click", closePageEditor); +// Freeze owned draft controls during Save. The saved baseline is always the +// captured submitted snapshot, never any later visible form state. +function freezeDraft(freeze){ + const form=$("#page-editor-form"),surface=$("[data-editor-surface]",form); + if(freeze){ + const controls=$$("[data-page-field], [data-editor-source], .editor-toolbar button, [data-event-tool] input, [data-event-tool] select, [data-event-tool] button",form); + frozenDraftControls={controls:controls.map(el=>[el,el.disabled]),surface,editable:surface.contentEditable}; + controls.forEach(el=>el.disabled=true);surface.contentEditable="false";resetEditingContext(); + }else if(frozenDraftControls){ + frozenDraftControls.controls.forEach(([el,disabled])=>el.disabled=disabled);frozenDraftControls.surface.contentEditable=frozenDraftControls.editable;frozenDraftControls=null; + } +} - $("#btn-back").addEventListener("click", () => { - $("#tenant-detail-section").hidden = true; - $("#tenants-section").hidden = false; - currentTenant = null; - refreshTenants(); - }); +function activeContentSection(surface){ + const plain=[...surface.childNodes].some(n=>n.nodeType===Node.TEXT_NODE&&n.textContent.trim()); + return currentRoute?.view==="content"&&surface.children.length>1&&!plain?surface.children[(currentRoute.section||1)-1]:surface; +} - $("#btn-reload").addEventListener("click", async () => { - try { - await api.reload(); - toast("Tenant cache reloaded", "ok"); - } catch (e) { fail(e); } - }); -}); +// Walk actual DOM boundary points. Images/BRs count as content even when their +// textContent is empty; only a caret at the selected section's edge is blocked. +function atContentBoundary(range,context,backward){ + let node=range.startContainer,offset=range.startOffset; + while(node){ + const length=node.nodeType===Node.TEXT_NODE?node.data.length:node.childNodes.length; + if(offset!==(backward?0:length))return false; + if(node===context)return true; + const parent=node.parentNode;if(!parent||!context.contains(parent))return false; + const index=[...parent.childNodes].indexOf(node); + offset=backward?index:index+1;node=parent; + } + return false; +} diff --git a/adminui/static/index.html b/adminui/static/index.html index 5a9a22d..46bee4b 100644 --- a/adminui/static/index.html +++ b/adminui/static/index.html @@ -1,80 +1,20 @@ - - - - - Multisite Admin - - - -
-

Multisite Admin

- -
- -
-
-

Tenants

- - - -
IDSlugLabelTheme
- -

New tenant

-
- - - - -
-
- -
- - - - - + + +

+ + diff --git a/adminui/static/platform.html b/adminui/static/platform.html new file mode 100644 index 0000000..e626b2a --- /dev/null +++ b/adminui/static/platform.html @@ -0,0 +1,7 @@ + +Multisite administration +

Platform workspace

Multisite administration

+

Tenants

Manage site registration and domains. Page content is edited in the separate Site editor.

TenantSlugTheme
+ + +
\ No newline at end of file diff --git a/adminui/static/purify.min.js b/adminui/static/purify.min.js new file mode 100644 index 0000000..00742c4 --- /dev/null +++ b/adminui/static/purify.min.js @@ -0,0 +1,7 @@ +/*! @license DOMPurify 3.4.16 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.16/LICENSE */ +(function(e,t){typeof exports==`object`&&typeof module<`u`?module.exports=t():typeof define==`function`&&define.amd?define([],t):(e=typeof globalThis<`u`?globalThis:e||self,e.DOMPurify=t())})(this,function(){"use strict";function e(e,t){this.v=e,this.k=t}function t(e,t){(t==null||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n`u`?null:T(BigInt.prototype.toString),pe=typeof Symbol>`u`?null:T(Symbol.prototype.toString),x=T(Object.prototype.hasOwnProperty),S=T(Object.prototype.toString),C=T(RegExp.prototype.test),w=E(TypeError);function T(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);var n=[...arguments].slice(1);return g(e,t,n)}}function E(e){return function(){return te(e,[...arguments])}}function D(e,t){let n=arguments.length>2&&arguments[2]!==void 0?arguments[2]:oe;if(l&&l(e,null),!v(t))return e;let r=t.length;for(;r--;){let i=t[r];if(typeof i==`string`){let e=n(i);e!==i&&(u(t)||(t[r]=e),i=e)}e[i]=!0}return e}function me(e){for(let t=0;t/g),Ae=m(/\${[\w\W]*/g),je=m(/^data-[\-\w.\u00B7-\uFFFF]+$/),Me=m(/^aria-[\-\w]+$/),Ne=m(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),Pe=m(/^(?:\w+script|data):/i),Fe=m(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),Ie=m(/^html$/i),Le=m(/^[a-z][.\w]*(-[.\w]+)+$/i),Re=m(/<[/\w!]/g),ze=m(/<[/\w]/g),Be=m(/<\/no(script|embed|frames)/i),Ve=m(/\/>/i),A={element:1,attribute:2,text:3,cdataSection:4,entityReference:5,entityNode:6,processingInstruction:7,comment:8,document:9,documentType:10,documentFragment:11,notation:12},j=[`style`,`script`,`xmp`,`iframe`,`noembed`,`noframes`,`plaintext`,`noscript`],He=p(D({},j)),Ue=function(){let e={};return _(j,t=>{e[t]=m(RegExp(`])`,`i`))}),p(e)}(),We=function(){return typeof window>`u`?null:window},Ge=function(e,t){if(typeof e!=`object`||typeof e.createPolicy!=`function`)return null;let n=null,r=`data-tt-policy-suffix`;t&&t.hasAttribute(r)&&(n=t.getAttribute(r));let i=`dompurify`+(n?`#`+n:``);try{return e.createPolicy(i,{createHTML(e){return e},createScriptURL(e){return e}})}catch(e){return console.warn(`TrustedTypes policy `+i+` could not be created.`),null}},Ke=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},M=function(e,t,n,r){return x(e,t)&&v(e[t])?D(r.base?O(r.base):{},e[t],r.transform):n},qe=function(e,t,n){let r=x(e,t)?e[t]:void 0;return r&&typeof r==`object`?O(r):n()};function Je(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:We(),t=e=>Je(e);if(t.version=`3.4.16`,t.removed=[],!e||!e.document||e.document.nodeType!==A.document||!e.Element)return t.isSupported=!1,t;let n=e.document,r=n,i=r.currentScript;e.DocumentFragment;let a=e.HTMLTemplateElement,o=e.Node,s=e.Element,l=e.NodeFilter;e.NamedNodeMap===void 0&&(e.NamedNodeMap||e.MozNamedAttrMap),e.HTMLFormElement;let u=e.DOMParser,d=e.trustedTypes,f=s.prototype,ee=k(f,`cloneNode`),g=k(f,`remove`),te=k(f,`removeAttributeNode`),fe=k(f,`nextSibling`),y=k(f,`childNodes`),b=k(f,`parentNode`),pe=k(f,`shadowRoot`),S=k(f,`attributes`),T=o&&o.prototype?k(o.prototype,`nodeType`):null,E=o&&o.prototype?k(o.prototype,`nodeName`):null,me=o&&o.prototype?k(o.prototype,`ownerDocument`):null,j=function(e){return T?T(e):e.nodeType},Ye=function(e){return E?E(e):e.nodeName};if(typeof a==`function`){let e=n.createElement(`template`);e.content&&e.content.ownerDocument&&(n=e.content.ownerDocument)}let N,P=``,Xe,Ze=!1,Qe=0,$e=function(){if(Qe>0)throw w(`A configured TRUSTED_TYPES_POLICY callback (createHTML or createScriptURL) must not call DOMPurify.sanitize, as that causes infinite recursion. Do not pass a policy whose callbacks wrap DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted Types" section of the README.`)},F=function(e){$e(),Qe++;try{return N.createHTML(e)}finally{Qe--}},et=function(e){$e(),Qe++;try{return N.createScriptURL(e)}finally{Qe--}},tt=function(){return Ze||(Xe=Ge(d,i),Ze=!0),Xe},nt=n,rt=nt.implementation,it=nt.createNodeIterator,at=nt.createDocumentFragment,ot=nt.getElementsByTagName,st=r.importNode,I=Ke();t.isSupported=typeof c==`function`&&typeof b==`function`&&rt&&rt.createHTMLDocument!==void 0;let ct=Oe,lt=ke,ut=Ae,dt=je,ft=Me,pt=Pe,mt=Fe,ht=Le,gt=Ne,L=null,_t=D({},[..._e,...ve,...ye,...xe,...Ce]),R=null,vt=D({},[...we,...Te,...Ee,...De]),z=Object.seal(h(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),yt=null,bt=null,B=Object.seal(h(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}})),xt=!0,St=!0,Ct=!1,wt=!0,V=!1,H=!0,U=!1,Tt=!1,Et=null,Dt=null,Ot=!1,W=!1,kt=!1,At=!1,jt=!0,Mt=!1,Nt=`user-content-`,Pt=!0,Ft=!1,G={},K=null,It=D({},/* @__PURE__ */ `annotation-xml.audio.colgroup.desc.foreignobject.head.iframe.math.mi.mn.mo.ms.mtext.noembed.noframes.noscript.plaintext.script.selectedcontent.style.svg.template.thead.title.video.xmp`.split(`.`)),Lt=null,Rt=D({},[`audio`,`video`,`img`,`source`,`image`,`track`]),zt=null,Bt=D({},[`alt`,`class`,`for`,`id`,`label`,`name`,`pattern`,`placeholder`,`role`,`summary`,`title`,`value`,`style`,`xmlns`]),Vt=`http://www.w3.org/1998/Math/MathML`,Ht=`http://www.w3.org/2000/svg`,q=`http://www.w3.org/1999/xhtml`,J=q,Ut=!1,Wt=null,Gt=D({},[Vt,Ht,q],se),Kt=p([`mi`,`mo`,`mn`,`ms`,`mtext`]),qt=D({},Kt),Jt=p([`annotation-xml`]),Yt=D({},Jt),Xt=D({},[`title`,`style`,`font`,`a`,`script`]),Zt=null,Qt=[`application/xhtml+xml`,`text/html`],Y=null,$t=null,en=n.createElement(`form`),tn=function(e){return e instanceof RegExp||e instanceof Function},nn=function(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if($t&&$t===e)return;(!e||typeof e!=`object`)&&(e={}),e=O(e),Zt=Qt.indexOf(e.PARSER_MEDIA_TYPE)===-1?`text/html`:e.PARSER_MEDIA_TYPE,Y=Zt===`application/xhtml+xml`?se:oe,L=M(e,`ALLOWED_TAGS`,_t,{transform:Y}),R=M(e,`ALLOWED_ATTR`,vt,{transform:Y}),Wt=M(e,`ALLOWED_NAMESPACES`,Gt,{transform:se}),zt=M(e,`ADD_URI_SAFE_ATTR`,Bt,{transform:Y,base:Bt}),Lt=M(e,`ADD_DATA_URI_TAGS`,Rt,{transform:Y,base:Rt}),K=M(e,`FORBID_CONTENTS`,It,{transform:Y}),yt=M(e,`FORBID_TAGS`,O({}),{transform:Y}),bt=M(e,`FORBID_ATTR`,O({}),{transform:Y}),G=x(e,`USE_PROFILES`)?e.USE_PROFILES&&typeof e.USE_PROFILES==`object`?O(e.USE_PROFILES):e.USE_PROFILES:!1,xt=e.ALLOW_ARIA_ATTR!==!1,St=e.ALLOW_DATA_ATTR!==!1,Ct=e.ALLOW_UNKNOWN_PROTOCOLS||!1,wt=e.ALLOW_SELF_CLOSE_IN_ATTR!==!1,V=e.SAFE_FOR_TEMPLATES||!1,H=e.SAFE_FOR_XML!==!1,U=e.WHOLE_DOCUMENT||!1,W=e.RETURN_DOM||!1,kt=e.RETURN_DOM_FRAGMENT||!1,At=e.RETURN_TRUSTED_TYPE||!1,Ot=e.FORCE_BODY||!1,jt=e.SANITIZE_DOM!==!1,Mt=e.SANITIZE_NAMED_PROPS||!1,Pt=e.KEEP_CONTENT!==!1,Ft=e.IN_PLACE||!1,gt=ge(e.ALLOWED_URI_REGEXP)?e.ALLOWED_URI_REGEXP:Ne,J=typeof e.NAMESPACE==`string`?e.NAMESPACE:q,qt=qe(e,`MATHML_TEXT_INTEGRATION_POINTS`,()=>D({},Kt)),Yt=qe(e,`HTML_INTEGRATION_POINTS`,()=>D({},Jt));let t=qe(e,`CUSTOM_ELEMENT_HANDLING`,()=>h(null));if(z=h(null),x(t,`tagNameCheck`)&&tn(t.tagNameCheck)&&(z.tagNameCheck=t.tagNameCheck),x(t,`attributeNameCheck`)&&tn(t.attributeNameCheck)&&(z.attributeNameCheck=t.attributeNameCheck),x(t,`allowCustomizedBuiltInElements`)&&typeof t.allowCustomizedBuiltInElements==`boolean`&&(z.allowCustomizedBuiltInElements=t.allowCustomizedBuiltInElements),m(z),V&&(St=!1),kt&&(W=!0),G&&(L=D({},Ce),R=h(null),G.html===!0&&(D(L,_e),D(R,we)),G.svg===!0&&(D(L,ve),D(R,Te),D(R,De)),G.svgFilters===!0&&(D(L,ye),D(R,Te),D(R,De)),G.mathMl===!0&&(D(L,xe),D(R,Ee),D(R,De))),B.tagCheck=null,B.attributeCheck=null,x(e,`ADD_TAGS`)&&(typeof e.ADD_TAGS==`function`?B.tagCheck=e.ADD_TAGS:v(e.ADD_TAGS)&&(L===_t&&(L=O(L)),D(L,e.ADD_TAGS,Y))),x(e,`ADD_ATTR`)&&(typeof e.ADD_ATTR==`function`?B.attributeCheck=e.ADD_ATTR:v(e.ADD_ATTR)&&(R===vt&&(R=O(R)),D(R,e.ADD_ATTR,Y))),x(e,`ADD_FORBID_CONTENTS`)&&v(e.ADD_FORBID_CONTENTS)&&(K===It&&(K=O(K)),D(K,e.ADD_FORBID_CONTENTS,Y)),Pt&&(L[`#text`]=!0),U&&D(L,[`html`,`head`,`body`]),L.table&&(D(L,[`tbody`]),delete yt.tbody),e.TRUSTED_TYPES_POLICY){if(typeof e.TRUSTED_TYPES_POLICY.createHTML!=`function`)throw w(`TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.`);if(typeof e.TRUSTED_TYPES_POLICY.createScriptURL!=`function`)throw w(`TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.`);let t=N;N=e.TRUSTED_TYPES_POLICY;try{P=F(``)}catch(e){throw N=t,e}}else e.TRUSTED_TYPES_POLICY===null?(N=void 0,P=``):(N===void 0&&(N=tt()),N&&typeof P==`string`&&(P=F(``)));p&&p(e),$t=e},rn=D({},[...ve,...ye,...be]),an=D({},[...xe,...Se]),on=function(e,t,n){return t.namespaceURI===q?e===`svg`:t.namespaceURI===Vt?e===`svg`&&(n===`annotation-xml`||qt[n]):!!rn[e]},sn=function(e,t,n){return t.namespaceURI===q?e===`math`:t.namespaceURI===Ht?e===`math`&&Yt[n]:!!an[e]},cn=function(e,t,n){return t.namespaceURI===Ht&&!Yt[n]||t.namespaceURI===Vt&&!qt[n]?!1:!an[e]&&(Xt[e]||!rn[e])},ln=function(e){let t=b(e);(!t||!t.tagName)&&(t={namespaceURI:J,tagName:`template`});let n=oe(e.tagName),r=oe(t.tagName);return Wt[e.namespaceURI]?e.namespaceURI===Ht?on(n,t,r):e.namespaceURI===Vt?sn(n,t,r):e.namespaceURI===q?cn(n,t,r):!!(Zt===`application/xhtml+xml`&&Wt[e.namespaceURI]):!1},X=function(e){ie(t.removed,{element:e});try{b(e).removeChild(e)}catch(t){if(g(e),!b(e))throw w(`a node selected for removal could not be detached from its tree and cannot be safely returned; refusing to sanitize in place`)}},un=function(e,t,n){try{te(e,t)}catch(t){try{e.removeAttribute(n)}catch(e){}}},dn=function(e){pn(e);let t=y(e);if(t){let e=[];_(t,t=>{ie(e,t)}),_(e,e=>{try{g(e)}catch(e){}})}let n=S(e);if(n)for(let t=n.length-1;t>=0;--t){let r=n[t],i=r&&r.name;typeof i==`string`&&un(e,r,i)}},Z=function(e,n,r){if(!r)try{r=n.getAttributeNode(e)}catch(e){r=null}ie(t.removed,{attribute:r||null,from:n});try{r?te(n,r):n.removeAttribute(e)}catch(t){try{n.removeAttribute(e)}catch(e){}}if(e===`is`){if(W||kt)try{X(n)}catch(e){}else try{n.setAttribute(e,``)}catch(e){}}},fn=function(e){let t=S(e);if(t)for(let n=t.length-1;n>=0;--n){let r=t[n],i=r&&r.name;typeof i!=`string`||R[Y(i)]||un(e,r,i)}},pn=function(e){let t=[e];for(;t.length>0;){let e=t.pop();j(e)===A.element&&fn(e);let n=y(e);if(n)for(let e=n.length-1;e>=0;--e)t.push(n[e])}},mn=function(e,t){return H?e===`patchsrc`||e===`for`&&t!==`label`&&t!==`output`:!1},hn=function(e){if(!H)return;let t=[e];for(;t.length>0;){let e=t.pop(),n=j(e);if(n===A.processingInstruction||n===A.comment&&C(ze,e.data)){try{g(e)}catch(e){}continue}if(n===A.element){let t=e,n=Y(Ye(e));try{t.hasAttribute&&t.hasAttribute(`patchsrc`)&&t.removeAttribute(`patchsrc`),t.hasAttribute&&t.hasAttribute(`for`)&&mn(`for`,n)&&t.removeAttribute(`for`)}catch(e){}}let r=y(e);if(r)for(let e=r.length-1;e>=0;--e)t.push(r[e])}},gn=function(e){let t=null,r=null;if(Ot)e=``+e;else{let t=ce(e,/^[\r\n\t ]+/);r=t&&t[0]}Zt===`application/xhtml+xml`&&J===q&&(e=``+e+``);let i=N?F(e):e;if(J===q)try{t=new u().parseFromString(i,Zt)}catch(e){}if(!t||!t.documentElement){t=rt.createDocument(J,`template`,null);try{t.documentElement.innerHTML=Ut?P:i}catch(e){}}let a=t.body||t.documentElement;return e&&r&&a.insertBefore(n.createTextNode(r),a.childNodes[0]||null),J===q?ot.call(t,U?`html`:`body`)[0]:U?t.documentElement:a},_n=function(e){let t=me?me(e):e.ownerDocument;return it.call(t||e,e,l.SHOW_ELEMENT|l.SHOW_COMMENT|l.SHOW_TEXT|l.SHOW_PROCESSING_INSTRUCTION|l.SHOW_CDATA_SECTION,null)},vn=function(e){return e=le(e,ct,` `),e=le(e,lt,` `),e=le(e,ut,` `),e},yn=function(e){var t;e.normalize();let n=me?me(e):e.ownerDocument,r=it.call(n||e,e,l.SHOW_TEXT|l.SHOW_COMMENT|l.SHOW_CDATA_SECTION|l.SHOW_PROCESSING_INSTRUCTION,null),i=r.nextNode();for(;i;)i.data=vn(i.data),i=r.nextNode();let a=(t=e.querySelectorAll)==null?void 0:t.call(e,`template`);a&&_(a,e=>{xn(e.content)&&yn(e.content)})},bn=function(e){let t=E?E(e):null;return typeof t!=`string`||Y(t)!==`form`?!1:typeof e.nodeName!=`string`||typeof e.textContent!=`string`||typeof e.removeChild!=`function`||e.attributes!==S(e)||typeof e.removeAttribute!=`function`||typeof e.removeAttributeNode!=`function`||typeof e.getAttributeNode!=`function`||typeof e.setAttribute!=`function`||typeof e.namespaceURI!=`string`||typeof e.insertBefore!=`function`||typeof e.hasChildNodes!=`function`||e.nodeType!==T(e)||e.childNodes!==y(e)},xn=function(e){if(!T||typeof e!=`object`||!e)return!1;try{return T(e)===A.documentFragment}catch(e){return!1}},Sn=function(e){if(!T||typeof e!=`object`||!e)return!1;try{return typeof T(e)==`number`}catch(e){return!1}};function Q(e,n,r){e.length!==0&&_(e,e=>{e.call(t,n,r,$t)})}let Cn=function(e,t){return!!(H&&e.hasChildNodes()&&!Sn(e.firstElementChild)&&C(Re,e.textContent)&&C(Re,e.innerHTML)||H&&e.namespaceURI===q&&He[t]&&(Sn(e.firstElementChild)||typeof e.textContent==`string`&&C(Ue[t],e.textContent))||e.nodeType===A.processingInstruction||H&&e.nodeType===A.comment&&C(ze,e.data))},wn=function(e,t){return e instanceof RegExp?C(e,t):e instanceof Function&&!!e(t,...[...arguments].slice(2))},Tn=function(e,t,n){if(!yt[t]&&An(t)&&wn(z.tagNameCheck,t))return!1;if(Pt&&!K[t]){let t=b(e),r=y(e);if(r&&t){let i=r.length;for(let a=i-1;a>=0;--a){let i=e===n?ee(r[a],!0):r[a];t.insertBefore(i,fe(e))}}}return X(e),!0},En=function(e,t,n,r){return e.length===0?t:t===n||t===r?O(t):t},$=function(e,t){return e===t||b(e)!==null?!1:(Ft&&pn(e),!0)},Dn=function(e,n){if(Q(I.beforeSanitizeElements,e,null),$(e,n))return!0;if(bn(e))return X(e),!0;let r=Y(Ye(e));if(L=En(I.uponSanitizeElement,L,_t,Et),Q(I.uponSanitizeElement,e,{tagName:r,allowedTags:L}),$(e,n))return!0;if(Cn(e,r))return X(e),!0;if(yt[r]||!(B.tagCheck instanceof Function&&B.tagCheck(r))&&!L[r]){let t=Tn(e,r,n);return t===!1&&(Q(I.afterSanitizeElements,e,null),$(e,n))?!0:t}if(j(e)===A.element&&!ln(e)||(r===`noscript`||r===`noembed`||r===`noframes`)&&C(Be,e.innerHTML))return X(e),!0;if(V&&e.nodeType===A.text){let n=vn(e.textContent);e.textContent!==n&&(ie(t.removed,{element:e.cloneNode()}),e.textContent=n)}return Q(I.afterSanitizeElements,e,null),$(e,n)},On=function(e,t,r){if(bt[t]||mn(t,e)||jt&&(t===`id`||t===`name`)&&(r in n||r in en))return!1;let i=R[t]||B.attributeCheck instanceof Function&&B.attributeCheck(t,e);return St&&C(dt,t)||xt&&C(ft,t)?!0:i?zt[t]||C(gt,le(r,mt,``))||(t===`src`||t===`xlink:href`||t===`href`)&&e!==`script`&&ue(r,`data:`)===0&&Lt[e]||Ct&&!C(pt,le(r,mt,``))?!0:!r:An(e)&&wn(z.tagNameCheck,e)&&wn(z.attributeNameCheck,t,e)||t===`is`&&z.allowCustomizedBuiltInElements&&wn(z.tagNameCheck,r)},kn=D({},[`annotation-xml`,`color-profile`,`font-face`,`font-face-format`,`font-face-name`,`font-face-src`,`font-face-uri`,`missing-glyph`]),An=function(e){return!kn[oe(e)]&&C(ht,e)},jn=function(e,t,n,r){if(N&&typeof d==`object`&&typeof d.getAttributeType==`function`&&!n)switch(d.getAttributeType(e,t)){case`TrustedHTML`:return F(r);case`TrustedScriptURL`:return et(r)}return r},Mn=function(e,t,n,r){try{return n?e.setAttributeNS(n,t,r):e.setAttribute(t,r),!bn(e)||(X(e),!1)}catch(n){return Z(t,e),!1}},Nn=function(e,n){if(Q(I.beforeSanitizeAttributes,e,null),$(e,n))return;let r=e.attributes;if(!r||bn(e))return;R=En(I.uponSanitizeAttribute,R,vt,Dt);let i={attrName:``,attrValue:``,keepAttr:!0,allowedAttributes:R,forceKeepAttr:void 0},a=r.length,o=Y(e.nodeName);for(;a--;){let n=r[a],s=n.name,c=n.namespaceURI,l=n.value,u=Y(s),d=l,f=s===`value`?d:de(d),p=!1;if(i.attrName=u,i.attrValue=f,i.keepAttr=!0,i.forceKeepAttr=void 0,Q(I.uponSanitizeAttribute,e,i),f=i.attrValue,Mt&&(u===`id`||u===`name`)&&ue(f,Nt)!==0&&(Z(s,e,n),f=Nt+f,p=!0),H&&C(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,f)){Z(s,e,n);continue}if(u===`attributename`&&ce(f,`href`)){Z(s,e,n);continue}if(!i.forceKeepAttr){if(!i.keepAttr){Z(s,e,n);continue}if(!wt&&C(Ve,f)){Z(s,e,n);continue}if(V&&(f=vn(f)),!On(o,u,f)){Z(s,e,n);continue}f=jn(o,u,c,f),f!==d&&Mn(e,s,c,f)&&p&&re(t.removed)}}Q(I.afterSanitizeAttributes,e,null),$(e,n)},Pn=function(e){let t=null,n=_n(e);for(Q(I.beforeSanitizeShadowDOM,e,null);t=n.nextNode();)if(Q(I.uponSanitizeShadowNode,t,null),Dn(t,e),Nn(t,e),xn(t.content)&&Pn(t.content),j(t)===A.element){let e=pe(t);xn(e)&&(Fn(e),Pn(e))}Q(I.afterSanitizeShadowDOM,e,null)},Fn=function(e){let t=[{node:e,shadow:null}];for(;t.length>0;){let e=t.pop();if(e.shadow){Pn(e.shadow);continue}let n=e.node,r=j(n)===A.element,i=y(n);if(i)for(let e=i.length-1;e>=0;--e)t.push({node:i[e],shadow:null});if(r){let e=E?E(n):null;if(typeof e==`string`&&Y(e)===`template`){let e=n.content;xn(e)&&t.push({node:e,shadow:null})}}if(r){let e=pe(n);xn(e)&&t.push({node:null,shadow:e},{node:e,shadow:null})}}};return t.sanitize=function(e){let n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},i=null,a=null,o=null,s=null;if(Ut=!e,Ut&&(e=``),typeof e!=`string`&&!Sn(e)&&(e=he(e),typeof e!=`string`))throw w(`dirty is not a string, aborting`);if(!t.isSupported)return e;Tt?(L=Et,R=Dt):nn(n),(I.uponSanitizeElement.length>0||I.uponSanitizeAttribute.length>0)&&(L=O(L)),I.uponSanitizeAttribute.length>0&&(R=O(R)),t.removed=[];let c=Ft&&typeof e!=`string`&&Sn(e);if(c){hn(e);let t=Ye(e);if(typeof t==`string`){let n=Y(t);if(!L[n]||yt[n])throw dn(e),w(`root node is forbidden and cannot be sanitized in-place`)}if(bn(e))throw dn(e),w(`root node is clobbered and cannot be sanitized in-place`);try{Fn(e)}catch(t){throw dn(e),t}}else if(Sn(e))i=gn(``),a=i.ownerDocument.importNode(e,!0),a.nodeType===A.element&&a.nodeName===`BODY`||a.nodeName===`HTML`?i=a:i.appendChild(a),Fn(i);else{if(!W&&!V&&!U&&e.indexOf(`<`)===-1)return N&&At?F(e):e;if(i=gn(e),!i)return W?null:At?P:``}i&&Ot&&X(i.firstChild);let l=c?e:i;try{let e=_n(l);for(;o=e.nextNode();)Dn(o,l),Nn(o,l),xn(o.content)&&Pn(o.content)}catch(n){throw c&&(dn(e),_(t.removed,e=>{e.element&&pn(e.element)})),n}if(c){let n=!1;if(_(t.removed,t=>{t.element&&(t.element===e&&(n=!0),pn(t.element))}),n)throw w(`a node selected for removal could not be safely returned; refusing to sanitize in place`);return V&&yn(e),e}if(W){if(V&&yn(i),kt)for(s=at.call(i.ownerDocument);i.firstChild;)s.appendChild(i.firstChild);else s=i;return(R.shadowroot||R.shadowrootmode)&&(s=st.call(r,s,!0)),s}let u=U?i.outerHTML:i.innerHTML;return U&&L[`!doctype`]&&i.ownerDocument&&i.ownerDocument.doctype&&i.ownerDocument.doctype.name&&C(Ie,i.ownerDocument.doctype.name)&&(u=` +`+u),V&&(u=vn(u)),N&&At?F(u):u},t.setConfig=function(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};nn(e),Tt=!0,Et=L,Dt=R},t.clearConfig=function(){$t=null,Tt=!1,Et=null,Dt=null,N=Xe,P=``},t.isValidAttribute=function(e,t,n){$t||nn({});let r=Y(e),i=Y(t);return On(r,i,n)},t.addHook=function(e,t){typeof t==`function`&&x(I,e)&&ie(I[e],t)},t.removeHook=function(e,t){if(x(I,e)){if(t!==void 0){let n=ne(I[e],t);return n===-1?void 0:ae(I[e],n,1)[0]}return re(I[e])}},t.removeHooks=function(e){x(I,e)&&(I[e]=[])},t.removeAllHooks=function(){I=Ke()},t}return Je()}); +//# sourceMappingURL=purify.min.js.map \ No newline at end of file diff --git a/go.mod b/go.mod index 1d8fac2..3e0e109 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,13 @@ module github.com/GoCodeAlone/workflow-plugin-cms go 1.26.4 +toolchain go1.27.1 + require ( github.com/GoCodeAlone/workflow v0.80.24 github.com/jackc/pgx/v5 v5.9.2 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af ) @@ -37,7 +41,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect - golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/host/integration_test.go b/host/integration_test.go index 77771fb..645c8d4 100644 --- a/host/integration_test.go +++ b/host/integration_test.go @@ -52,6 +52,7 @@ func TestIntegration_FullMatrix(t *testing.T) { } srv := New(Config{ + AdminPlatformAccess: func(*http.Request) bool { return true }, // trusted synthetic fixture PreviewSubdomainBase: "preview.test", TenantResolverStore: resolver, MediaBackend: &media.LocalFS{Root: dir, PublicURL: "https://media.test"}, diff --git a/host/server.go b/host/server.go index 986cf85..e56a2fd 100644 --- a/host/server.go +++ b/host/server.go @@ -18,7 +18,6 @@ import ( "path/filepath" "strconv" "strings" - "sync" "time" "github.com/GoCodeAlone/workflow-plugin-cms/adminui" @@ -58,6 +57,13 @@ type Config struct { // AdminHost is set and AdminAuth is nil, admin requests fail closed. AdminAuth func(*http.Request) bool + // AdminTenantAccess adds membership enforcement after role authorization. + // Hosts with tenant editors must supply it; nil preserves legacy operator-only wiring. + AdminTenantAccess func(*http.Request, int64) bool + // AdminPlatformAccess is mandatory for tenant creation, domain reads/writes + // and global cache reload. Hosts must enforce durable platform authority. + AdminPlatformAccess func(*http.Request) bool + // OnIngest is called with the verified payload. Production wires // this to a Fetcher; tests typically pass a no-op. OnIngest func(bundle.IngestPayload) error @@ -114,17 +120,14 @@ type TenantInfo = internal.TenantInfo // Server is the assembled multisite host. type Server struct { - cfg Config - admin *internal.AdminAPI - ingest *bundle.IngestHandler - media *media.UploadHandler - metrics *requestCounters - audit *audit.Logger - adminUI http.Handler - adminRoot http.Handler - mu sync.RWMutex - cached map[string]TenantInfo // host:lookup cache for the simple resolver - cachedSlug map[string]TenantInfo + cfg Config + admin *internal.AdminAPI + ingest *bundle.IngestHandler + media *media.UploadHandler + metrics *requestCounters + audit *audit.Logger + adminUI http.Handler + adminRoot http.Handler } // New builds a Server. Fills in memory-backed defaults for nil stores. @@ -139,16 +142,17 @@ func New(cfg Config) *Server { cfg.Pages = store.NewMemoryPageStore() } - s := &Server{ - cfg: cfg, - cached: map[string]TenantInfo{}, - cachedSlug: map[string]TenantInfo{}, - } + s := &Server{cfg: cfg} // AdminAPI exposes /api/v1/admin/*. api := internal.NewAdminAPI(cfg.TenantsAdmin, cfg.Pages) api.PreviewBase = cfg.PreviewSubdomainBase api.ReloadFunc = s.flushCaches + api.TenantAccess = cfg.AdminTenantAccess + api.PlatformAccess = cfg.AdminPlatformAccess + api.RequestAccess = cfg.AdminAuth + api.ResolveTemplate = s.resolveTenantTemplate + api.ListTemplates = s.listTenantTemplates s.admin = api // IngestHandler exposes /api/v1/ingest/release. @@ -183,13 +187,9 @@ func (s *Server) EmitMetrics(_ context.Context, recorder telemetry.MetricRecorde return nil } -// flushCaches clears the in-memory tenant lookup cache. Called by the -// /api/v1/admin/reload endpoint (T31). +// flushCaches preserves the reload API. Tenant routing now reads authoritative +// store state on every request, so there is no host mapping cache to invalidate. func (s *Server) flushCaches() error { - s.mu.Lock() - defer s.mu.Unlock() - s.cached = map[string]TenantInfo{} - s.cachedSlug = map[string]TenantInfo{} return nil } @@ -232,14 +232,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Media upload route: POST /api/v1/admin/tenants/:id/upload. if strings.HasPrefix(path, "/api/v1/admin/tenants/") && strings.HasSuffix(path, "/upload") { + tid := extractTenantIDFromPath(path) + if tid <= 0 || path != "/api/v1/admin/tenants/"+strconv.FormatInt(tid, 10)+"/upload" { + http.NotFound(rec, r) + return + } if !s.authorizeAdmin(rec, r, adminHost) { return } + if s.cfg.AdminTenantAccess != nil && !s.cfg.AdminTenantAccess(r, extractTenantIDFromPath(path)) { + http.Error(rec, "forbidden", http.StatusForbidden) + return + } if s.media == nil { http.Error(rec, "media backend not configured", http.StatusServiceUnavailable) return } - tid := extractTenantIDFromPath(path) s.media.ServeForTenant(rec, r, tid) return } @@ -248,6 +256,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !s.authorizeAdmin(rec, r, adminHost) { return } + if strings.Contains(path, "/pages/assets/") { + s.servePreviewAsset(rec, r) + return + } s.admin.ServeHTTP(rec, r) return } @@ -408,7 +420,15 @@ func (s *Server) serveCMSPage(w http.ResponseWriter, r *http.Request, tenant Ten http.Error(w, "page lookup failed", http.StatusInternalServerError) return true } - html, rendered, err := internal.RenderPageDocument(p, s.pageTemplate(p.TemplateID), time.Now().UTC()) + if !internal.PagePubliclyRenderable(p, time.Now().UTC()) { + return false + } + template, err := s.resolveTenantTemplate(r.Context(), tenant.TenantID, p.TemplateID) + if err != nil { + http.Error(w, "page template unavailable", http.StatusServiceUnavailable) + return true + } + html, rendered, err := internal.RenderPageDocument(p, template, time.Now().UTC()) if err != nil { http.Error(w, "page render failed", http.StatusInternalServerError) return true @@ -447,7 +467,10 @@ func resolveBundlePath(bundleRoot, slug, urlPath string) (string, bool) { if bundleRoot == "" || slug == "" { return "", false } - tenantRoot := filepath.Join(bundleRoot, slug, "current") + tenantRoot, err := tenantBundleRoot(bundleRoot, slug) + if err != nil { + return "", false + } rel := strings.TrimPrefix(urlPath, "/") if rel == "" || strings.HasSuffix(urlPath, "/") { rel = filepath.Join(rel, "index.html") @@ -461,13 +484,24 @@ func resolveBundlePath(bundleRoot, slug, urlPath string) (string, bool) { if err != nil || strings.HasPrefix(relToRoot, "..") { return "", false } - return full, true + resolved, err := filepath.EvalSymlinks(full) + if err != nil { + return "", false + } + relToRoot, err = filepath.Rel(tenantRoot, resolved) + if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) { + return "", false + } + return resolved, true } // extractTenantIDFromPath parses /api/v1/admin/tenants/:id/upload → // :id. Returns 0 on parse failure (handler returns 400). func extractTenantIDFromPath(path string) int64 { const prefix = "/api/v1/admin/tenants/" + if !strings.HasPrefix(path, prefix) { + return 0 + } rest := strings.TrimPrefix(path, prefix) parts := strings.SplitN(rest, "/", 2) if len(parts) == 0 { @@ -490,48 +524,33 @@ func (s *Server) serveHealthz(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"status":"ok"}`)) } -// resolveTenant uses the configured TenantResolverStore (if any) + -// preview-subdomain fallback (if cfg.PreviewSubdomainBase set). -// -// Cached in-memory; flushed by /api/v1/admin/reload. +// ResolveTenant uses authoritative domain/tenant lookup. Host-policy decisions +// and public rendering share this lookup; an indefinitely cached mapping could +// serve the wrong tenant after a domain move. Resolver stores may optimize reads. +func (s *Server) ResolveTenant(ctx context.Context, host string) (TenantInfo, bool) { + return s.resolveTenant(ctx, host) +} + func (s *Server) resolveTenant(ctx context.Context, host string) (TenantInfo, bool) { host = strings.ToLower(strings.Split(host, ":")[0]) - - s.mu.RLock() - if t, ok := s.cached[host]; ok { - s.mu.RUnlock() - return t, true - } - s.mu.RUnlock() - if s.cfg.TenantResolverStore != nil { if t, ok := s.cfg.TenantResolverStore.Lookup(ctx, host); ok { - s.mu.Lock() - s.cached[host] = t - s.mu.Unlock() return t, true } } - - // Preview-subdomain fallback. if s.cfg.PreviewSubdomainBase != "" { base := strings.ToLower(strings.TrimPrefix(s.cfg.PreviewSubdomainBase, ".")) suffix := "." + base if strings.HasSuffix(host, suffix) { slug := strings.TrimSuffix(host, suffix) - if slug != "" && !strings.Contains(slug, ".") { - if s.cfg.TenantResolverStore != nil { - if t, ok := s.cfg.TenantResolverStore.LookupBySlug(ctx, slug); ok { - s.mu.Lock() - s.cachedSlug[slug] = t - s.mu.Unlock() - return t, true - } + if slug != "" && !strings.Contains(slug, ".") && s.cfg.TenantResolverStore != nil { + if t, ok := s.cfg.TenantResolverStore.LookupBySlug(ctx, slug); ok { + t.Kind = "preview" + return t, true } } } } - return TenantInfo{}, false } diff --git a/host/server_test.go b/host/server_test.go index 595976d..b174d54 100644 --- a/host/server_test.go +++ b/host/server_test.go @@ -576,7 +576,7 @@ func TestServer_AdminHostRootServesAdminUIWhenAuthorized(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("admin root with auth: got %d body=%q, want 200", rec.Code, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "Multisite Admin") { + if !strings.Contains(rec.Body.String(), "Site editor") { t.Fatalf("admin root body = %q, want admin UI", rec.Body.String()) } } @@ -630,7 +630,7 @@ func TestServer_AdminCreatedTenantResolvesStaticBundle(t *testing.T) { if err := os.Symlink(versionDir, filepath.Join(root, "acme", "current")); err != nil { t.Fatal(err) } - s := New(Config{BundleRoot: root}) + s := New(Config{AdminPlatformAccess: func(*http.Request) bool { return true }, BundleRoot: root}) rec := doReq(t, s, "POST", "/api/v1/admin/tenants", "application/json", strings.NewReader(`{"slug":"acme"}`)) if rec.Code != http.StatusCreated { @@ -657,7 +657,7 @@ func TestServer_AdminCreatedTenantResolvesStaticBundle(t *testing.T) { func TestServer_AdminMutationsRecordAuditEntries(t *testing.T) { sink := audit.NewMemorySink() - s := New(Config{ + s := New(Config{AdminPlatformAccess: func(*http.Request) bool { return true }, AuditSignKey: "test-sign-key", AuditSink: sink, AuditActor: func(r *http.Request) string { @@ -705,7 +705,7 @@ func TestServer_AdminMutationsRecordAuditEntries(t *testing.T) { func TestServer_AutoPreviewDomainRecordsAuditEntry(t *testing.T) { sink := audit.NewMemorySink() - s := New(Config{ + s := New(Config{AdminPlatformAccess: func(*http.Request) bool { return true }, PreviewSubdomainBase: "preview.example", AuditSignKey: "test-sign-key", AuditSink: sink, @@ -782,38 +782,35 @@ func TestServer_TenantResolver_DeepPreviewRejected(t *testing.T) { } } -func TestServer_AdminReload_FlushesCache(t *testing.T) { +func TestServer_DomainMoveIsVisibleWithoutReload(t *testing.T) { r := &stubResolver{ byHost: map[string]TenantInfo{ "acme.example": {TenantID: 7, TenantSlug: "acme"}, }, } - s := New(Config{TenantResolverStore: r}) + s := New(Config{AdminPlatformAccess: func(*http.Request) bool { return true }, TenantResolverStore: r}) - // Prime cache. - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Host = "acme.example" - s.ServeHTTP(httptest.NewRecorder(), req) - if _, ok := s.cached["acme.example"]; !ok { - t.Fatal("expected cache to be populated") + if tenant, ok := s.ResolveTenant(context.Background(), "acme.example"); !ok || tenant.TenantID != 7 { + t.Fatal("initial domain lookup failed") + } + r.byHost["acme.example"] = TenantInfo{TenantID: 8, TenantSlug: "beta"} + if tenant, ok := s.ResolveTenant(context.Background(), "acme.example"); !ok || tenant.TenantID != 8 { + t.Fatal("domain reassignment used stale tenant data") } // Reload. - req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/reload", nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/reload", nil) rec := httptest.NewRecorder() s.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("reload: %d", rec.Code) } - if _, ok := s.cached["acme.example"]; ok { - t.Error("expected cache to be cleared after reload") - } } func TestServer_EndToEnd_TenantCreate_PreviewAutoProvision(t *testing.T) { // Three-tenant integration: each tenant created via admin API // gets a preview subdomain auto-provisioned. Verifies T16+T32+V18. - s := New(Config{PreviewSubdomainBase: "preview.gocodealone.com"}) + s := New(Config{AdminPlatformAccess: func(*http.Request) bool { return true }, PreviewSubdomainBase: "preview.gocodealone.com"}) for _, slug := range []string{"acme", "beta", "gamma"} { body, _ := json.Marshal(map[string]string{"slug": slug}) diff --git a/host/template_preview_test.go b/host/template_preview_test.go new file mode 100644 index 0000000..790733b --- /dev/null +++ b/host/template_preview_test.go @@ -0,0 +1,93 @@ +package host + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow-plugin-cms/store" +) + +func TestTenantBundleTemplatesAndAuthenticatedPreview(t *testing.T) { + root := t.TempDir() + tenants := store.NewMemoryTenantAdminStore() + pages := store.NewMemoryPageStore() + a := &store.Tenant{Slug: "a"} + b := &store.Tenant{Slug: "b"} + for _, tenant := range []*store.Tenant{a, b} { + if err := tenants.CreateTenant(context.Background(), tenant); err != nil { + t.Fatal(err) + } + _ = tenants.CreateDomain(context.Background(), &store.Domain{TenantID: tenant.ID, Host: tenant.Slug + ".test", Kind: "preview"}) + } + dir := filepath.Join(root, "a", "current", "cms", "templates") + _ = os.MkdirAll(dir, 0700) + _ = os.WriteFile(filepath.Join(dir, "shell.html"), []byte(``), 0600) + assetDir := filepath.Join(root, "a", "current", "assets") + _ = os.MkdirAll(assetDir, 0700) + _ = os.WriteFile(filepath.Join(assetDir, "site.css"), []byte("body{color:navy}"), 0600) + _ = os.WriteFile(filepath.Join(assetDir, "attack.html"), []byte(""), 0600) + _ = os.WriteFile(filepath.Join(assetDir, "fake.png"), []byte(""), 0600) + _ = os.Symlink(filepath.Join(dir, "shell.html"), filepath.Join(assetDir, "escape.css")) + for _, tenant := range []*store.Tenant{a, b} { + _ = pages.Create(context.Background(), tenant.ID, &store.Page{Path: "/", Title: "Home", TemplateID: "shell", BodyHTML: "

Published

", Status: store.StatusPublished}) + } + s := New(Config{BundleRoot: root, TenantsAdmin: tenants, Pages: pages, AdminHost: "admin.test", AdminAuth: func(r *http.Request) bool { return r.Header.Get("Authorization") == "test" }, AdminTenantAccess: func(r *http.Request, id int64) bool { return id == a.ID }}) + req := httptest.NewRequest("GET", "https://a.test/", nil) + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "") { + t.Fatalf("template missing: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("GET", "https://b.test/", nil) + rec = httptest.NewRecorder() + s.ServeHTTP(rec, req) + if rec.Code != 503 { + t.Fatalf("tenant B reused A shell: %d", rec.Code) + } + for _, tc := range []struct { + method, path, auth string + want int + }{ + {"POST", "/api/v1/admin/tenants/1/pages/preview", "", 401}, + {"POST", "/api/v1/admin/tenants/2/pages/preview", "test", 403}, + {"POST", "/api/v1/admin/tenants/1/pages/preview", "test", 200}, + {"GET", "/api/v1/admin/tenants/1/pages/assets/assets/site.css", "test", 200}, + {"GET", "/api/v1/admin/tenants/1/pages/assets/assets/attack.html", "test", 404}, + {"GET", "/api/v1/admin/tenants/1/pages/assets/assets/fake.png", "test", 404}, + {"GET", "/api/v1/admin/tenants/1/pages/assets/assets/escape.css", "test", 404}, + } { + req = httptest.NewRequest(tc.method, "https://admin.test"+tc.path, strings.NewReader(`{"title":"Draft","path":"/new","template_id":"shell","body_html":"

Unsaved

"}`)) + req.Header.Set("Authorization", tc.auth) + rec = httptest.NewRecorder() + s.ServeHTTP(rec, req) + if rec.Code != tc.want { + t.Errorf("%s %s: %d %s", tc.method, tc.path, rec.Code, rec.Body.String()) + } + if tc.want == 200 && strings.HasSuffix(tc.path, "preview") && !strings.Contains(rec.Body.String(), "pages/assets/assets/site.css") { + t.Error("preview lacks scoped asset path") + } + } + // Bypass only the outer auth gate; the inner tenant hook must still deny. + req = httptest.NewRequest("POST", "https://admin.test/api/v1/admin/tenants/2/pages", strings.NewReader(`{"path":"/leak","title":"Leak"}`)) + rec = httptest.NewRecorder() + s.AdminAPI().ServeHTTP(rec, req) + if rec.Code != 403 { + t.Fatalf("inner membership bypass: %d", rec.Code) + } + // A current link cannot select another tenant's assets/templates, while + // normal version links within the tenant remain supported by existing tests. + _ = os.MkdirAll(filepath.Join(root, "b"), 0700) + _ = os.Symlink(filepath.Join(root, "a", "current"), filepath.Join(root, "b", "current")) + _, err := s.resolveTenantTemplate(context.Background(), b.ID, "shell") + if err == nil { + t.Fatal("tenant B current link reused A template") + } + if _, ok := resolveBundlePath(root, "b", "/assets/site.css"); ok { + t.Fatal("tenant B current link reused A public asset") + } +} diff --git a/host/templates.go b/host/templates.go new file mode 100644 index 0000000..7e856c3 --- /dev/null +++ b/host/templates.go @@ -0,0 +1,211 @@ +package host + +import ( + "context" + "errors" + "github.com/GoCodeAlone/workflow-plugin-cms/internal" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var templateIDPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) +var tenantSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +func (s *Server) resolveTenantTemplate(ctx context.Context, id int64, name string) (internal.PageTemplate, error) { + if name == "" { + return internal.PageTemplate{}, nil + } + if !templateIDPattern.MatchString(name) { + return internal.PageTemplate{}, errors.New("invalid template") + } + if s.cfg.TenantsAdmin == nil { + if value, ok := s.cfg.PageTemplates[name]; ok { + return internal.PageTemplate{ID: name, HTML: value}, nil + } + return internal.PageTemplate{}, errors.New("tenant templates unavailable") + } + tenant, err := s.cfg.TenantsAdmin.GetTenant(ctx, id) + if err != nil { + if value, ok := s.cfg.PageTemplates[name]; ok { + return internal.PageTemplate{ID: name, HTML: value}, nil + } + return internal.PageTemplate{}, err + } + root, err := tenantBundleRoot(s.cfg.BundleRoot, tenant.Slug) + var file *os.File + if err == nil { + file, err = scopedFile(root, filepath.Join("cms", "templates", name+".html")) + } + if err != nil { + // Explicit host templates remain supported for legacy hosts. + if value, ok := s.cfg.PageTemplates[name]; ok { + return internal.PageTemplate{ID: name, HTML: value}, nil + } + return internal.PageTemplate{}, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, (1<<20)+1)) + if err != nil || len(data) > 1<<20 || strings.Count(string(data), "") != 1 { + return internal.PageTemplate{}, errors.New("invalid template shell") + } + return internal.PageTemplate{ID: name, HTML: string(data)}, nil +} + +func (s *Server) listTenantTemplates(ctx context.Context, id int64) ([]string, error) { + tenant, err := s.cfg.TenantsAdmin.GetTenant(ctx, id) + if err != nil { + return nil, err + } + names := []string{} + root, rootErr := tenantBundleRoot(s.cfg.BundleRoot, tenant.Slug) + var entries []os.DirEntry + if rootErr == nil { + entries, _ = os.ReadDir(filepath.Join(root, "cms", "templates")) + } + for _, e := range entries { + name := strings.TrimSuffix(e.Name(), ".html") + if !e.IsDir() && strings.HasSuffix(e.Name(), ".html") && templateIDPattern.MatchString(name) { + if _, err := s.resolveTenantTemplate(ctx, id, name); err == nil { + names = append(names, name) + } + } + } + for name := range s.cfg.PageTemplates { + if templateIDPattern.MatchString(name) { + names = append(names, name) + } + } + sort.Strings(names) + return names, nil +} + +func scopedFile(root, rel string) (*os.File, error) { + if filepath.IsAbs(rel) || rel == "" || strings.HasPrefix(filepath.Clean(rel), "..") { + return nil, errors.New("invalid path") + } + realRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + target, err := filepath.EvalSymlinks(filepath.Join(root, rel)) + if err != nil { + return nil, err + } + inside, err := filepath.Rel(realRoot, target) + if err != nil || strings.HasPrefix(inside, "..") { + return nil, errors.New("outside tenant bundle") + } + f, err := os.Open(target) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + f.Close() + return nil, errors.New("not a regular file") + } + return f, nil +} + +// The trusted boundary is the tenant directory, not its mutable current link. +// current may point to a version within that tenant, never a sibling tenant. +func tenantBundleRoot(bundleRoot, slug string) (string, error) { + if bundleRoot == "" || !tenantSlugPattern.MatchString(slug) { + return "", errors.New("invalid tenant bundle") + } + base, err := filepath.EvalSymlinks(bundleRoot) + if err != nil { + return "", err + } + scope := filepath.Join(base, slug) + realScope, err := filepath.EvalSymlinks(scope) + if err != nil || realScope != scope { + return "", errors.New("tenant directory escapes bundle scope") + } + current, err := filepath.EvalSymlinks(filepath.Join(scope, "current")) + if err != nil { + return "", err + } + rel, err := filepath.Rel(scope, current) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("current bundle escapes tenant scope") + } + return current, nil +} + +func (s *Server) servePreviewAsset(w http.ResponseWriter, r *http.Request) { + id := extractTenantIDFromPath(r.URL.Path) + prefix := "/api/v1/admin/tenants/" + strconv.FormatInt(id, 10) + "/pages/assets/" + if id <= 0 || !strings.HasPrefix(r.URL.Path, prefix) { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + if s.cfg.AdminTenantAccess != nil && !s.cfg.AdminTenantAccess(r, id) { + http.Error(w, "forbidden", 403) + return + } + tenant, err := s.cfg.TenantsAdmin.GetTenant(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + rel := strings.TrimPrefix(r.URL.Path, prefix) + if !strings.HasPrefix(rel, "assets/") { + http.NotFound(w, r) + return + } + types := map[string]string{".css": "text/css; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf"} + typ, ok := types[strings.ToLower(filepath.Ext(rel))] + if !ok { + http.NotFound(w, r) + return + } + root, err := tenantBundleRoot(s.cfg.BundleRoot, tenant.Slug) + if err != nil { + http.NotFound(w, r) + return + } + assetRoot := filepath.Join(root, "assets") + realAssets, err := filepath.EvalSymlinks(assetRoot) + if err != nil || realAssets != assetRoot { + http.NotFound(w, r) + return + } + f, err := scopedFile(assetRoot, strings.TrimPrefix(rel, "assets/")) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, (8<<20)+1)) + if err != nil || len(data) > 8<<20 { + http.NotFound(w, r) + return + } + if strings.HasPrefix(typ, "image/") { + got, _, _ := mime.ParseMediaType(http.DetectContentType(data)) + if got != typ { + http.NotFound(w, r) + return + } + } + w.Header().Set("Content-Type", typ) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + if r.Method == http.MethodGet { + _, _ = w.Write(data) + } +} diff --git a/hostpolicy/policy.go b/hostpolicy/policy.go new file mode 100644 index 0000000..1f998ad --- /dev/null +++ b/hostpolicy/policy.go @@ -0,0 +1,391 @@ +// Package hostpolicy gates all content on resolved alternate hosts. Compose it +// outside every application wrapper; it neither provisions tenants nor grants +// CMS access. Credentials are owner supplied and stored only as bcrypt hashes. +package hostpolicy + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "golang.org/x/crypto/bcrypt" + "net" + "net/http" + "net/netip" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +type Tenant struct { + ID int64 + Slug, Kind string +} +type Policy struct { + Primary string `json:"primary"` + RedirectAliases []string `json:"redirect_aliases"` + PasswordHash string `json:"-"` +} + +// TransportMode describes the deployment's HTTPS boundary, never client input. +type TransportMode string + +const ( + TransportStrict TransportMode = "strict" + TransportAppPlatformHTTPS TransportMode = "app-platform-https" +) + +type Config struct { + AdminHost, PlatformHost string + Policies map[string]Policy + Resolve func(context.Context, string) (Tenant, bool) + TrustedProxies []netip.Prefix + Transport TransportMode +} +type success struct{ until time.Time } +type attempts struct { + count int + until time.Time +} +type Gate struct { + cfg Config + next http.Handler + mu sync.Mutex + cache map[[32]byte]success + failures map[string]attempts + kdf chan struct{} +} + +var hostPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$`) + +func New(cfg Config, next http.Handler) (*Gate, error) { + if cfg.Resolve == nil || next == nil { + return nil, errors.New("host policy requires resolver and handler") + } + switch cfg.Transport { + case "", TransportStrict: + cfg.Transport = TransportStrict + case TransportAppPlatformHTTPS: + if len(cfg.TrustedProxies) != 0 { + return nil, errors.New("managed HTTPS cannot be combined with proxy trust") + } + default: + return nil, errors.New("invalid host policy transport mode") + } + for _, prefix := range cfg.TrustedProxies { + if !prefix.IsValid() || prefix.Bits() == 0 { + return nil, errors.New("unbounded trusted proxy is forbidden") + } + } + for _, name := range []string{cfg.AdminHost, cfg.PlatformHost} { + if name == "" || normalize(name) != name { + return nil, errors.New("exact admin and platform hosts required") + } + } + if cfg.AdminHost == cfg.PlatformHost { + return nil, errors.New("admin and platform hosts must differ") + } + owners := map[string]string{} + copyPolicies := map[string]Policy{} + for slug, p := range cfg.Policies { + p.RedirectAliases = append([]string(nil), p.RedirectAliases...) + names := append([]string{}, p.RedirectAliases...) + if p.Primary != "" { + names = append(names, p.Primary) + } else if len(names) > 0 { + return nil, errors.New("review-only tenant cannot have redirect aliases") + } + var primaryID int64 + for _, name := range names { + if normalize(name) != name || name == "" || name == cfg.AdminHost || name == cfg.PlatformHost { + return nil, errors.New("invalid canonical host") + } + if _, ok := owners[name]; ok { + return nil, errors.New("duplicate canonical host") + } + owners[name] = slug + tenant, ok := cfg.Resolve(context.Background(), name) + if !ok || tenant.ID <= 0 || tenant.Slug != slug || tenant.Kind == "preview" { + return nil, errors.New("canonical domain must belong to the same tenant") + } + if primaryID == 0 { + primaryID = tenant.ID + } else if primaryID != tenant.ID { + return nil, errors.New("canonical tenant mismatch") + } + } + copyPolicies[slug] = p + } + cfg.Policies = copyPolicies + cfg.TrustedProxies = append([]netip.Prefix(nil), cfg.TrustedProxies...) + return &Gate{cfg: cfg, next: next, cache: map[[32]byte]success{}, failures: map[string]attempts{}, kdf: make(chan struct{}, 2)}, nil +} + +func normalize(raw string) string { + if strings.ContainsAny(raw, "/\\@, \t\r\n") { + return "" + } + host := raw + if value, _, err := net.SplitHostPort(raw); err == nil { + host = value + } + host = strings.ToLower(host) + if !hostPattern.MatchString(host) || strings.Contains(host, "..") || strings.HasSuffix(host, ".") { + return "" + } + return host +} + +func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request) { + host := normalize(r.Host) + if host == g.cfg.AdminHost { + g.next.ServeHTTP(w, r) + return + } + private := &privateWriter{ResponseWriter: w} + if host == g.cfg.PlatformHost { + if r.URL.Path == "/healthz" && (r.Method == "GET" || r.Method == "HEAD") { + private.headers() + g.next.ServeHTTP(private, r) + if !private.wrote { + private.headers() + } + } else { + http.NotFound(private, r) + } + return + } + tenant, ok := g.cfg.Resolve(r.Context(), host) + if host == "" || !ok || tenant.ID <= 0 { + http.NotFound(private, r) + return + } + p := g.cfg.Policies[tenant.Slug] + // Canonical configuration is validated again against the current mapping. + // Domain moves cannot turn a former canonical domain into a public bypass. + if p.Primary != "" { + canonical, ok := g.cfg.Resolve(r.Context(), p.Primary) + if !ok || canonical.ID != tenant.ID || canonical.Slug != tenant.Slug || canonical.Kind == "preview" { + http.Error(private, "host policy unavailable", 503) + return + } + if host == p.Primary { + if r.URL.Path == "/api/v1/ingest/release" { + http.NotFound(private, r) + return + } + g.next.ServeHTTP(w, r) + return + } + private.canonical = "https://" + p.Primary + safeTarget(r) + for _, alias := range p.RedirectAliases { + if host == alias && tenant.Kind != "preview" { + if r.URL.Path == "/api/v1/ingest/release" { + http.NotFound(private, r) + return + } + http.Redirect(private, r, private.canonical, http.StatusPermanentRedirect) + return + } + } + } + private.hsts = g.secure(r) + if r.URL.Path == "/api/v1/ingest/release" { + http.NotFound(private, r) + return + } + if !private.hsts { + http.Error(private, "HTTPS required", http.StatusForbidden) + return + } + hash := p.PasswordHash + cost, err := bcrypt.Cost([]byte(hash)) + if err != nil || cost < 10 || cost > 14 { + http.Error(private, "alternate host access is not configured", http.StatusServiceUnavailable) + return + } + username, password, ok := r.BasicAuth() + if !ok || username != tenant.Slug || len(password) == 0 || len(password) > 72 { + challenge(private) + return + } + key := sha256.Sum256([]byte(tenant.Slug + "\x00" + hash + "\x00" + password)) + now := time.Now() + g.mu.Lock() + cached := g.cache[key].until.After(now) + limit := g.failures[tenant.Slug] + g.mu.Unlock() + if !cached { + if limit.until.After(now) && limit.count >= 20 { + private.Header().Set("Retry-After", "60") + http.Error(private, "try again later", 429) + return + } + select { + case g.kdf <- struct{}{}: + default: + http.Error(private, "try again later", 503) + return + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + <-g.kdf + g.mu.Lock() + if err != nil { + limit = g.failures[tenant.Slug] + if !limit.until.After(now) { + limit = attempts{until: now.Add(time.Minute)} + } + limit.count++ + g.failures[tenant.Slug] = limit + } else { + if len(g.cache) >= 256 { + for k, v := range g.cache { + if !v.until.After(now) { + delete(g.cache, k) + } + } + if len(g.cache) >= 256 { + for k := range g.cache { + delete(g.cache, k) + break + } + } + } + g.cache[key] = success{until: now.Add(time.Minute)} + } + g.mu.Unlock() + if err != nil { + challenge(private) + return + } + } + safe := r.Clone(r.Context()) + safe.Header = r.Header.Clone() + safe.Header.Del("Authorization") + safe.Header.Del("Proxy-Authorization") + private.headers() + g.next.ServeHTTP(private, safe) + if !private.wrote { + private.headers() + } +} + +func (g *Gate) secure(r *http.Request) bool { + if g.cfg.Transport == TransportAppPlatformHTTPS { + // App Platform upgrades every external HTTP request before forwarding it + // to this HTTP listener. This explicit provider invariant says nothing + // about tenant authority: the protected Host still always needs Basic. + return true + } + if r.TLS != nil { + return true + } + if len(r.Header.Values("X-Forwarded-Proto")) != 1 || strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))) != "https" { + return false + } + remote, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return false + } + addr, err := netip.ParseAddr(remote) + if err != nil { + return false + } + addr = addr.Unmap() + for _, prefix := range g.cfg.TrustedProxies { + if prefix.Contains(addr) { + return true + } + } + return false +} +func safeTarget(r *http.Request) string { + query := r.URL.Query() + for key := range query { + lower := strings.ToLower(key) + if strings.Contains(lower, "token") || strings.Contains(lower, "nonce") || lower == "code" || lower == "authorization" { + query.Del(key) + } + } + path := r.URL.EscapedPath() + if !strings.HasPrefix(path, "/") { + path = "/" + } + target := path + if encoded := query.Encode(); encoded != "" { + target += "?" + encoded + } + return target +} +func challenge(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", `Basic realm="Private site review", charset="UTF-8"`) + http.Error(w, "authentication required", 401) +} + +type privateWriter struct { + http.ResponseWriter + canonical string + wrote bool + hsts bool +} + +func (w *privateWriter) headers() { + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") + if !strings.Contains(strings.ToLower(w.Header().Get("Vary")), "authorization") { + w.Header().Add("Vary", "Authorization") + } + if w.canonical != "" { + w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"canonical\"", w.canonical)) + } + w.Header().Set("X-Content-Type-Options", "nosniff") + if w.hsts { + w.Header().Set("Strict-Transport-Security", "max-age=86400") + } +} +func (w *privateWriter) WriteHeader(status int) { + if w.wrote { + return + } + w.headers() + w.wrote = true + w.ResponseWriter.WriteHeader(status) +} +func (w *privateWriter) Write(data []byte) (int, error) { + if !w.wrote { + w.WriteHeader(200) + } + return w.ResponseWriter.Write(data) +} +func (w *privateWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } +func (w *privateWriter) Flush() { + if !w.wrote { + w.WriteHeader(200) + } + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// ParseTrustedProxies accepts explicit CIDRs only; never trust forwarded host. +func ParseTrustedProxies(raw string) ([]netip.Prefix, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + out := []netip.Prefix{} + for _, item := range strings.Split(raw, ",") { + p, err := netip.ParsePrefix(strings.TrimSpace(item)) + if err != nil || p.Bits() == 0 { + return nil, errors.New("invalid trusted proxy CIDR") + } + out = append(out, p.Masked()) + } + return out, nil +} + +// CanonicalURL is intended for callers that need a safe public canonical hint. +func CanonicalURL(primary string, path *url.URL) string { + return "https://" + primary + safeTarget(&http.Request{URL: path}) +} diff --git a/hostpolicy/policy_test.go b/hostpolicy/policy_test.go new file mode 100644 index 0000000..0d8b375 --- /dev/null +++ b/hostpolicy/policy_test.go @@ -0,0 +1,274 @@ +package hostpolicy + +import ( + "context" + "crypto/tls" + "golang.org/x/crypto/bcrypt" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" +) + +func TestCompleteHostBoundary(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("fixture-only-password"), 10) + if err != nil { + t.Fatal(err) + } + hosts := map[string]Tenant{"primary.test": {1, "a", "vanity"}, "www.primary.test": {1, "a", "vanity"}, "a.preview.test": {1, "a", "preview"}, "review.test": {2, "review", "preview"}, "b.test": {3, "b", "vanity"}} + cfg := Config{AdminHost: "admin.test", PlatformHost: "platform.test", Resolve: func(_ context.Context, h string) (Tenant, bool) { v, ok := hosts[h]; return v, ok }, Policies: map[string]Policy{"a": {Primary: "primary.test", RedirectAliases: []string{"www.primary.test"}, PasswordHash: string(hash)}, "review": {PasswordHash: string(hash)}, "b": {Primary: "b.test"}}, TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/28")}} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000") + if r.Header.Get("Authorization") != "" { + t.Error("private credential reached app") + } + w.Write([]byte("content")) + }) + gate, err := New(cfg, next) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + host, path, user, password, proto, remote string + tls bool + want int + }{ + {host: "primary.test", path: "/", want: 200}, + {host: "www.primary.test", path: "/x?token=secret&nonce=n&code=c&page=2", want: 308}, + {host: "a.preview.test", path: "/", tls: true, want: 401}, + {host: "a.preview.test", path: "/assets/site.css", tls: true, want: 401}, + {host: "a.preview.test", path: "/api/v1/admin/tenants/1/pages", tls: true, want: 401}, + {host: "a.preview.test", path: "/__multisite/live-edit/exchange", tls: true, want: 401}, + {host: "a.preview.test", path: "/healthz", tls: true, want: 401}, + {host: "a.preview.test", path: "/", user: "a", password: "wrong", tls: true, want: 401}, + {host: "a.preview.test", path: "/", user: "review", password: "fixture-only-password", tls: true, want: 401}, + {host: "a.preview.test", path: "/", user: "a", password: "fixture-only-password", tls: true, want: 200}, + {host: "review.test", path: "/", user: "a", password: "fixture-only-password", tls: true, want: 401}, + {host: "review.test", path: "/", user: "review", password: "fixture-only-password", tls: true, want: 200}, + {host: "review.test", path: "/", user: "review", password: "fixture-only-password", proto: "https", remote: "198.51.100.1:1234", want: 403}, + {host: "review.test", path: "/", user: "review", password: "fixture-only-password", proto: "https", remote: "192.0.2.2:1234", want: 200}, + {host: "review.test", path: "/", proto: "https,http", remote: "192.0.2.2:1234", want: 403}, + {host: "review.test", path: "/", want: 403}, + {host: "unknown.test", path: "/", tls: true, want: 404}, + {host: "platform.test", path: "/", tls: true, want: 404}, + {host: "platform.test", path: "/healthz", want: 200}, + {host: "primary.test", path: "/api/v1/ingest/release", want: 404}, + {host: "admin.test", path: "/api/v1/ingest/release", want: 200}, + } { + r := httptest.NewRequest("GET", "http://"+tc.host+tc.path, nil) + if tc.tls { + r.TLS = &tls.ConnectionState{} + } + r.RemoteAddr = tc.remote + r.Header.Set("X-Forwarded-Proto", tc.proto) + r.Header.Set("X-Forwarded-Host", "primary.test") + if tc.user != "" { + r.SetBasicAuth(tc.user, tc.password) + } + w := httptest.NewRecorder() + gate.ServeHTTP(w, r) + if w.Code != tc.want { + t.Errorf("%s%s got %d want %d: %s", tc.host, tc.path, w.Code, tc.want, w.Body.String()) + } + if tc.host != "admin.test" && !(tc.host == "primary.test" && tc.path == "/") { + if !strings.Contains(w.Header().Get("Cache-Control"), "no-store") || !strings.Contains(w.Header().Get("X-Robots-Tag"), "noindex") || !strings.Contains(w.Header().Get("Vary"), "Authorization") { + t.Errorf("private cache/robots headers lost on %s", tc.host) + } + } + if tc.want == 308 && w.Header().Get("Location") != "https://primary.test/x?page=2" { + t.Errorf("unsafe canonical redirect: %s", w.Header().Get("Location")) + } + if tc.host == "primary.test" && tc.path == "/" && (w.Header().Get("X-Robots-Tag") != "" || strings.Contains(w.Header().Get("Cache-Control"), "no-store") || w.Header().Get("Vary") != "") { + t.Error("public primary acquired private policy headers") + } + } + hosts["primary.test"] = Tenant{3, "b", "vanity"} + w := httptest.NewRecorder() + gate.ServeHTTP(w, httptest.NewRequest("GET", "https://www.primary.test/", nil)) + if w.Code != 503 { + t.Fatal("domain reassignment remained public") + } +} + +func TestHostPolicyRejectsCrossTenantCanonicalAndUnboundedProxy(t *testing.T) { + cfg := Config{AdminHost: "admin.test", PlatformHost: "platform.test", Resolve: func(_ context.Context, h string) (Tenant, bool) { return Tenant{2, "other", "vanity"}, true }, Policies: map[string]Policy{"a": {Primary: "other.test"}}} + if _, err := New(cfg, http.NotFoundHandler()); err == nil { + t.Fatal("cross-tenant primary accepted") + } + cfg.Policies = nil + cfg.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")} + if _, err := New(cfg, http.NotFoundHandler()); err == nil { + t.Fatal("unbounded proxy accepted") + } +} + +func TestMissingHashFailsClosedAndFailureChecksAreBounded(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("fixture"), 10) + cfg := Config{AdminHost: "admin.test", PlatformHost: "platform.test", Resolve: func(_ context.Context, h string) (Tenant, bool) { return Tenant{1, "a", "preview"}, true }, Policies: map[string]Policy{"a": {PasswordHash: string(hash)}}} + gate, err := New(cfg, http.NotFoundHandler()) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 21; i++ { + r := httptest.NewRequest("GET", "https://a.preview.test/", nil) + r.SetBasicAuth("a", "wrong") + w := httptest.NewRecorder() + gate.ServeHTTP(w, r) + want := 401 + if i == 20 { + want = 429 + } + if w.Code != want { + t.Fatalf("attempt %d = %d", i, w.Code) + } + } + cfg.Policies = map[string]Policy{"a": {PasswordHash: "invalid"}} + gate, _ = New(cfg, http.NotFoundHandler()) + w := httptest.NewRecorder() + gate.ServeHTTP(w, httptest.NewRequest("GET", "https://a.preview.test/", nil)) + if w.Code != 503 { + t.Fatal("malformed hash did not fail closed") + } +} + +func TestAppPlatformModeKeepsAuthenticationIndependentOfForwardingHeaders(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("review-fixture-only"), 10) + if err != nil { + t.Fatal(err) + } + hosts := map[string]Tenant{ + "primary.test": {1, "live", "vanity"}, "www.primary.test": {1, "live", "vanity"}, + "review.test": {2, "review", "preview"}, "unconfigured.test": {3, "unconfigured", "preview"}, + } + called := 0 + gate, err := New(Config{AdminHost: "admin.test", PlatformHost: "platform.test", Transport: TransportAppPlatformHTTPS, + Resolve: func(_ context.Context, h string) (Tenant, bool) { v, ok := hosts[h]; return v, ok }, + Policies: map[string]Policy{"live": {Primary: "primary.test", RedirectAliases: []string{"www.primary.test"}}, "review": {PasswordHash: string(hash)}}, + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called++ + if r.Header.Get("Authorization") != "" || r.Header.Get("Proxy-Authorization") != "" { + t.Error("sensitive credential reached inner app") + } + w.Header().Set("Cache-Control", "public, max-age=31536000") + w.Header().Set("X-Robots-Tag", "index") + if normalize(r.Host) == "review.test" { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + w.Write([]byte("review-only-content")) + } else { + w.Write([]byte("public-or-health-content")) + } + })) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"/", "/assets/portrait.jpg", "/media/recording", "/api/v1/admin/tenants/2/pages", "/__multisite/live-edit/exchange", "/healthz"} { + for _, proto := range []string{"", "http", "https", "https,http", "invalid"} { + // Successful authentication first populates the verifier cache. An anonymous + // request for the identical URL must never reach the app afterwards. + for _, authorized := range []bool{true, false} { + r := httptest.NewRequest("GET", "http://REVIEW.test:443"+path, nil) + r.RemoteAddr = "198.51.100.99:3333" + if proto != "" { + r.Header.Set("X-Forwarded-Proto", proto) + r.Header.Add("X-Forwarded-Proto", "client-second-value") + r.Header.Set("X-Forwarded-Host", "primary.test") + r.Header.Set("Forwarded", "host=primary.test;proto=https") + } + r.Header.Set("Proxy-Authorization", "Basic fixture-proxy-credential") + if authorized { + r.SetBasicAuth("review", "review-fixture-only") + } + before := called + w := httptest.NewRecorder() + gate.ServeHTTP(w, r) + if authorized { + if w.Code != 200 || w.Body.String() != "review-only-content" || called != before+1 { + t.Fatalf("authenticated %s proto=%q: %d %s", path, proto, w.Code, w.Body.String()) + } + } else if w.Code != 401 || called != before || strings.Contains(w.Body.String(), "review-only-content") { + t.Fatalf("anonymous after auth %s escaped: %d", path, w.Code) + } + if w.Header().Get("Cache-Control") != "private, no-store" || w.Header().Get("X-Robots-Tag") != "noindex, nofollow, noarchive" || w.Header().Get("Strict-Transport-Security") != "max-age=86400" || !strings.Contains(w.Header().Get("Vary"), "Authorization") { + t.Fatalf("protected headers lost: %#v", w.Header()) + } + } + } + } + for _, tc := range []struct { + host, path, user, password string + want int + body string + }{ + {"review.test", "/", "live", "review-fixture-only", 401, ""}, + {"review.test", "/", "review", "wrong", 401, ""}, + {"review.test", "/api/v1/ingest/release", "", "", 404, ""}, + {"unconfigured.test", "/", "review", "review-fixture-only", 503, ""}, + {"unknown.test", "/", "review", "review-fixture-only", 404, ""}, + {"platform.test", "/", "review", "review-fixture-only", 404, ""}, + {"platform.test", "/healthz", "", "", 200, "public-or-health-content"}, + {"primary.test", "/assets/review-secret", "", "", 200, "public-or-health-content"}, + {"www.primary.test", "/", "", "", 308, ""}, + } { + r := httptest.NewRequest("GET", "http://"+tc.host+tc.path, nil) + r.Header.Set("X-Forwarded-Host", "review.test") + r.Header.Set("Forwarded", "host=review.test;proto=https") + r.Header.Set("X-Forwarded-Proto", "https") + if tc.user != "" { + r.SetBasicAuth(tc.user, tc.password) + } + w := httptest.NewRecorder() + gate.ServeHTTP(w, r) + if w.Code != tc.want || tc.body != "" && w.Body.String() != tc.body || tc.want != 200 && strings.Contains(w.Body.String(), "review-only-content") { + t.Errorf("Host %s forwarding selected authority: %d %s", tc.host, w.Code, w.Body.String()) + } + if tc.host == "primary.test" || tc.host == "www.primary.test" || tc.host == "platform.test" { + if w.Header().Get("Strict-Transport-Security") != "" { + t.Error("HSTS leaked outside protected host") + } + } + if tc.host == "review.test" && w.Header().Get("Strict-Transport-Security") != "max-age=86400" { + t.Error("protected error lost HSTS") + } + } +} + +func TestTransportConfigurationFailsClosed(t *testing.T) { + cfg := Config{AdminHost: "admin.test", PlatformHost: "platform.test", Resolve: func(context.Context, string) (Tenant, bool) { return Tenant{1, "review", "preview"}, true }} + for _, mode := range []TransportMode{"auto", "app-platform", "APP-PLATFORM-HTTPS"} { + cfg.Transport = mode + if _, err := New(cfg, http.NotFoundHandler()); err == nil { + t.Fatalf("invalid mode %q accepted", mode) + } + } + cfg.Transport = TransportAppPlatformHTTPS + cfg.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")} + if _, err := New(cfg, http.NotFoundHandler()); err == nil { + t.Fatal("managed mode combined with forwarding trust") + } + cfg.Transport = "" + cfg.TrustedProxies = nil + gate, err := New(cfg, http.NotFoundHandler()) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("GET", "http://review.test/", nil) + r.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + gate.ServeHTTP(w, r) + if w.Code != 403 || w.Header().Get("WWW-Authenticate") != "" { + t.Fatal("default strict mode accepted client TLS assertion or challenged on HTTP") + } + cfg.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("192.0.2.1/32")} + gate, err = New(cfg, http.NotFoundHandler()) + if err != nil { + t.Fatal(err) + } + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Add("X-Forwarded-Proto", "http") + w = httptest.NewRecorder() + gate.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal("strict mode accepted repeated forwarding values") + } +} diff --git a/internal/admin_contribution.go b/internal/admin_contribution.go index 4b6b0de..0fcdd3e 100644 --- a/internal/admin_contribution.go +++ b/internal/admin_contribution.go @@ -18,23 +18,20 @@ type AdminContribution struct { func CMSAdminContribution() AdminContribution { return AdminContribution{ ID: "cms-site-manager", - Title: "Sites", + Title: "Site editor", Category: "content", Path: "/admin/cms/sites", RenderMode: "iframe", AppContext: "multisite", Permissions: []string{ "admin:multisite.sites:read", - "admin:multisite.sites:update", "admin:multisite.pages:read", "admin:multisite.pages:update", "admin:multisite.publish:update", - "admin:multisite.onboarding:plan", }, Metadata: CMSAdminContributionMetadata(true), Actions: []string{ "site.list", - "domain.list", "page.list", "template.list", "overlay.list", @@ -51,7 +48,6 @@ func CMSAdminContributionMetadata(authorized bool) map[string]string { } return map[string]string{ "sites_path": "/api/v1/admin/tenants", - "domains_path": "/api/v1/admin/tenants/{tenant_id}/domains", "pages_path": "/api/v1/admin/tenants/{tenant_id}/pages", "templates_path": "/api/v1/admin/tenants/{tenant_id}/templates", "overlays_path": "/api/v1/admin/tenants/{tenant_id}/overlays", diff --git a/internal/admin_contribution_test.go b/internal/admin_contribution_test.go index 0866e79..2e10a58 100644 --- a/internal/admin_contribution_test.go +++ b/internal/admin_contribution_test.go @@ -12,13 +12,14 @@ func TestCMSAdminContribution(t *testing.T) { t.Fatalf("RenderMode = %q, want iframe", contribution.RenderMode) } + if contribution.Metadata["domains_path"] != "" || stringSetContains(contribution.Permissions, "admin:multisite.sites:update") { + t.Fatal("content contribution exposes platform capabilities") + } for _, permission := range []string{ "admin:multisite.sites:read", - "admin:multisite.sites:update", "admin:multisite.pages:read", "admin:multisite.pages:update", "admin:multisite.publish:update", - "admin:multisite.onboarding:plan", } { if !stringSetContains(contribution.Permissions, permission) { t.Fatalf("missing permission %q in %#v", permission, contribution.Permissions) @@ -27,7 +28,6 @@ func TestCMSAdminContribution(t *testing.T) { for _, key := range []string{ "sites_path", - "domains_path", "pages_path", "templates_path", "overlays_path", diff --git a/internal/admin_http.go b/internal/admin_http.go index 2ed6491..fd032ca 100644 --- a/internal/admin_http.go +++ b/internal/admin_http.go @@ -1,6 +1,7 @@ package internal import ( + "context" "encoding/json" "errors" "net/http" @@ -32,12 +33,21 @@ import ( // `.` domain row (V18 / T32). Empty disables // auto-provision. type AdminAPI struct { - tenants store.TenantAdminStore - pages store.PageStore - ReloadFunc func() error - PreviewBase string - Audit *audit.Logger - AuditActor func(*http.Request) string + tenants store.TenantAdminStore + pages store.PageStore + ReloadFunc func() error + PreviewBase string + Audit *audit.Logger + AuditActor func(*http.Request) string + TenantAccess func(*http.Request, int64) bool + // PlatformAccess must authorize current platform authority, including reads. + // Missing configuration fails closed for global and domain administration. + PlatformAccess func(*http.Request) bool + // RequestAccess projects page permissions without dispatching a write and + // repeats the host's authorization inside this handler when configured. + RequestAccess func(*http.Request) bool + ResolveTemplate func(context.Context, int64, string) (PageTemplate, error) + ListTemplates func(context.Context, int64) ([]string, error) } // NewAdminAPI returns a handler using the given stores. Either store @@ -49,55 +59,144 @@ func NewAdminAPI(tenants store.TenantAdminStore, pages store.PageStore) *AdminAP // ServeHTTP dispatches by method + path. func (a *AdminAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/api/v1/admin") + route, tid, rid := parseAdminRoute(r.URL.Path) + if route == "" { + writeJSONError(w, 404, "not_found", "route not found") + return + } + platform := route == "/reload" || (route == "/tenants" && r.Method != http.MethodGet) || strings.HasPrefix(route, "/domains") + if platform && (a.PlatformAccess == nil || !a.PlatformAccess(r)) { + writeJSONError(w, 403, "forbidden", "platform access denied") + return + } + if a.RequestAccess != nil && !a.RequestAccess(r) { + writeJSONError(w, 403, "forbidden", "request access denied") + return + } + if tid > 0 && a.TenantAccess != nil && !a.TenantAccess(r, tid) { + writeJSONError(w, 403, "forbidden", "tenant access denied") + return + } switch { - case path == "/reload" && r.Method == http.MethodPost: + case route == "/reload" && r.Method == "POST": a.reload(w, r) - - case path == "/tenants" && r.Method == http.MethodGet: + case route == "/tenants" && r.Method == "GET": a.listTenants(w, r) - case path == "/tenants" && r.Method == http.MethodPost: + case route == "/tenants" && r.Method == "POST": a.createTenant(w, r) + case route == "/domains" && r.Method == "GET": + a.listDomains(w, r, tid) + case route == "/domains" && r.Method == "POST": + a.createDomain(w, r, tid) + case route == "/domains/id" && r.Method == "DELETE": + a.deleteDomain(w, r, tid, rid) + case route == "/pages" && r.Method == "GET": + a.listPages(w, r, tid) + case route == "/pages" && r.Method == "POST": + a.createPage(w, r, tid) + case route == "/pages/id" && r.Method == "GET": + a.getPage(w, r, tid, rid) + case route == "/pages/id" && r.Method == "PUT": + a.updatePage(w, r, tid, rid) + case route == "/pages/id" && r.Method == "DELETE": + a.deletePage(w, r, tid, rid) + case route == "/pages/preview" && r.Method == "POST": + a.previewPage(w, r, tid) + case route == "/pages/templates" && r.Method == "GET": + a.pageTemplates(w, r, tid) + case route == "/pages/permissions" && r.Method == "GET": + a.pagePermissions(w, r, tid) + case route == "/overlays/clone" && r.Method == "POST": + a.cloneOverlay(w, r, tid) + case route == "/overlays/publish" && r.Method == "PUT": + a.publishOverlay(w, r, tid) + case route == "/overlays/disable" && r.Method == "PUT": + a.disableOverlay(w, r, tid) + case route == "/nav/published" && r.Method == "POST": + a.publishedNavigation(w, r, tid) + case route == "/widgets/render" && r.Method == "POST": + a.renderWidget(w, r, tid) + case route == "/media/validate" && r.Method == "POST": + a.validateMedia(w, r, tid) + default: + writeJSONError(w, 404, "not_found", "route not found") + } +} - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/domains") && r.Method == http.MethodGet: - a.listDomains(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/domains") && r.Method == http.MethodPost: - a.createDomain(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.Contains(path, "/domains/") && r.Method == http.MethodDelete: - tid, did := extractTenantAndDomainID(path) - a.deleteDomain(w, r, tid, did) - - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/pages") && r.Method == http.MethodGet: - a.listPages(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/pages") && r.Method == http.MethodPost: - a.createPage(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.Contains(path, "/pages/") && r.Method == http.MethodGet: - tid, pid := extractTenantAndPageID(path) - a.getPage(w, r, tid, pid) - case strings.HasPrefix(path, "/tenants/") && strings.Contains(path, "/pages/") && r.Method == http.MethodPut: - tid, pid := extractTenantAndPageID(path) - a.updatePage(w, r, tid, pid) - case strings.HasPrefix(path, "/tenants/") && strings.Contains(path, "/pages/") && r.Method == http.MethodDelete: - tid, pid := extractTenantAndPageID(path) - a.deletePage(w, r, tid, pid) - - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/overlays/clone") && r.Method == http.MethodPost: - a.cloneOverlay(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/overlays/publish") && r.Method == http.MethodPut: - a.publishOverlay(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/overlays/disable") && r.Method == http.MethodPut: - a.disableOverlay(w, r, extractTenantID(path)) - - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/nav/published") && r.Method == http.MethodPost: - a.publishedNavigation(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/widgets/render") && r.Method == http.MethodPost: - a.renderWidget(w, r, extractTenantID(path)) - case strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/media/validate") && r.Method == http.MethodPost: - a.validateMedia(w, r, extractTenantID(path)) +// Parse once: authorization and dispatch consume the same ID. No suffix routes, +// repeated tenant/page segments, empty segments or noncanonical numeric IDs. +func parseAdminRoute(path string) (route string, tid, rid int64) { + const prefix = "/api/v1/admin/" + if !strings.HasPrefix(path, prefix) { + return + } + parts := strings.Split(strings.TrimPrefix(path, prefix), "/") + if len(parts) == 1 && (parts[0] == "tenants" || parts[0] == "reload") { + return "/" + parts[0], 0, 0 + } + if len(parts) < 3 || len(parts) > 4 || parts[0] != "tenants" { + return + } + tid, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || tid <= 0 || strconv.FormatInt(tid, 10) != parts[1] { + return "", 0, 0 + } + if len(parts) == 3 && (parts[2] == "pages" || parts[2] == "domains") { + return "/" + parts[2], tid, 0 + } + if len(parts) != 4 { + return "", 0, 0 + } + route = "/" + parts[2] + "/" + parts[3] + switch route { + case "/pages/preview", "/pages/templates", "/pages/permissions", "/overlays/clone", "/overlays/publish", "/overlays/disable", "/nav/published", "/widgets/render", "/media/validate": + return route, tid, 0 + } + if parts[2] != "pages" && parts[2] != "domains" { + return "", 0, 0 + } + rid, err = strconv.ParseInt(parts[3], 10, 64) + if err != nil || rid <= 0 || strconv.FormatInt(rid, 10) != parts[3] { + return "", 0, 0 + } + return "/" + parts[2] + "/id", tid, rid +} - default: - writeJSONError(w, http.StatusNotFound, "not_found", "route not found") +// pagePermissions invokes the same callbacks as writes, but never dispatches +// them. A page ID affects the probe target only; it grants no authority. +func (a *AdminAPI) pagePermissions(w http.ResponseWriter, r *http.Request, tid int64) { + w.Header().Set("Cache-Control", "private, no-store") + if a.RequestAccess == nil || a.TenantAccess == nil { + writeJSONError(w, 503, "unavailable", "permission projection not configured") + return + } + pid := int64(0) + if values, present := r.URL.Query()["page_id"]; present { + if len(values) != 1 { + writeJSONError(w, 400, "bad_request", "canonical page_id required") + return + } + var err error + pid, err = strconv.ParseInt(values[0], 10, 64) + if err != nil || pid <= 0 || strconv.FormatInt(pid, 10) != values[0] { + writeJSONError(w, 400, "bad_request", "canonical page_id required") + return + } } + base := "/api/v1/admin/tenants/" + strconv.FormatInt(tid, 10) + "/pages" + probe := func(method, path string) bool { + p := r.Clone(r.Context()) + u := *r.URL + u.Path, u.RawPath, u.RawQuery = path, "", "" + p.URL, p.Method, p.RequestURI, p.Body, p.ContentLength = &u, method, "", http.NoBody, 0 + return a.RequestAccess(p) && a.TenantAccess(p, tid) + } + edit, remove := false, false + if pid > 0 { + path := base + "/" + strconv.FormatInt(pid, 10) + edit, remove = probe(http.MethodPut, path), probe(http.MethodDelete, path) + } + writeJSON(w, 200, map[string]bool{"create": probe(http.MethodPost, base), "edit": edit, "delete": remove}) } // --- Reload handler ----------------------------------------------------- @@ -128,6 +227,15 @@ func (a *AdminAPI) listTenants(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusInternalServerError, "internal", err.Error()) return } + if a.TenantAccess != nil { + visible := make([]*store.Tenant, 0, len(tenants)) + for _, tenant := range tenants { + if a.TenantAccess(r, tenant.ID) { + visible = append(visible, tenant) + } + } + tenants = visible + } writeJSON(w, http.StatusOK, map[string]any{"tenants": tenants}) } @@ -278,8 +386,8 @@ type pageUpdateBody struct { BodyBlocks json.RawMessage `json:"body_blocks"` Status *string `json:"status"` TemplateID *string `json:"template_id"` - PublishAt *time.Time `json:"publish_at"` - UnpublishAt *time.Time `json:"unpublish_at"` + PublishAt json.RawMessage `json:"publish_at"` + UnpublishAt json.RawMessage `json:"unpublish_at"` } func (a *AdminAPI) createPage(w http.ResponseWriter, r *http.Request, tenantID int64) { @@ -296,6 +404,9 @@ func (a *AdminAPI) createPage(w http.ResponseWriter, r *http.Request, tenantID i writeJSONError(w, http.StatusBadRequest, "bad_request", err.Error()) return } + if strings.TrimSpace(string(body.BodyBlocks)) == "null" { + body.BodyBlocks = nil + } p := &store.Page{ TenantID: tenantID, Subsite: body.Subsite, @@ -336,7 +447,15 @@ func (a *AdminAPI) getPage(w http.ResponseWriter, r *http.Request, tenantID, pag statusFromPageErr(w, err) return } - writeJSON(w, http.StatusOK, p) + body, err := renderPageBody(p) + if err != nil { + writeJSONError(w, 400, "bad_request", "invalid page blocks") + return + } + writeJSON(w, http.StatusOK, struct { + *store.Page + RenderedBodyHTML string + }{p, body}) } func (a *AdminAPI) updatePage(w http.ResponseWriter, r *http.Request, tenantID, pageID int64) { @@ -369,9 +488,16 @@ func (a *AdminAPI) updatePage(w http.ResponseWriter, r *http.Request, tenantID, } if body.BodyHTML != nil { existing.BodyHTML = *body.BodyHTML + if len(body.BodyBlocks) == 0 { + existing.BodyBlocks = nil + } } if len(body.BodyBlocks) > 0 { - existing.BodyBlocks = body.BodyBlocks + if string(body.BodyBlocks) == "null" { + existing.BodyBlocks = nil + } else { + existing.BodyBlocks = body.BodyBlocks + } } if body.Status != nil { existing.Status = store.PageStatus(*body.Status) @@ -379,11 +505,17 @@ func (a *AdminAPI) updatePage(w http.ResponseWriter, r *http.Request, tenantID, if body.TemplateID != nil { existing.TemplateID = *body.TemplateID } - if body.PublishAt != nil { - existing.PublishAt = body.PublishAt + if len(body.PublishAt) > 0 { + if err := json.Unmarshal(body.PublishAt, &existing.PublishAt); err != nil { + writeJSONError(w, 400, "bad_request", "invalid publish_at") + return + } } - if body.UnpublishAt != nil { - existing.UnpublishAt = body.UnpublishAt + if len(body.UnpublishAt) > 0 { + if err := json.Unmarshal(body.UnpublishAt, &existing.UnpublishAt); err != nil { + writeJSONError(w, 400, "bad_request", "invalid unpublish_at") + return + } } if err := existing.Validate(); err != nil { writeJSONError(w, http.StatusBadRequest, "bad_request", err.Error()) @@ -604,47 +736,8 @@ func statusFromPageErr(w http.ResponseWriter, err error) { } } -func extractTenantAndPageID(path string) (int64, int64) { - parts := strings.Split(path, "/") - var tid, pid int64 - for i, p := range parts { - if p == "tenants" && i+1 < len(parts) { - tid, _ = strconv.ParseInt(parts[i+1], 10, 64) - } - if p == "pages" && i+1 < len(parts) { - pid, _ = strconv.ParseInt(parts[i+1], 10, 64) - } - } - return tid, pid -} - // --- helpers ------------------------------------------------------------ -func extractTenantID(path string) int64 { - parts := strings.Split(path, "/") - for i, p := range parts { - if p == "tenants" && i+1 < len(parts) { - id, _ := strconv.ParseInt(parts[i+1], 10, 64) - return id - } - } - return 0 -} - -func extractTenantAndDomainID(path string) (int64, int64) { - parts := strings.Split(path, "/") - var tid, did int64 - for i, p := range parts { - if p == "tenants" && i+1 < len(parts) { - tid, _ = strconv.ParseInt(parts[i+1], 10, 64) - } - if p == "domains" && i+1 < len(parts) { - did, _ = strconv.ParseInt(parts[i+1], 10, 64) - } - } - return tid, did -} - func decodeJSON(r *http.Request, dst any) error { defer r.Body.Close() dec := json.NewDecoder(r.Body) diff --git a/internal/admin_http_test.go b/internal/admin_http_test.go index a892b9a..4c7ed3b 100644 --- a/internal/admin_http_test.go +++ b/internal/admin_http_test.go @@ -14,7 +14,9 @@ import ( ) func newAdminTestAPI() *AdminAPI { - return NewAdminAPI(store.NewMemoryTenantAdminStore(), store.NewMemoryPageStore()) + api := NewAdminAPI(store.NewMemoryTenantAdminStore(), store.NewMemoryPageStore()) + api.PlatformAccess = func(*http.Request) bool { return true } // trusted unit fixture only + return api } func TestAdmin_PageTemplateAndScheduleFieldsPersist(t *testing.T) { diff --git a/internal/admin_permissions_test.go b/internal/admin_permissions_test.go new file mode 100644 index 0000000..aef6065 --- /dev/null +++ b/internal/admin_permissions_test.go @@ -0,0 +1,54 @@ +package internal + +import ( + "net/http" + "strings" + "testing" +) + +func TestPlatformHookFailsClosedEvenWithoutOuterMiddleware(t *testing.T) { + a := newAdminTestAPI() + a.PlatformAccess = nil + for _, tc := range []struct{ method, path string }{{"POST", "/api/v1/admin/tenants"}, {"POST", "/api/v1/admin/reload"}, {"GET", "/api/v1/admin/tenants/1/domains"}, {"POST", "/api/v1/admin/tenants/1/domains"}, {"DELETE", "/api/v1/admin/tenants/1/domains/1"}} { + w, _ := doJSON(t, a, tc.method, tc.path, map[string]string{"slug": "must-not-create"}) + if w.Code != 403 { + t.Errorf("%s %s: %d", tc.method, tc.path, w.Code) + } + } + w, data := doJSON(t, a, "GET", "/api/v1/admin/tenants", nil) + if w.Code != 200 || len(data["tenants"].([]any)) != 0 { + t.Fatal("denied platform write created a tenant") + } +} +func TestPermissionProjectionUsesWriteCallbacksWithoutWriting(t *testing.T) { + a := newAdminTestAPI() + var calls []string + a.RequestAccess = func(r *http.Request) bool { + calls = append(calls, r.Method+" "+r.URL.Path) + return r.Method == "GET" || r.Method == "PUT" + } + a.TenantAccess = func(r *http.Request, tid int64) bool { return tid == 1 } + w, data := doJSON(t, a, "GET", "/api/v1/admin/tenants/1/pages/permissions?page_id=9", nil) + if w.Code != 200 || data["create"] != false || data["edit"] != true || data["delete"] != false { + t.Fatalf("projection %d %v", w.Code, data) + } + if !strings.Contains(strings.Join(calls, "\n"), "PUT /api/v1/admin/tenants/1/pages/9") { + t.Fatal("did not probe exact page path") + } + if w.Header().Get("Cache-Control") != "private, no-store" { + t.Fatal("permission projection cacheable") + } + w, _ = doJSON(t, a, "GET", "/api/v1/admin/tenants/2/pages/permissions?page_id=9", nil) + if w.Code != 403 { + t.Fatal("wrong tenant projection accepted") + } + w, _ = doJSON(t, a, "GET", "/api/v1/admin/tenants/1/pages/permissions?page_id=09", nil) + if w.Code != 400 { + t.Fatal("noncanonical page accepted") + } + a.RequestAccess = nil + w, _ = doJSON(t, a, "GET", "/api/v1/admin/tenants/1/pages/permissions", nil) + if w.Code != 503 { + t.Fatal("missing projection hook must fail closed") + } +} diff --git a/internal/editor_regression_test.go b/internal/editor_regression_test.go new file mode 100644 index 0000000..004872e --- /dev/null +++ b/internal/editor_regression_test.go @@ -0,0 +1,39 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func TestHTMLUpdateClearsCanonicalBlocksAndSchedule(t *testing.T) { + a := newAdminTestAPI() + _, 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}) + if rec.Code != http.StatusOK || p["BodyBlocks"] != nil || p["PublishAt"] != nil { + t.Fatalf("stale canonical content or schedule: %d %#v", rec.Code, p) + } +} + +func TestMalformedTenantRoutesCannotAuthorizeOneTenantAndDispatchAnother(t *testing.T) { + a := newAdminTestAPI() + a.TenantAccess = func(r *http.Request, id int64) bool { return id == 1 } + for _, path := range []string{ + "/api/v1/admin/tenants/1/tenants/2/pages/9", + "/api/v1/admin/x/tenants/2/pages/preview", + "/api/v1/admin/x/tenants/2/pages/templates", + "/api/v1/admin/tenants/1/domains/2/domains/9", + "/api/v1/admin/tenants/01/pages", + } { + r := httptest.NewRequest("PUT", path, strings.NewReader(`{"body_html":"cross tenant"}`)) + w := httptest.NewRecorder() + a.ServeHTTP(w, r) + if w.Code != 404 { + t.Errorf("malformed route reached dispatch: %s status %d", path, w.Code) + } + } +} diff --git a/internal/page_document.go b/internal/page_document.go index b99a34a..d9b0284 100644 --- a/internal/page_document.go +++ b/internal/page_document.go @@ -34,7 +34,8 @@ func RenderPageDocument(page *store.Page, template PageTemplate, now time.Time) return body, true, nil } if strings.Contains(template.HTML, cmsBodySlot) { - return strings.Replace(template.HTML, cmsBodySlot, body, 1), true, nil + shell := strings.ReplaceAll(template.HTML, "", html.EscapeString(page.Title)) + return strings.Replace(shell, cmsBodySlot, body, 1), true, nil } return template.HTML + body, true, nil } diff --git a/internal/preview.go b/internal/preview.go new file mode 100644 index 0000000..9b8ac89 --- /dev/null +++ b/internal/preview.go @@ -0,0 +1,93 @@ +package internal + +import ( + "bytes" + "github.com/GoCodeAlone/workflow-plugin-cms/store" + "golang.org/x/net/html" + "net/http" + "strconv" + "strings" + "time" +) + +func (a *AdminAPI) pageTemplates(w http.ResponseWriter, r *http.Request, id int64) { + if id <= 0 || a.ListTemplates == nil { + writeJSONError(w, 404, "not_found", "templates unavailable") + return + } + names, err := a.ListTemplates(r.Context(), id) + if err != nil { + writeJSONError(w, 503, "unavailable", "templates unavailable") + return + } + writeJSON(w, 200, map[string]any{"templates": names}) +} + +func (a *AdminAPI) previewPage(w http.ResponseWriter, r *http.Request, id int64) { + if id <= 0 || a.ResolveTemplate == nil { + writeJSONError(w, 404, "not_found", "preview unavailable") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + var body pageBody + if err := decodeJSON(r, &body); err != nil { + writeJSONError(w, 400, "bad_request", "invalid preview document") + return + } + if strings.TrimSpace(string(body.BodyBlocks)) == "null" { + body.BodyBlocks = nil + } + template, err := a.ResolveTemplate(r.Context(), id, body.TemplateID) + if err != nil { + writeJSONError(w, 503, "unavailable", "template unavailable") + return + } + p := &store.Page{TenantID: id, Title: body.Title, Path: body.Path, BodyHTML: body.BodyHTML, BodyBlocks: body.BodyBlocks, Status: store.StatusPublished} + if err := p.Validate(); err != nil { + writeJSONError(w, 400, "bad_request", err.Error()) + return + } + doc, _, err := RenderPageDocument(p, template, time.Now().UTC()) + if err != nil { + writeJSONError(w, 400, "bad_request", "invalid page blocks") + return + } + prefix := "/api/v1/admin/tenants/" + strconv.FormatInt(id, 10) + "/pages/assets" + csp := "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-src 'none'; script-src 'none'; connect-src 'none'" + doc = previewAssetPaths(doc, prefix, csp) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", csp+"; sandbox") + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(doc)) +} + +func previewAssetPaths(doc, prefix, csp string) string { + root, err := html.Parse(strings.NewReader(doc)) + if err != nil { + return "" + } + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "head" { + meta := &html.Node{Type: html.ElementNode, Data: "meta", Attr: []html.Attribute{{Key: "http-equiv", Val: "Content-Security-Policy"}, {Key: "content", Val: csp}}} + n.InsertBefore(meta, n.FirstChild) + } + for i, a := range n.Attr { + if a.Key == "src" || a.Key == "href" { + if strings.HasPrefix(a.Val, "/assets/") { + n.Attr[i].Val = prefix + a.Val + } else if strings.HasPrefix(a.Val, "assets/") { + n.Attr[i].Val = prefix + "/" + a.Val + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + var b bytes.Buffer + _ = html.Render(&b, root) + return b.String() +} diff --git a/internal/preview_test.go b/internal/preview_test.go new file mode 100644 index 0000000..632a0da --- /dev/null +++ b/internal/preview_test.go @@ -0,0 +1,15 @@ +package internal + +import ( + "strings" + "testing" +) + +func TestPreviewCSPInsertedBeforeResourcesWithAttributedHead(t *testing.T) { + doc := previewAssetPaths(``, "/tenant-assets", "script-src 'none'; img-src 'self'") + meta := strings.Index(doc, `http-equiv="Content-Security-Policy"`) + resource := strings.Index(doc, "external.test") + if meta < 0 || resource < meta || !strings.Contains(doc, `src="/tenant-assets/assets/photo.jpg"`) { + t.Fatalf("preview CSP/assets missing: %s", doc) + } +}