Add Users, media library, navigation menus from the scan, new-page wizard and deleted-page recovery
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
89d64418de
commit
8946915bfe
+115
-15
@@ -17,11 +17,30 @@ behaviour to matching regions, and submits complete serialised documents to `cms
|
||||
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.
|
||||
|
||||
Image uploads use the separate `cms/image_upload.php` endpoint because TinyMCE sends multipart file data. It applies the
|
||||
same authentication, role, and CSRF requirements before validating image bytes.
|
||||
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
|
||||
|
||||
@@ -29,15 +48,18 @@ Published content stays in the web document root. NeoCMS stores supporting data
|
||||
|
||||
| Path or document | Contents |
|
||||
| --- | --- |
|
||||
| `drafts/` | Complete unpublished HTML documents, named by a hash of their URI. |
|
||||
| `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 flat menu structures with optional parent labels. |
|
||||
| `media.json` | Reusable image alternative text. |
|
||||
| `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. |
|
||||
|
||||
@@ -53,26 +75,103 @@ it back to the iframe only. Drafting, scheduling, and publishing serialise a clo
|
||||
removing temporary CMS controls.
|
||||
|
||||
`neo-dupe` marks a repeatable block. `data-neo-shared="key"` marks a globally managed fragment. `data-neo-menu="name"`
|
||||
marks generated navigation that should be replaced whenever its named menu changes.
|
||||
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.
|
||||
|
||||
## Roles
|
||||
## 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 and upload images. |
|
||||
| `publisher` | Editor capabilities plus publish, schedule, and restore revisions. |
|
||||
| `administrator` | Publisher capabilities plus page, media, shared-content, and menu management. |
|
||||
| `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 current configured password hash. Removing an account or changing its hash invalidates
|
||||
the session on the next request, while roles are re-read from configuration so demotion is immediate.
|
||||
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
|
||||
|
||||
@@ -83,9 +182,10 @@ Every API request opportunistically processes due jobs. Reliable unattended oper
|
||||
|
||||
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 byte size, detected MIME type,
|
||||
image decoding, dimensions, pixel count, file count, and total storage. Login attempts are throttled by hashed address and
|
||||
address/username buckets.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user