Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 132 additions & 18 deletions adminui/adminui.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
23 changes: 21 additions & 2 deletions adminui/adminui_test.go
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions adminui/routes_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading