Users (Tools > Users) - Everyone can update their display name, email and password; administrators can add, edit, invite (one-time link), block and delete accounts. - Accounts in config.local.php stay locked to config (the main administrator); CMS accounts live in data/users.json. Sign-in is by login name. - Roles are now editor and administrator (publisher is an alias of editor). - Account protection: password hashes only (12-72 characters), administrator password confirmation for sensitive actions, rate-limited failures, hashed single-use invite tokens, self-lockout guards, sessions of blocked/deleted users end on the next request, every account event audited without secrets. - dataDirectory can move data outside the web root; the dashboard recommends it. Media library - Upload images, documents, video and audio, sorted into four categories by extension. - Extension allow-list; executables, scripts, HTML/SVG, archives, legacy and macro Office files refused; content sniffing; files containing code refused; PDFs with scripts refused; Office/OpenDocument packages inspected for macros and embedded objects. - Media library button in the editor inserts an image, document link, video or audio. Navigation menus, pages and interface - The site scan detects primary/secondary navigation and seeds Navigation Menus; menus can be renamed, edited and deleted but not created by the CMS; a scan button finds new navigation. - New page wizard (template, name, navigation group) in the Pages dialogue; new pages stay private drafts until published; pages can be used as templates. - Deleted pages can be recovered from the Dashboard. - Full-width top bar with the user at the top right, device icons, consistent buttons. Tests and docs updated (tests/run.php, README, ARCHITECTURE, DEVELOPMENT, CHANGELOG). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
198 lines
14 KiB
Markdown
198 lines
14 KiB
Markdown
# NeoCMS Architecture
|
|
|
|
## Design Goals
|
|
|
|
NeoCMS adds controlled visual editing to static HTML websites while retaining three properties:
|
|
|
|
* Public pages remain ordinary HTML files served directly by the web server.
|
|
* Supporting state uses files rather than a database service.
|
|
* Existing page structure and styling remain under the site's control.
|
|
|
|
## Request Flow
|
|
|
|
`cms/index.php` renders the authenticated administration shell. It passes the CSRF token and configured editable class to
|
|
`cms/js/cms.js` through metadata tags. The JavaScript client loads public pages into a same-origin iframe, binds editing
|
|
behaviour to matching regions, and submits complete serialised documents to `cms/controller/index.php`.
|
|
|
|
The preview iframe uses `sandbox="allow-same-origin"` without `allow-scripts`. Parent-side JavaScript may still inspect and
|
|
edit the DOM, while scripts belonging to public pages cannot reach the administration window, session, or CSRF token.
|
|
|
|
Every entry point loads `cms/bootstrap.php`, which reads `config.php` and the untracked `config.local.php`, works out the URL prefix
|
|
(`basePath`) and site root (`siteRoot`) for subfolder installs, and registers the class autoloader.
|
|
|
|
`CMSController` is the single API dispatcher. It authenticates every request and delegates mutating actions through
|
|
`requirePost()`, which enforces POST, CSRF validation, and the requested role capability.
|
|
|
|
Media uploads (images, documents, video, audio) use the separate `cms/image_upload.php` endpoint because TinyMCE and the Media
|
|
library send multipart file data. It applies the same authentication, role, and CSRF requirements before `MediaTypes::inspect()`
|
|
validates the file (see Media below).
|
|
|
|
## Components
|
|
|
|
| Class | Responsibility |
|
|
| --- | --- |
|
|
| `CMSController` | Action dispatcher and workflows (pages, drafts, publishing, menus, templates, users). |
|
|
| `Authentication` | Sessions, role capabilities, CSRF tokens, session revocation. |
|
|
| `UserStore` | Account registry: config accounts plus CMS accounts, invitations, password policy. |
|
|
| `PagePaths` | URI and path safety (containment, symlinks, existing and new page paths). |
|
|
| `ContentDom` | HTML transforms: menus, shared regions, link resolution, image and SEO edits. |
|
|
| `MediaTypes` | The allow-list of media extensions and categories, and the upload safety checks. |
|
|
| `SiteAnalyser` | Byte-preserving tokenizer used by the site scan and menu tagging. |
|
|
| `FileStore` | Locked, atomic JSON metadata storage. |
|
|
| `Activity`, `Logger` | Dashboard activity feed and daily audit log. |
|
|
| `LoginRateLimiter` | Hashed address and identity throttling, also used for password confirmation and invites. |
|
|
|
|
## Storage Model
|
|
|
|
Published content stays in the web document root. NeoCMS stores supporting data beneath `cms/data/`:
|
|
|
|
| Path or document | Contents |
|
|
| --- | --- |
|
|
| `drafts/` | Complete unpublished HTML documents, named by a hash of their URI. New pages live only here until first published. |
|
|
| `revisions/` | Immutable snapshots of earlier published documents. |
|
|
| `scheduled/` | Complete HTML staged for future publication. |
|
|
| `drafts.json` | Human-readable draft ownership and timestamp metadata. |
|
|
| `revisions.json` | Revision URI, author, reason, and timestamp index. |
|
|
| `schedules.json` | Publication times and staged-document identifiers. |
|
|
| `shared.json` | Named global HTML fragments. |
|
|
| `menus.json` | Named menus: `{key: {items: [{label, url, parent}], updated, title?}}`; seeded by the site scan, never created by the CMS. |
|
|
| `newpages.json` | Pending (unpublished) pages: title, chosen menu, template, author, created. |
|
|
| `pagetemplates.json` | Pages offered as templates in the New page section of Pages. |
|
|
| `users.json` | CMS-created accounts and profile overlays for config accounts (`{users: {...}, profiles: {...}}`). |
|
|
| `media.json` | Per-file description (alt text or link text), original filename, and uploader. |
|
|
| `activity.json` | The latest 250 dashboard activity entries. |
|
|
| `login-attempts.json` | Hashed address and identity buckets used for login throttling. |
|
|
|
|
`FileStore` replaces JSON documents atomically by writing a temporary neighbour and renaming it. Public page publication
|
|
uses the same approach. A revision is created before replacing, deleting, or globally updating a public page. Private
|
|
directories use mode `0700` and private files use `0600` where supported. Configurable count and byte quotas prevent drafts,
|
|
schedules, revisions, and uploads from growing without bound.
|
|
|
|
## Editing Model
|
|
|
|
The configured `editableClass` identifies editable regions. The client edits a region's inner HTML in TinyMCE, then writes
|
|
it back to the iframe only. Drafting, scheduling, and publishing serialise a clone of the complete iframe document after
|
|
removing temporary CMS controls.
|
|
|
|
`neo-dupe` marks a repeatable block. `data-neo-shared="key"` marks a globally managed fragment. `data-neo-menu="name"`
|
|
marks a `<nav>` whose list is replaced whenever its named menu changes. `data-neo-image` marks an image outside an editable
|
|
region that can be replaced through the image dialogue.
|
|
|
|
The bundled templates use the default `editable` class. Sites that override `editableClass` must update their templates
|
|
to match; otherwise the resulting page is valid HTML but, quite reasonably, is not treated as editable by NeoCMS.
|
|
|
|
## Site Scan
|
|
|
|
`SiteAnalyser` tokenises each page (skipping comments, scripts, styles, and templates) to locate element offsets, then
|
|
`apply()` splices markers into the original bytes. It deliberately avoids DOM re-serialisation so an automated pass over
|
|
every page cannot rewrite unrelated markup. The browser drives the scan in small batches so it can show a progress bar and page count: `listSitePages` returns the
|
|
page list, `analyseSitePages` reports a plan for up to 100 pages per request (the client totals the results), and
|
|
`applySiteTagging` reuses `rewritePages()` to write only the pages it is given, after taking a revision of each,
|
|
through the same atomic-replace path as publishing. All three need the administrator role. Local asset references are resolved and confined by
|
|
`PagePaths::refExists()`.
|
|
|
|
## Media
|
|
|
|
`MediaTypes` is the single source of truth for uploads. Its allow-list maps each extension to one of four categories (imagery,
|
|
documents, video, audio); the category is derived from the extension whenever the library is listed, and there is no separate
|
|
category field to drift. `MediaTypes::inspect()` runs for every upload in `cms/image_upload.php` (the endpoint TinyMCE also uses):
|
|
extension allow-list plus a deny list applied to every dot-separated part of the original name, content sniffing with `finfo`
|
|
against the extension, refusal of executable signatures, a chunked scan of the whole file for code markers, and per-format
|
|
checks (image decoding within the size limits, PDF actions, and a small built-in ZIP directory reader for OOXML and
|
|
OpenDocument packages that refuses macros, embedded objects, scripts, and executables without needing the `zip` extension).
|
|
Files are stored under `uploads/` as `slug-8hex.ext` (one dot only) or as legacy 32-hex names, both matched by
|
|
`MediaTypes::nameRegex()`, which the usage counter, the site scan, and `deleteMedia` all share. `uploads/.htaccess` denies
|
|
executable and markup extensions, sets `nosniff`, and serves documents with `Content-Disposition: attachment`. The editor's
|
|
**Media library** button opens the same dialog in pick mode and inserts an `<img>`, a document link, or a `<video>`/`<audio>`
|
|
element by category.
|
|
|
|
## New Pages and Templates
|
|
|
|
`newPageAction` reads the chosen template (a file in `cms/templates/` or `page:<uri>` for a page marked in `pagetemplates.json`),
|
|
sets its title, and stores it only as a private draft plus a `newpages.json` record. No file is created, so the page cannot be
|
|
served. `publishContent` (used by Publish Now and by scheduled publishing) creates the file on first publication, then
|
|
`finishNewPage` adds the link to the chosen menu, propagates it, and removes the pending record. The preview writes the draft into
|
|
an `about:blank` frame with an injected `<base>` element so relative assets resolve. Page filenames come from the name via
|
|
`uniqueUri`, never from user input.
|
|
|
|
## Navigation Menus
|
|
|
|
`SiteAnalyser::navs()` classifies each `<nav>` by its position and label (header, banner and main navigation are `primary`;
|
|
footer and legal are `secondary`). The scan tags it with `data-neo-menu` and seeds `menus.json` from the first page that has it
|
|
(existing menus are never overwritten). `ContentDom::withMenu()` replaces only the inner list of a marked `<nav>` in the page's
|
|
original bytes, sets `aria-current="page"` for the current page, and returns null when nothing changes so the page is not
|
|
rewritten. The CMS cannot create a menu: `saveMenu` requires an existing key and may change its items and display `title`.
|
|
|
|
## Deleted Page Recovery
|
|
|
|
Deleting a page first stores its HTML as a revision with the reason "Before delete". The dashboard's `deleted` list (editors
|
|
and administrators) contains pages whose newest revision has that reason and whose file no longer exists; **Recover** calls
|
|
`restoreRevision`, which recreates the file from the snapshot.
|
|
|
|
## Subfolder Installs
|
|
|
|
`bootstrap.php` derives `basePath` from the script URL and `siteRoot` from the folder containing `cms/`. Stored menu links and
|
|
page URLs carry the prefix, and the session cookie path is `basePath/cms`. Both values can be overridden in configuration.
|
|
|
|
## Roles
|
|
Capabilities are intentionally small and cumulative:
|
|
|
|
| Role | Capabilities |
|
|
| --- | --- |
|
|
| `editor` | Save drafts, upload images, publish, schedule, restore revisions, and recover deleted pages (the old `publisher` role is an alias). |
|
|
| `administrator` | Editor capabilities plus page, media, shared-content, menu, template, and user management. |
|
|
|
|
The interface hides controls the user cannot invoke, but server-side checks remain authoritative. Hidden buttons are a
|
|
convenience, not a security boundary wearing a clever disguise.
|
|
|
|
Active sessions are bound to the account's current password hash. Removing, blocking, or re-passwording an account invalidates
|
|
its sessions on the next request, while roles are re-read on every request so a demotion is immediate.
|
|
|
|
## Users and Accounts
|
|
|
|
`UserStore` merges two sources. Accounts in `config.local.php` are authoritative and locked: they are always active, cannot be
|
|
edited, blocked, or deleted from the UI, and cannot be shadowed by a CMS account (only a display name and email overlay is
|
|
stored for them). Accounts created in the UI live in `data/users.json` as `{hash, role, name, email, status, invite?}` with status
|
|
`active`, `blocked`, or `invited`. `UserStore::authArgs()` supplies `Authentication` with the config accounts plus **active** CMS
|
|
accounts only, which is why blocked or deleted users lose their sessions without any extra code. Sign-in uses the login name only; the
|
|
email address is contact information (unique, but not a login identifier).
|
|
|
|
Actions: `users` (GET; administrators also receive the account list, never hashes or tokens), `saveProfile` and `changePassword`
|
|
(any signed-in user, acting only on their own account taken from the session), and `saveUser`, `inviteUser`, `blockUser`, and
|
|
`deleteUser` (administrators). Sensitive actions and email or password changes require the actor's current password, checked
|
|
by `confirmPassword()` and throttled with `LoginRateLimiter`. Invitations are 32 random bytes; only the SHA-256 is stored, the link
|
|
works once and expires after 7 days, and acceptance goes through `cms/login/?invite=TOKEN`. Passwords are 12 to 72 bytes
|
|
(bcrypt truncates beyond 72), must differ from the username and email, and are stored with `password_hash`. Guard rails in the
|
|
store and controller refuse self-block, self-delete, self-demotion, duplicate usernames and emails, and any change to a config
|
|
account.
|
|
|
|
## Interface
|
|
|
|
The administration shell is a full-height grid: logo and page path on the left, the signed-in user and Log out at the top right,
|
|
and the toolbar buttons plus the desktop/tablet/mobile icons beneath them (wrapping to their own row below 1100px). Dialogues are
|
|
jQuery UI dialogues built from shared helpers (`dataTable`, `rowButton`), with a snackbar for results. Row buttons are small
|
|
secondary buttons, action buttons (Save, Next, Apply, and the editor buttons) are a step larger, and the primary action is blue.
|
|
|
|
## Scheduled Publishing
|
|
|
|
Every API request opportunistically processes due jobs. Reliable unattended operation comes from invoking
|
|
`cms/publish_scheduled.php` through cron. The worker is CLI-only and uses the same publication and revision path as the UI.
|
|
|
|
## Security Boundaries
|
|
|
|
NeoCMS relies on PHP sessions with strict mode, HTTP-only cookies, SameSite=Lax, and Secure cookies under HTTPS. Mutating
|
|
requests require a constant-time-checked CSRF token. Page paths are canonicalised and constrained to HTML files beneath the
|
|
document root, excluding the CMS directory and symbolic links. Uploads are constrained by an extension allow-list, byte size, detected MIME type,
|
|
code scanning, image decoding, dimensions, pixel count, file count, and total storage. Login attempts are throttled by hashed address and
|
|
address/username buckets. Account data is protected by hashing only, withholding hashes and tokens from every response, requiring
|
|
the actor's password for sensitive account actions, rate limiting those checks, and auditing every account event without secrets.
|
|
|
|
HTML and JSON responses receive Content Security Policy, anti-framing, no-sniffing, referrer, permissions, cross-origin,
|
|
cache-control, and conditional HSTS headers. CDN scripts and styles are fixed to explicit versions and protected by SRI.
|
|
|
|
The supplied Apache rules protect configuration, data, logs, CLI entry points, and uploaded executable extensions. Other
|
|
servers must provide equivalent denial rules, and production use should always use HTTPS.
|
|
|
|
Audit entries are locked while appending, normalised to prevent forged lines, rotated at a configurable size, and pruned
|
|
after a configurable retention period. They remain operational records rather than a tamper-proof ledger.
|