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:
Geoff Moore
2026-09-24 17:47:34 +01:00
co-authored by Claude Sonnet 5
parent 89d64418de
commit 8946915bfe
20 changed files with 3001 additions and 325 deletions
+168 -41
View File
@@ -23,19 +23,29 @@ Further technical documentation:
* Seamless Integration: Simply add the CMS to any existing static website. Just assign a special class to the elements
you want to make editable, and you’re all set.
* Consistent Layouts: NeoCMS supports page templates to keep your layouts neat and uniform across all pages.
* Drafts and Publishing: Editors can save drafts without changing the public page. Publishers can publish immediately or
schedule a publication.
* Revision History: Every changed or deleted page receives a timestamped filesystem snapshot that publishers can restore.
* Page Management: Search, create, duplicate, rename, and delete editable HTML pages, including pages in subdirectories.
* Media Library: Browse uploaded images, reuse them, maintain alternative text, see usage counts, and remove files.
* Drafts and Publishing: Clicking Save in the editor keeps the change as a private draft without touching the public page.
Editors can then publish immediately or schedule a publication.
* Revision History: Every changed or deleted page receives a timestamped filesystem snapshot that editors can restore, and
deleted pages can be recovered from the Dashboard.
* Page Management: Search, duplicate, rename, and delete editable HTML pages, including pages in subdirectories. The New page section of the Pages dialogue is a
three-step wizard (template, page name, navigation group); the new page stays a private draft until it is published. Any page can
be offered as a template with Create Template.
* Media Library: Upload images, documents, video, and audio; they are sorted into Imagery, Documents, Video, and Audio by file
extension. Reuse them from the editor's Media library button, maintain alternative text or link text, see usage counts, and
remove files. Executables and files containing code are refused.
* SEO Settings: Edit the page title, description, canonical URL, Open Graph image, and robots indexing setting.
* Shared Content: Mark content with `data-neo-shared="name"` and update matching regions across the site.
* Navigation Menus: Build ordered and nested navigation markup and insert it into an editable region.
* Responsive Preview: Preview the current page at desktop, tablet, and mobile widths.
* Navigation Menus: The site scan finds each page's primary and secondary navigation and pre-fills the menus. Edit, rename, or
delete a menu and the change is applied to every page that uses it. Menus are created by the web developer's markup, not by the
CMS.
* Responsive Preview: Preview the current page at desktop, tablet, and mobile widths using the device icons in the top bar.
* Accessibility Checks: Check common authoring problems such as missing alt text, empty links, heading jumps, missing labels,
and absent page metadata before publishing.
* Roles and Activity: Use editor, publisher, and administrator roles. The dashboard displays drafts, schedules, system
problems, and recent activity.
* Site Scan: Analyse every managed page and the media library, then make pages editable in one step. The scan
finds content areas, images, navigation, broken local references, and missing SEO tags, and adds the markers NeoCMS needs.
* Users and Roles: Everyone can update their display name, email address, and password in Tools > Users. Administrators can add,
edit, invite, block, and delete accounts. Two roles, Editor and Administrator, are described on that screen.
* Activity: The dashboard displays drafts, schedules, deleted pages, system problems, and recent activity.
## Installation Guide
@@ -46,8 +56,15 @@ To install NeoCMS:
* Copy `cms/config.local.php.example` to `cms/config.local.php`, add password hashes, and assign roles.
* Update the elements you want to make editable by adding the configured editable class. It is `editable` by default.
* Confirm that PHP may write to the managed HTML files, `cms/data/`, `cms/logs/`, and `uploads/`.
* **Live sites:** create a folder outside the web root (for example `/var/neocms-data`), make it writable by PHP, and set
`'dataDirectory' => '/var/neocms-data'` in `cms/config.local.php`. It must be an absolute path. Without this, accounts, drafts,
and revisions live in `cms/data/`, which is protected only by the supplied `.htaccess`. Administrators see a Dashboard
recommendation until the data folder is outside the web root. Audit logs remain in `cms/logs/`, also protected by `.htaccess`.
* Visit `/cms/`, log in, and begin editing.
The site may also live in a subfolder (for example `http://localhost/mysite/`): the URL prefix is detected automatically. See
[Subfolder installs](#subfolder-installs).
That’s it! You’re up and running.
## Configuration
@@ -57,17 +74,21 @@ Tracked defaults live in `cms/config.php`. Deployment credentials and overrides
| Setting | Type | Purpose |
| --- | --- | --- |
| `authentication` | array | Maps usernames to `password_hash()` values; plaintext credentials are rejected. |
| `roles` | array | Maps usernames to `editor`, `publisher`, or `administrator`. |
| `authentication` | array | Maps the main administrator usernames to `password_hash()` values; plaintext credentials are rejected. These accounts are managed only here. |
| `roles` | array | Maps those usernames to `editor` or `administrator` (`publisher` is accepted as an alias of `editor`). |
| `security` | array | Configures session expiry, login throttling, request limits, storage quotas, and audit-log retention. |
| `uploads` | array | Configures image size, dimension, file-count, and aggregate-storage limits. |
| `uploads` | array | Configures the per-file size limit (all media types), image dimension, file-count, and aggregate-storage limits. |
| `audit` | boolean | Enables daily activity logs under `cms/logs/`. |
| `skipWelcomePage` | boolean | Opens the public root instead of the built-in guide after login. |
| `showFullUrl` | boolean | Displays the current page path in the toolbar. |
| `editableClass` | string | Selects the single CSS class used to identify editable regions. |
`dataDirectory` is an internal override used by the integration suite and specialised deployments. Normal installations
should allow NeoCMS to use `cms/data/`.
`dataDirectory` is the absolute path of the folder holding accounts (`users.json`), drafts, revisions, schedules, and menus. It
defaults to `cms/data/`; on a live site, point it at a folder outside the web root (see the Installation Guide) so those files
can never be served. The folder is created on first use if PHP may create it, and the CLI scheduler uses the same setting.
`basePath` (URL prefix of the site root, such as `/mysite`) and `siteRoot` (the folder containing `cms/`) are detected
automatically and only need setting behind a path-rewriting proxy.
### Editable Class
@@ -95,27 +116,52 @@ created pages are immediately discoverable by the page picker.
### Users and Roles
Passwords must be generated with `password_hash()` and stored under `authentication` in `cms/config.local.php`. Assign the
same username a role under `roles`:
There are two kinds of account:
* **Config accounts** are the main administrators. Generate a hash with `password_hash()` and store it under `authentication` in
`cms/config.local.php`, with a role under `roles`. They can be changed only in that file (in Tools > Users they are listed as
"Config"; only their display name and email can be set from the CMS).
* **CMS accounts** are created in **Tools > Users** and stored in `cms/data/users.json`.
```php
'authentication' => [
'author' => '$2y$10$...',
'publisher' => '$2y$10$...',
'admin' => '$2y$10$...',
],
'roles' => [
'author' => 'editor',
'publisher' => 'publisher',
'admin' => 'administrator',
],
```
* `editor`: edit content, save drafts, and upload media.
* `publisher`: editor permissions plus immediate and scheduled publishing and revision restoration.
* `administrator`: publisher permissions plus page, media, shared-content, and menu management.
There are two roles:
Users without an explicit valid role receive `editor`, the least powerful role. Account removal, password-hash changes,
and role changes affect active sessions on their next request. Sessions expire after 30 minutes of inactivity and 12 hours
in total by default.
| Role | Can do |
| --- | --- |
| `editor` | Edit content, save drafts, upload images, publish or schedule pages, and restore revisions or deleted pages. Cannot change the site structure or manage users. |
| `administrator` | Everything an editor can, plus creating new pages, duplicate, rename and delete pages, templates, navigation menus, shared content, site scan, deleting media, and managing users. |
The former `publisher` role is now part of `editor`; existing `publisher` assignments are treated as `editor`. Users without an
explicit valid role receive `editor`, the least powerful role.
#### The Users screen
* **My account** (every user): see your login name (set by an administrator), change the display name, the email address (needs the
current password), and the password (current, new, confirmation; CMS accounts only). You sign in with your login name and
password; the email address is contact information only.
* **Roles**: a description of each role, with your own marked.
* **User accounts** (administrators): add an account with a password, edit an account (name, email, role, optional password
reset), invite someone, block or unblock, or delete. Add, edit, invite, block, and delete ask for the administrator's own
password. You cannot block, delete, or demote yourself, and config accounts cannot be changed here.
* **Invitations** produce a one-time link that is shown once and expires after 7 days. The invitee opens it and chooses their own
password. No email is sent by the CMS.
#### Account protection
Passwords are 12 to 72 characters and are stored only as hashes. Invite tokens are stored only as hashes and work once. Secrets
never appear in API responses or the activity log. Repeated wrong passwords are rate limited, every account event is recorded in
the activity feed and audit log, and blocking, deleting, changing a role, or changing a password affects that user's sessions on
their next request. `data/.htaccess` denies web access to `users.json`.
Sessions expire after 30 minutes of inactivity and 12 hours in total by default.
When HTTPS terminates at a trusted reverse proxy, set `'cookieSecure' => true` beneath `security` in the local override.
This keeps the session cookie marked Secure even when PHP receives the proxy's internal HTTP request.
@@ -156,7 +202,12 @@ of its shared regions.
### Navigation Menus
Menu items use one line each in this format:
Menus come from your site's own navigation. The **site scan** finds each page's `<nav>`: a header, banner, or main navigation is
treated as `primary`, a footer or legal navigation as `secondary`, and the block is marked in place with
`data-neo-menu="name"`. The first page that contains a menu supplies its items, and a saved menu is never overwritten.
**Tools > Navigation Menus** (main toolbar) lists the menus. Choose **Edit** to change a menu's display name or items, one per
line:
```text
Home | /
@@ -164,13 +215,82 @@ About | /about.html
Team | /team.html | About
```
The optional third value is the label of the parent item. After saving, the generated `<nav>` can be inserted into the
currently selected editable region.
The optional third value is the label of the parent item. Saving rewrites only the list inside each marked `<nav>` on every
page (other bytes, the `<nav>` attributes, and `aria-current="page"` for the current page are handled), and skips pages that
would not change. **Delete** removes a menu from the CMS; pages keep their current navigation. **Scan site for new navigation**
finds navigation blocks a developer has added since the last scan.
The CMS does not create menus: new menus are the web developer's markup, discovered by the scan. The menu key cannot be renamed,
but the display name can.
### Media Library
**Media** uploads and lists files in four categories, decided by the file extension:
| Category | Accepted extensions |
| --- | --- |
| Imagery | jpg, jpeg, png, gif, webp |
| Documents | pdf, docx, xlsx, pptx, odt, ods, odp, txt, csv |
| Video | mp4, m4v, webm, mov, ogv |
| Audio | mp3, wav, ogg, oga, m4a, aac, flac |
Anything not in this list is refused: executables (`.exe`, `.dll`, `.bat`, `.msi`, ...), scripts and server code (`.php`, `.js`, `.sh`, ...),
HTML, SVG, XML, archives, the older `.doc`/`.xls`/`.ppt` formats, and macro-enabled Office files. Every upload is also checked
by content, not just name: the file type must match its extension; files that start like an executable or contain `<?php`,
`<?=`, `<script`, or `<%` anywhere are refused (so a code payload hidden inside an image is caught); PDFs containing
JavaScript, launch actions, or embedded files are refused; and Office/OpenDocument files are refused if they contain macros,
embedded objects, scripts, or executables. Names such as `report.exe.pdf` are refused too. These checks are best-effort hardening,
not a malware scanner, so keep server-side virus scanning where policy requires it.
Uploaded files are saved under `uploads/` with a generated name (`price-list-3f2a9c1d.pdf`); the original name is kept as the
default link text. Every file is limited by `uploads.maxFileBytes` (10 MiB by default), and PHP's `upload_max_filesize` and
`post_max_size` must allow at least that. In the editor, use the **Media library** button to insert an image, a document link, a
video player, or an audio player at the cursor. Documents are served as downloads. `uploads/.htaccess` denies executable and markup
extensions; for Nginx use the rule shown under Web Server Security.
### Site Scan
Administrators can open **Tools > Site scan** to analyse the whole site: page and folder counts, pages that can be made
editable, images without alternative text, broken local image/CSS/script references, uploaded files that no page uses, and
missing title, description, and Open Graph tags. Tick the pages and the kinds of change to make, then apply:
* **Content areas**: adds the editable class to `<main>` (or `role="main"`), otherwise to top-level `<article>` or
text-bearing `<section>` elements. Headers, navigation, footers, asides, and forms are skipped, and `<body>` is never
tagged. Pages that already contain an editable region are left alone; pages with no safe container are reported for manual
tagging.
* **Images**: adds `data-neo-image` to images outside editable regions. In the editor, clicking one opens a dialogue to pick
a Media Library image or change its address and alternative text.
* **Navigation**: marks each `<nav>` (primary/secondary) with `data-neo-menu` and pre-fills Navigation Menus from it.
* **SEO tags**: inserts only the missing title, description, `og:title`, `og:description`, and `og:image` tags, with values
taken from the page. Existing tags are never changed, and canonical and robots values are reported but not invented.
The scan edits page source directly rather than re-parsing it, so nothing else in a file changes, and a revision of every
changed page is kept in Revision history.
### Drafts and Revisions
Drafts do not alter public HTML. When opening a page with a saved draft, NeoCMS offers to load it. Publishing creates a
revision of the previous public file first. Deleting a page also retains its last revision.
revision of the previous public file first. Deleting a page also retains its last revision, and **Dashboard > Deleted pages**
lists pages whose newest revision is "Before delete" and whose file is gone, with a **Recover** button that recreates the page
from that snapshot. Revisions are pruned by the configured retention limits.
### New Pages and Templates
The **New page** section (administrators), under the page list in **Pages**, is a three-step wizard: choose a template, give the page a name, then choose a navigation group
(or none). The filename is generated from the name (`About Us` becomes `about-us.html`, numbered if taken). The page is saved
only as a private draft: no file exists, so it is not visible on the site, until it is first published (Publish Now or a
schedule). Publishing creates the file and adds a link to the chosen menu on every page. Unpublished pages appear in Pages as
"New - unpublished" and can be discarded.
Templates are the files in `cms/templates/` plus any page you choose **Create Template** for in the Pages list. A template page
is used live (later edits to it show in new pages), and the **Delete** action in the template list deletes a template file or
stops using a page as a template (the page is kept).
### Subfolder Installs
The site can live in a subfolder such as `http://localhost/neocms/`. The URL prefix (`basePath`) and the folder that contains
`cms/` (`siteRoot`) are detected automatically, page links stored by menus are prefixed accordingly, and the session cookie path
follows the CMS. Root installs are unchanged.
NeoCMS stores metadata, drafts, scheduled content, and revisions under `cms/data/`. Back up this directory together with
the website. The supplied `.htaccess` denies direct HTTP access on Apache; other web servers should deny `/cms/data/` and
@@ -190,14 +310,20 @@ The server timezone controls PHP date handling; the browser converts the selecte
## Administration Workflow
1. Open a page from **Pages**.
2. Select a region carrying the configured editable class and edit it in TinyMCE.
3. Use **Save Draft** to retain private work, or **Publish** to replace the public HTML after creating a revision.
4. Use **Schedule** when publication should occur later.
5. Check revisions, accessibility findings, SEO metadata, shared content, and menus through the relevant toolbar dialogues.
1. Open a page from **Pages** (or create one in the **New page** section under the list).
2. Select a region carrying the configured editable class and edit it in TinyMCE. Clicking an image opens the image dialogue.
3. In the editor, click **Save Draft** to keep your change private, **Publish Now** to replace the public HTML after
creating a revision, or **Schedule Publish** when publication should occur later. **Cancel** discards unapplied edits.
4. Check revisions, accessibility findings, SEO metadata, shared content, and menus through the relevant toolbar dialogues.
The Save button inside the content-editor modal only applies content to the in-browser preview. It does not write a public
file. This distinction is intentional and prevents an innocent wording change from making an unscheduled public debut.
The top bar shows the current page path on the left; your name and Log out are at the top right, with the toolbar buttons
(Dashboard, Pages, Media, SEO, Navigation Menus, Tools) and the desktop/tablet/mobile preview icons beneath. Tools holds
Revision history, Accessibility check, Shared content, Site scan, and Users. Dialogue buttons share one style: small
secondary buttons for row actions, larger buttons for actions (Save, Next, Apply), and blue for the primary action.
Save Draft in the content editor (and Apply in the image and SEO dialogues) stores the change as a private draft. It never writes
the public file, which only changes on Publish. This is intentional and prevents an innocent wording change from making an
unscheduled public debut. The page header shows **Draft:** once a draft is saved, and reopening the page offers to load it.
## Page Markup Recommendations
@@ -208,7 +334,7 @@ common authoring issues but is not a complete accessibility audit.
## Requirements
* PHP 8 running on Linux
* PHP DOM and fileinfo extensions
* PHP DOM and fileinfo extensions (the fileinfo extension is used to check uploads; the zip extension is not needed)
* PHP sessions enabled
* Write access to `cms/data/`, `cms/logs/`, `uploads/`, and managed HTML pages
* A modern browser with JavaScript enabled
@@ -225,7 +351,7 @@ Apache users should allow the supplied `.htaccess` rules. For Nginx, add equival
location ^~ /cms/data/ { deny all; }
location ^~ /cms/logs/ { deny all; }
location ~ ^/cms/(config(?:\.local)?\.php|config\.local\.php\.example|publish_scheduled\.php)$ { deny all; }
location ~ ^/uploads/.*\.(php[0-9]?|phtml|phar|cgi|pl|py|sh)$ { deny all; }
location ~ ^/uploads/.*\.(php[0-9]?|phtml|phar|cgi|pl|py|sh|exe|dll|bat|cmd|com|msi|asp|aspx|jsp|jspx|html?|xhtml|shtml|svg|xml|js|jar|swf)$ { deny all; }
```
Also disable directory listings, prevent PHP source download through server misconfiguration, serve the CMS only over
@@ -254,8 +380,9 @@ php tests/run.php
```
It exercises authentication hardening, session revocation, login throttling, iframe sandboxing, path containment, content
limits, configurable editable regions, drafts, publication, revisions, page operations, shared content, menus, and
scheduled publishing in temporary directories. See [Development and testing](docs/DEVELOPMENT.md) for the complete check
limits, configurable editable regions, drafts, publication, revisions, page operations, new-page privacy and templates,
shared content, menu detection and propagation, user accounts (roles, invitations, blocking, password confirmation, config
accounts), and scheduled publishing in temporary directories. See [Development and testing](docs/DEVELOPMENT.md) for the complete check
list used by this repository.
## Licence
+3
View File
@@ -11,6 +11,9 @@ return [
'roles' => [
'admin' => 'administrator',
],
// Live sites: store CMS data (accounts, drafts, revisions) OUTSIDE the web root, as an absolute path PHP can write to,
// for example '/var/neocms-data'. The default cms/data/ is protected only by .htaccess.
// 'dataDirectory' => '/var/neocms-data',
// Set this to true when HTTPS is terminated by a trusted reverse proxy.
'security' => [
'cookieSecure' => true,
+2 -1
View File
@@ -10,7 +10,8 @@ $config = [
'authentication' => [
// 'admin' => '$2y$10$replace-with-a-real-password-hash',
],
// Grant each user the least powerful role they need: editor, publisher, or administrator.
// Accounts here are the main administrators and are managed only in this file. Other accounts are created in the CMS (Tools > Users).
// Roles: editor (edit, publish, schedule) or administrator (everything, including users). The old 'publisher' role counts as editor.
'roles' => [
// 'admin' => 'administrator',
],
+91 -46
View File
@@ -27,14 +27,15 @@ body {
border-bottom: 1px solid #1a1a1a;
-webkit-box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
display: flex;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: "logo url user" "logo url buttons";
align-items: center;
gap: 16px;
gap: 6px 16px;
padding: 10px 16px;
box-sizing: border-box;
width: 100%;
min-width: 0;
flex-wrap: wrap;
}
/* Same-origin preview iframe; JavaScript narrows its width for responsive previews. */
@@ -50,33 +51,29 @@ body {
/* Product mark remains a fixed size while neighbouring controls wrap around it. */
.controls .logo {
background-image: url("/cms/img/logo.png");
background-image: url("../img/logo.png");
width: 60px;
height: 60px;
display: block;
flex: 0 0 60px;
grid-area: logo;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* The two toolbar rows stay together and yield horizontal space when necessary. */
/* Top right: user and Log out; the menu buttons sit directly beneath them. */
.controls .buttonContainer {
margin-left: auto;
flex: 0 1 auto;
min-width: 0;
grid-area: buttons;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 7px 12px;
}
.toolbar-row {
display: flex;
gap: 7px;
justify-content: flex-end;
margin: 3px 0;
flex-wrap: wrap;
}
.publish-controls {
opacity: 0.94;
margin: 0;
}
@@ -115,9 +112,12 @@ body {
/* Current page path and unpublished-state label. */
.controls #urlbox {
flex: 1 1 320px;
grid-area: url;
min-width: 0;
text-align: center;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.1rem;
line-height: 1.3;
color: #dee2e6;
@@ -135,14 +135,18 @@ body {
/* Compact account, role, logout, and operational-warning area. */
.controls .loggedInDetails {
flex: 0 1 auto;
grid-area: user;
justify-self: end;
color: #dee2e6;
text-align: right;
font-size: 0.9rem;
line-height: 1.4;
max-width: 260px;
}
/* Name and Log out share one line. */
.controls .loggedInDetails br:first-of-type { display: none; }
.controls .loggedInDetails .link-button { margin-left: 10px; }
.link-button {
border: 0;
padding: 0;
@@ -219,6 +223,9 @@ body {
/* Consistent form controls across SEO, scheduling, shared-content, and menu dialogues. */
.cms-dialog input[type="text"],
.cms-dialog input[type="email"],
.cms-dialog input[type="password"],
.cms-dialog select,
.cms-dialog input[type="url"],
.cms-dialog input[type="datetime-local"],
.cms-dialog textarea {
@@ -347,44 +354,34 @@ body {
color: #ff4d4d;
}
/* At medium widths the page path receives its own row. */
/* At medium widths the buttons take their own row under the user block; the path stays beside the logo. */
@media (max-width: 1100px) {
.pageContainer .controls {
gap: 12px;
}
.controls #urlbox {
order: 3;
flex-basis: 100%;
text-align: left;
}
.controls .buttonContainer {
margin-left: 0;
grid-template-areas: "logo url user" "buttons buttons buttons";
}
}
/* On small screens controls stack into touch-friendly full-width rows. */
/* On small screens controls stack into touch-friendly full-width rows; the user block stays top right. */
@media (max-width: 760px) {
.pageContainer .controls {
align-items: flex-start;
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas: "logo user" "url url" "buttons buttons";
padding: 12px;
}
.controls .logo {
width: 48px;
height: 48px;
flex-basis: 48px;
}
.controls .buttonContainer,
.controls .loggedInDetails,
.toolbar-row {
flex: 1 1 100%;
flex-wrap: wrap;
}
.controls #urlbox {
flex-basis: 100%;
max-width: none;
text-align: left;
}
.controls .buttonContainer ul {
gap: 8px;
}
@@ -501,10 +498,58 @@ body {
.newpage-content { padding: 0; background: none; border: 0; width: auto; }
#newPageForm > label[for="filename"] { margin: 4px 0 6px; font-weight: 600; }
#newPageForm input[type="text"] { width: 100%; box-sizing: border-box; padding: 9px; margin-bottom: 16px; border: 1px solid #c9cfd8; border-radius: 6px; }
#newPageForm button[type="submit"] { align-self: flex-end; padding: 9px 22px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
#newPageForm button[type="submit"]:hover { background: #2f5a8a; }
/* Primary submit buttons inside dialogue forms. */
.cms-dialog form button[type="submit"] { padding: 9px 18px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
.cms-dialog form button[type="submit"]:hover { background: #2f5a8a; }
/* Dialogue buttons use the small row-button metrics; primary actions are filled blue. */
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)) {
padding: 4px 9px; font-family: inherit; font-size: 12px; font-weight: 400; color: #374151;
background: #fff; border: 1px solid #c9cfd8; border-radius: 4px; cursor: pointer;
}
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)):hover { background: #f3f5f8; }
.ui-dialog .ui-dialog-content button.danger-text { color: #a51d2d; }
.ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content #siteScanApply {
border-color: #3b6ea5; background: #3b6ea5; color: #fff; font-weight: 600;
}
.ui-dialog .ui-dialog-content button[type="submit"]:hover, .ui-dialog .ui-dialog-content .step-next:hover, .ui-dialog .ui-dialog-content #siteScanApply:hover { background: #2f5a8a; }
#fileList .row-actions button { padding: 4px 9px; font-size: 12px; }
/* Action buttons (editor Save/Schedule/Publish, dialogue Save/Next/Apply) are a step larger than row buttons. */
#editModal .editor-actions button, .ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content .step-back, .ui-dialog .ui-dialog-content #siteScanApply { padding: 6px 14px; font-size: 13px; }
#sharedList, #menuList { margin-bottom: 16px; }
/* Device-preview buttons are icon-only; the active one is highlighted. */
.viewport-button {
min-width: 42px;
padding: 8px 10px;
}
.viewport-button.active {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -3px;
}
/* New-page wizard. */
.step-indicator { margin: 0 0 4px; font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; }
.step-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
#menuChoice { display: grid; gap: 8px; }
#newPageForm section[hidden] { display: none; }
/* Wizard lists reuse the page-table look. */
#radioList, #menuChoice { display: block; margin-bottom: 14px; }
#radioList .table-wrap, #menuChoice .table-wrap { max-height: 46vh; overflow: auto; }
/* Users dialogue. */
#usersDialog form { margin-bottom: 8px; }
#usersDialog #inviteLink { width: calc(100% - 100px); box-sizing: border-box; padding: 6px; }
/* Important administrator recommendations (for example the data directory notice). */
.important-note, .cms-dialog .scan-note.important-note { color: #b3141f; font-weight: 600; }
/* Media library: upload, category tabs, and file cards. */
.media-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 10px; }
.media-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.media-tab[aria-selected="true"] { background: #3b6ea5 !important; border-color: #3b6ea5 !important; color: #fff !important; }
.media-thumb { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; height: 130px; background: #eef2f5; color: #4a7bb7; font-weight: 600; font-size: 12px; }
.media-thumb svg { width: 40px; height: 40px; }
.media-actions { display: flex; flex-wrap: wrap; gap: 6px; }
#mediaUploadReport:empty { display: none; }
/* New page wizard sits under the page list in the Pages dialogue. */
#newPageSection { margin-top: 22px; padding-top: 6px; border-top: 1px solid #d4dde4; }
+25 -38
View File
@@ -1,9 +1,9 @@
<?php
/**
* Authenticated TinyMCE image-upload endpoint.
* Authenticated media-upload endpoint (images, documents, video, audio), also used by TinyMCE for pasted images.
*
* Files are checked by byte size, detected MIME type, and image decoding before receiving a random
* server filename. Trusting only the browser-supplied extension would be admirably optimistic.
* Every file is checked by MediaTypes::inspect (extension allow-list, content sniffing, code scanning) before it receives a
* server-generated filename. Trusting only the browser-supplied extension would be admirably optimistic.
*/
// Load credentials, role assignments, and shared application settings.
@@ -13,12 +13,13 @@ use NeoCMS\Activity;
use NeoCMS\Authentication;
use NeoCMS\FileStore;
use NeoCMS\Logger;
use NeoCMS\MediaTypes;
use NeoCMS\SecurityHeaders;
SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
// Authentication is required before any upload details are processed.
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
if (!$authentication->isLoggedIn()) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorised']);
@@ -48,14 +49,6 @@ if (!$authentication->isValidCsrfToken($csrfToken)) {
exit;
}
// Map explicitly supported image MIME types to controlled filename extensions.
$allowed_types = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
// Apply configured byte, dimension, pixel-count, file-count, and total-storage limits.
$uploadConfig = is_array($config['uploads'] ?? null) ? $config['uploads'] : [];
$max_file_size = max(1024, (int) ($uploadConfig['maxFileBytes'] ?? 10 * 1024 * 1024));
@@ -66,7 +59,7 @@ $maxFiles = max(1, (int) ($uploadConfig['maxFiles'] ?? 2000));
$maxTotalBytes = max($max_file_size, (int) ($uploadConfig['maxTotalBytes'] ?? 500 * 1024 * 1024));
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max_file_size + 1024 * 1024) {
http_response_code(413);
echo json_encode(['error' => 'Upload request is too large.']);
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
exit;
}
@@ -77,38 +70,32 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
// Enforce the application limit even if php.ini permits larger files.
if ($file['size'] > $max_file_size) {
http_response_code(400);
echo json_encode(['error' => 'File size exceeds the maximum limit.']);
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
exit;
}
// Inspect file bytes rather than trusting the user-controlled original filename.
// A pasted image may arrive without an extension; name it from its detected type so it can be checked like any other file.
$originalName = (string) $file['name'];
if (pathinfo($originalName, PATHINFO_EXTENSION) === '') {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
$detected = (string) finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$originalName .= '.' . (['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'][$detected] ?? '');
}
if (!array_key_exists($mime_type, $allowed_types)) {
// Extension allow-list, content sniffing, code scanning and per-format checks (see MediaTypes::inspect).
try {
$inspection = MediaTypes::inspect($file['tmp_name'], $originalName, [
'maxBytes' => $max_file_size, 'maxWidth' => $maxWidth, 'maxHeight' => $maxHeight, 'maxPixels' => $maxPixels,
]);
} catch (\RuntimeException $exception) {
http_response_code(400);
echo json_encode(['error' => 'Invalid file type.']);
echo json_encode(['error' => $exception->getMessage()]);
exit;
}
// Confirm that an image decoder recognises the payload as an actual image.
$imageInfo = getimagesize($file['tmp_name']);
if ($imageInfo === false) {
http_response_code(400);
echo json_encode(['error' => 'Uploaded file is not a valid image.']);
exit;
}
[$width, $height] = $imageInfo;
if ($width > $maxWidth || $height > $maxHeight || $width * $height > $maxPixels) {
http_response_code(400);
echo json_encode(['error' => 'Image dimensions exceed the configured limit.']);
exit;
}
// Random names prevent collisions and avoid publishing the original local filename.
$extension = $allowed_types[$mime_type];
$filename = bin2hex(random_bytes(16)) . '.' . $extension;
// Generated names keep the original filename out of the URL namespace and allow only one extension.
$filename = MediaTypes::storedName($originalName, $inspection['ext']);
// Create the public upload directory on a fresh installation.
$uploadRoot = __DIR__ . '/../uploads';
@@ -164,7 +151,7 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$metadata = $store->read('media');
$originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME);
$altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200);
$metadata[$filename] = ['alt' => $altText];
$metadata[$filename] = ['alt' => $altText, 'original' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', basename($originalName)) ?? '', 0, 200), 'by' => $authentication->getLoggedInUser()];
try {
$store->write('media', $metadata);
} catch (\Throwable $exception) {
@@ -180,11 +167,11 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
// The file is saved by now, so a failure to write the audit entry must not fail the upload.
try {
(new Activity($store, new Logger($config['audit'] ?? true, $config['security'] ?? [])))
->record($authentication->getLoggedInUser(), 'Uploaded image', $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
->record($authentication->getLoggedInUser(), 'Uploaded ' . (['imagery' => 'image', 'documents' => 'document', 'video' => 'video', 'audio' => 'audio'][$inspection['category']]), $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
} catch (\Throwable $exception) {
// Deliberately ignored.
}
echo json_encode(['location' => $location]);
echo json_encode(['location' => $location, 'name' => $filename, 'category' => $inspection['category']]);
} else {
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
+101 -27
View File
@@ -8,7 +8,7 @@ use NeoCMS\Authentication;
use NeoCMS\SecurityHeaders;
// Prepare the session values required by both the rendered shell and JavaScript client.
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
$csrfToken = $authentication->getCsrfToken();
// Only a single valid CSS class is allowed; malformed values fall back safely.
$editableClass = $config['editableClass'] ?? 'editable';
@@ -54,20 +54,20 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<div class="buttonContainer">
<div class="toolbar-row">
<button class="headerButton" id="dashboardButton">Dashboard</button>
<button class="headerButton manage-only" id="newPage">New Page</button>
<button class="headerButton" id="selectPage">Pages</button>
<button class="headerButton" id="mediaButton">Media</button>
<button class="headerButton" id="seoButton">SEO</button>
<button class="headerButton manage-only" id="menusButton">Navigation Menus</button>
<button class="headerButton" id="moreButton">Tools</button>
</div>
<div class="toolbar-row publish-controls">
<button class="headerButton viewport-button" data-width="100%">Desktop</button>
<button class="headerButton viewport-button" data-width="768px">Tablet</button>
<button class="headerButton viewport-button" data-width="390px">Mobile</button>
<button class="headerButton viewport-button active" data-width="100%" title="Desktop view" aria-label="Desktop view" aria-pressed="true"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4" width="19" height="12.5" rx="1.5"/><path d="M8 20.5h8M12 16.5v4"/></svg></button>
<button class="headerButton viewport-button" data-width="768px" title="Tablet view" aria-label="Tablet view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2.5" width="14" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
<button class="headerButton viewport-button" data-width="390px" title="Mobile view" aria-label="Mobile view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
</div>
</div>
<div class="loggedInDetails">
<?php echo htmlspecialchars($authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?>
<span id="whoami"><?php $profile = \NeoCMS\UserStore::fromConfig($config)->profile($authentication->getLoggedInUser()); echo htmlspecialchars($profile['name'] !== '' ? $profile['name'] : $authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?></span>
(<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>)<br/>
<button id="logoutButton" class="link-button">Log out</button><br/>
<?php
@@ -91,24 +91,8 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
</div>
</div>
<!-- Template and target path controls for administrator page creation. -->
<div id="newPageDialog" title="Create a New Page">
<div class="newpage-content">
<form id="newPageForm">
<h3 class="dialog-section">Choose a template</h3>
<div id="radioList">
<!-- JavaScript populates available filesystem templates here. -->
</div>
<label for="filename">New Filename:</label>
<input type="text" id="filename" name="filename" placeholder="Enter new filename" required>
<button type="submit">Submit</button>
</form>
</div>
</div>
<!-- Searchable page picker with role-dependent management actions. -->
<div id="fileListDialog" title="Select an Existing Page">
<div id="fileListDialog" title="Pages">
<div class="filelist-content">
<input id="pageSearch" type="search" placeholder="Search pages" aria-label="Search pages">
<div class="table-wrap">
@@ -123,6 +107,37 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<p id="pageListEmpty" class="scan-note" hidden>No pages match your search.</p>
</div>
</div>
<!-- New page wizard: template, name, navigation group. Administrators only. -->
<div id="newPageSection" class="manage-only">
<h3 class="dialog-section">New page</h3>
<div class="newpage-content">
<form id="newPageForm">
<p id="newPageStep" class="step-indicator"></p>
<section data-step="1">
<h3 class="dialog-section">Choose a template</h3>
<div id="radioList">
<!-- JavaScript populates available filesystem templates here. -->
</div>
<div class="step-actions"><button type="button" class="step-next">Next</button></div>
</section>
<section data-step="2" hidden>
<h3 class="dialog-section">Name your page</h3>
<label for="pageName">Page name</label>
<input type="text" id="pageName" maxlength="80" placeholder="For example: About us" autocomplete="off">
<p class="scan-note">Used as the page title and the navigation link text. The filename is created for you.</p>
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="button" class="step-next">Next</button></div>
</section>
<section data-step="3" hidden>
<h3 class="dialog-section">Choose a navigation group</h3>
<div id="menuChoice">
<!-- JavaScript lists the saved navigation menus here. -->
</div>
<p class="scan-note">The page is saved as a private draft. It appears on the site, and in this menu, only once you publish it.</p>
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="submit">Save</button></div>
</section>
</form>
</div>
</div>
</div>
<!-- Operational overview: pending work, schedules, activity, and filesystem warnings. -->
@@ -134,14 +149,20 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<button id="revisionsButton">Revision history</button>
<button id="accessibilityButton">Accessibility check</button>
<button id="sharedButton" class="manage-only">Shared content</button>
<button id="menusButton" class="manage-only">Navigation menus</button>
<button id="siteScanButton" class="manage-only">Site scan</button>
<button id="usersButton">Users</button>
</div>
</div>
<!-- Reusable uploaded-image browser and metadata editor. -->
<div id="mediaDialog" class="cms-dialog" title="Media Library">
<p>Select an image to insert it into the open editable region.</p>
<div class="media-toolbar">
<button type="button" id="mediaUploadButton">Upload files</button>
<input type="file" id="mediaFile" multiple hidden accept="<?php echo htmlspecialchars('.' . implode(',.', \NeoCMS\MediaTypes::extensions()), ENT_QUOTES, 'UTF-8'); ?>">
<div id="mediaTabs" class="media-tabs" role="tablist" aria-label="Media categories"></div>
</div>
<p id="mediaHint" class="scan-note"></p>
<div id="mediaUploadReport" class="scan-note" role="status" aria-live="polite"></div>
<div id="mediaList" class="media-grid"></div>
</div>
@@ -184,13 +205,65 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<!-- Line-oriented menu editor supporting an optional parent label for nesting. -->
<div id="menusDialog" class="cms-dialog" title="Navigation Menus">
<div id="menuList"></div>
<form id="menuForm">
<label>Menu name<input id="menuName" type="text" pattern="[A-Za-z0-9_-]+" required></label>
<p class="scan-note"><button id="menuScanButton" type="button">Scan site for new navigation</button> Finds navigation added to any page since the last scan.</p>
<form id="menuForm" hidden>
<h3 class="dialog-section">Editing menu: <code id="menuKey"></code></h3>
<input id="menuName" type="hidden">
<label>Menu name<input id="menuTitle" type="text" maxlength="60" placeholder="Display name"></label>
<label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | /&#10;About | /about.html&#10;Team | /team.html | About" required></textarea></label>
<button type="submit">Save menu</button>
</form>
</div>
<!-- Own account (everyone), role descriptions, and account management (administrators only). -->
<div id="usersDialog" class="cms-dialog" title="Users">
<h3 class="dialog-section">My account</h3>
<form id="profileForm" autocomplete="off">
<label>Login name <span class="scan-note">(used to sign in; set by an administrator)</span><input id="profileLogin" type="text" readonly></label>
<label>Display name<input id="profileName" type="text" maxlength="80" required autocomplete="name"></label>
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="profileEmail" type="email" maxlength="254" autocomplete="email"></label>
<label>Current password <span class="scan-note">(only needed to change the email address)</span><input id="profileCurrent" type="password" maxlength="4096" autocomplete="current-password"></label>
<button type="submit">Save details</button>
</form>
<form id="passwordForm" autocomplete="off">
<h3 class="dialog-section">Change password</h3>
<label>Current password<input id="pwCurrent" type="password" maxlength="4096" required autocomplete="current-password"></label>
<label>New password <span class="scan-note">(12 to 72 characters)</span><input id="pwNew" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
<label>Confirm new password<input id="pwConfirm" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
<button type="submit">Change password</button>
</form>
<p id="passwordManaged" class="scan-note" hidden>Your password is managed in the site configuration (config.local.php) and cannot be changed here.</p>
<h3 class="dialog-section">Roles</h3>
<div id="rolesList"></div>
<div id="userAdmin" class="manage-only">
<h3 class="dialog-section">User accounts</h3>
<div id="userList"></div>
<p class="scan-note"><button id="userAddButton" type="button">Add user</button> <button id="userInviteButton" type="button">Invite user</button> Accounts marked Config are managed in config.local.php and cannot be changed here.</p>
<form id="userForm" autocomplete="off" hidden>
<h3 class="dialog-section" id="userFormTitle"></h3>
<input id="userExisting" type="hidden">
<label id="userUsernameRow">Login name <span class="scan-note">(3 to 32 letters, numbers, . - _)</span><input id="userUsername" type="text" maxlength="32" autocomplete="off"></label>
<label>Display name<input id="userName" type="text" maxlength="80" required autocomplete="off"></label>
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="userEmail" type="email" maxlength="254" autocomplete="off"></label>
<label>Role<select id="userRole"><option value="editor">Editor</option><option value="administrator">Administrator</option></select></label>
<label id="userPasswordRow">Password <span class="scan-note" id="userPasswordNote"></span><input id="userPassword" type="password" maxlength="72" autocomplete="new-password"></label>
<label>Your password <span class="scan-note">(to confirm this change)</span><input id="userConfirm" type="password" maxlength="4096" required autocomplete="current-password"></label>
<button type="submit" id="userSubmit">Save user</button> <button type="button" id="userCancel">Cancel</button>
</form>
<form id="confirmBox" autocomplete="off" hidden>
<h3 class="dialog-section" id="confirmTitle"></h3>
<label>Your password<input id="confirmPassword" type="password" maxlength="4096" required autocomplete="current-password"></label>
<button type="submit">Confirm</button> <button type="button" id="confirmCancel">Cancel</button>
</form>
<div id="inviteResult" hidden>
<h3 class="dialog-section">Invitation link</h3>
<p class="scan-note">Send this link to the person. It is shown only once, works once, and expires in 7 days. Their login name is <strong id="inviteLogin"></strong>.</p>
<input id="inviteLink" type="text" readonly> <button type="button" id="inviteCopy">Copy link</button>
</div>
</div>
</div>
<!-- Whole-site analysis with one-click tagging of content regions, images, and SEO tags. Administrators only. -->
<div id="siteScanDialog" class="cms-dialog" title="Site Scan">
<div id="siteScanProgress" hidden>
@@ -203,6 +276,7 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<label><input type="checkbox" id="scanContent" checked> Make content areas editable</label>
<label><input type="checkbox" id="scanImages" checked> Make images editable</label>
<label><input type="checkbox" id="scanSeo" checked> Add missing SEO tags (never overwrites existing ones)</label>
<label><input type="checkbox" id="scanMenus" checked> Detect navigation menus (fills Navigation Menus; never overwrites a saved menu)</label>
</fieldset>
<div id="siteScanPages"></div>
<p class="scan-note">Each changed page keeps a revision, so this can be undone from Revision history.</p>
+474 -51
View File
@@ -76,14 +76,18 @@
});
}
/** Latest response from the users action. */
let usersData = null;
/** Configure all jQuery UI dialogues once, leaving individual workflows to populate them. */
function initialiseDialogs() {
$('.cms-dialog, #newPageDialog, #fileListDialog').hide();
$('.cms-dialog, #newPageDialog, #fileListDialog').not('#editModal').each(function () {
$('.cms-dialog, #fileListDialog').hide();
$('.cms-dialog, #fileListDialog').not('#editModal').each(function () {
$(this).dialog({autoOpen: false, modal: true, width: Math.min(760, window.innerWidth - 30)});
});
// The page list is a wide table, so it gets a wider dialogue than the other tools.
$('#fileListDialog').dialog('option', 'width', Math.min(1000, window.innerWidth - 30));
$('#fileListDialog').dialog('option', 'maxHeight', Math.round(window.innerHeight * 0.92));
$('#editModal').dialog({
autoOpen: false,
modal: true,
@@ -96,9 +100,10 @@
/** Connect toolbar controls and forms to their workflow handlers. */
function bindToolbar() {
$('#dashboardButton').on('click', function () { loadDashboard(true); });
$('#newPage').on('click', openNewPage);
$('#selectPage').on('click', openPages);
$('#mediaButton').on('click', openMedia);
$('#mediaButton').on('click', function () { openMedia(); });
$('#mediaUploadButton').on('click', function () { $('#mediaFile').trigger('click'); });
$('#mediaFile').on('change', function () { const files = Array.from(this.files); this.value = ''; if (files.length) uploadMedia(files); });
$('#seoButton').on('click', openSeo);
$('#moreButton').on('click', function () { $('#toolsDialog').dialog('open'); });
$('#revisionsButton').on('click', openRevisions);
@@ -106,18 +111,25 @@
$('#sharedButton').on('click', openShared);
$('#menusButton').on('click', openMenus);
$('#siteScanButton').on('click', openSiteScan);
$('#usersButton').on('click', openUsers);
bindUsers();
$('#siteScanApply').on('click', applySiteScan);
$('#imageForm').on('submit', applyImage);
$('#seoForm').on('submit', applySeo);
$('#scheduleForm').on('submit', schedulePage);
$('#sharedForm').on('submit', saveShared);
$('#menuForm').on('submit', saveMenu);
$('#menuScanButton').on('click', scanForNavigation);
$('#newPageForm').on('submit', createPage);
$('#newPageForm').on('click', '.step-next', function () { stepNewPage(1); });
$('#newPageForm').on('click', '.step-back', function () { stepNewPage(-1); });
$('#pageSearch').on('input', filterPages);
$('#logoutButton').on('click', logout);
$('#closeEditorBtn').on('click', function () { $('#editModal').dialog('close'); });
$('.viewport-button').on('click', function () {
$('#frameContainer').css({width: $(this).data('width'), margin: '0 auto'});
$('.viewport-button').removeClass('active').attr('aria-pressed', 'false');
$(this).addClass('active').attr('aria-pressed', 'true');
});
}
@@ -139,7 +151,8 @@
return;
}
revalidatedHref = null;
if (!draftWrite) {
// about:blank is only ever our own draft surface, so it must not reset the page path or draft state.
if (!draftWrite && href !== 'about:blank') {
draftSaved = false;
framePath = iframe.contentWindow.location.pathname;
}
@@ -202,7 +215,14 @@
tinymce.init({
selector: '#editor', height: 360, branding: false, promotion: false, license_key: 'gpl',
plugins: 'preview searchreplace autolink autosave directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount quickbars emoticons help',
toolbar: 'undo redo | blocks | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image media table | code preview fullscreen',
toolbar: 'undo redo | blocks | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image media neomedia table | code preview fullscreen',
setup: function (editor) {
editor.ui.registry.addButton('neomedia', {
text: 'Media library', icon: 'gallery', tooltip: 'Insert an uploaded image, document, video or audio file',
onAction: function () { openMedia(function (item) { editor.insertContent(mediaHtml(item)); }); }
});
},
toolbar_mode: 'wrap',
images_upload_handler: uploadImage,
automatic_uploads: true,
convert_urls: false
@@ -423,20 +443,23 @@
$('#pageSearch').val('');
$('#pageListEmpty').prop('hidden', true);
pages.forEach(function (page) {
const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { navigateTo(page.url); });
const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { page.pending ? openPendingPage(page.url) : navigateTo(page.url); });
const name = $('<td class="page-cell">').append(docIcon());
$('<button type="button" class="page-open">').text(page.name).appendTo(name);
if (page.draft) $('<span class="badge-draft">').text('Draft').appendTo(name);
if (page.draft) $('<span class="badge-draft">').text(page.pending ? 'New - unpublished' : 'Draft').appendTo(name);
row.append(name, $('<td class="page-title">').text(page.title), $('<td class="page-date">').text(formatDate(page.modified)));
const actions = $('<td class="row-actions manage-col">').toggle(permissions.manage).appendTo(row);
if (permissions.manage) {
[['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
if (permissions.manage && page.pending) {
$('<button type="button" class="danger-text">').text('Discard').on('click', function (event) { event.stopPropagation(); discardNewPage(page.url); }).appendTo(actions);
} else if (permissions.manage) {
[['Create Template', 'template'], ['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
$('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
.on('click', function (event) { event.stopPropagation(); managePage(action[1], page.url); }).appendTo(actions);
.on('click', function (event) { event.stopPropagation(); action[1] === 'template' ? setPageTemplate(page.url, true) : managePage(action[1], page.url); }).appendTo(actions);
});
}
body.append(row);
});
await loadNewPage();
$('#fileListDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); }
}
@@ -511,29 +534,118 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Load available templates and open the new-page dialogue. */
async function openNewPage() {
/** Fill the New page section of the Pages dialogue with templates and navigation groups, at step 1. */
async function loadNewPage() {
$('#newPageSection').toggle(permissions.manage);
if (!permissions.manage) return;
try {
const templates = await api('getTemplates');
const menus = permissions.manage ? await api('menus') : {};
// Both lists use the same table styling as the page picker; a row selects its radio button.
const pick = function (body, name, value, checked, cells) {
const row = $('<tr class="page-row">');
const radio = $('<input type="radio">').attr('name', name).val(value).prop('checked', checked).on('click', function (event) { event.stopPropagation(); });
row.on('click', function () { radio.prop('checked', true); });
$('<td class="check-cell">').append(radio).appendTo(row);
cells.forEach(function (cell) { row.append(cell); });
body.append(row);
};
const list = $('#radioList').empty();
const templateBody = dataTable(list, ['', 'Template', 'Source', 'Actions']);
templates.forEach(function (item, index) {
$('<label class="template-option">').append($('<input type="radio" name="item">').val(item.id).prop('checked', index === 0), docIcon(), $('<span>').text(item.name)).appendTo(list);
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(item.name));
const actions = $('<td class="row-actions">');
if (permissions.manage) actions.append(rowButton('Delete', function () { deleteTemplate(item); }, true));
pick(templateBody, 'item', item.id, index === 0, [name, $('<td class="page-title">').text(item.detail ? 'Page ' + item.detail : 'Template file'), actions]);
});
$('#newPageDialog').dialog('open');
const groups = $('#menuChoice').empty();
const groupBody = dataTable(groups, ['', 'Navigation group', 'Items']);
pick(groupBody, 'menu', '', true, [$('<td class="page-cell">').append($('<strong>').text('None')), $('<td class="page-title">').text('Do not add to navigation')]);
Object.keys(menus).forEach(function (key) {
pick(groupBody, 'menu', key, false, [$('<td class="page-cell">').append(docIcon(), $('<strong>').text(menus[key].title || key)), $('<td class="page-title">').text(menus[key].items.length + ' link(s)')]);
});
$('#pageName').val('');
showNewPageStep(1);
} catch (error) { showMessage(error.message, 'error'); }
}
/** Create a template-based page and immediately navigate the preview to it. */
/** Show one step of the new-page wizard. */
function showNewPageStep(step) {
$('#newPageForm section').each(function () { $(this).prop('hidden', Number($(this).data('step')) !== step); });
$('#newPageStep').text('Step ' + step + ' of 3');
if (step > 1) $('#newPageForm section[data-step="' + step + '"]').find('input[type="text"]').first().trigger('focus');
}
/** Move between wizard steps, checking the current step is complete before going forward. */
function stepNewPage(direction) {
const step = Number($('#newPageForm section:not([hidden])').data('step'));
if (direction > 0 && step === 1 && !$('input[name="item"]:checked').length) { showMessage('Choose a template.', 'error'); return; }
if (direction > 0 && step === 2 && !$('#pageName').val().trim()) { showMessage('Enter a page name.', 'error'); return; }
showNewPageStep(step + direction);
}
/** Create the page as a private draft (nothing is public yet) and open it for editing. */
async function createPage(event) {
event.preventDefault();
try {
const result = await api('newPage', {template: $('input[name="item"]:checked').val(), filename: $('#filename').val()}, 'POST');
$('#newPageDialog').dialog('close');
navigateTo(result.url);
const result = await api('newPage', {template: $('input[name="item"]:checked').val(), name: $('#pageName').val(), menu: $('input[name="menu"]:checked').val() || ''}, 'POST');
await openPendingPage(result.url);
showMessage(result.message, 'success');
} catch (error) { showMessage(error.message, 'error'); }
}
/** Open a page that has never been published: it exists only as a draft, so it is written into the preview. */
async function openPendingPage(uri) {
if (dirty && !window.confirm('Discard unpublished changes?')) return;
try {
const draft = await api('getDraft', {uri: uri});
if (!draft.exists) throw new Error('This new page has no saved content.');
dirty = false;
framePath = basePath + uri;
loadedDrafts.add(uri);
writingDraft = true;
$('#fileListDialog').dialog('close');
$('#frameContainer').one('load.pending', function () {
writingDraft = true;
iframeDoc.open(); iframeDoc.write(withBase(draft.content, location.origin + basePath + uri)); iframeDoc.close();
// Writing into a blank frame does not raise a second load event, so bind the editing handlers directly.
writingDraft = true;
initialiseFrame();
draftSaved = true;
updateUrl();
});
$('#frameContainer').attr('src', 'about:blank');
} catch (error) { showMessage(error.message, 'error'); }
}
/** Remove a template: a template file is deleted; a page used as a template is only unmarked (the page stays). */
async function deleteTemplate(item) {
const isPage = item.id.indexOf('page:') === 0;
const message = isPage
? 'Stop using ' + item.detail + ' as a template? The page itself is not deleted.'
: 'Delete the template file "' + item.name + '"? This cannot be undone. Pages already made from it are not affected.';
if (!window.confirm(message)) return;
try {
showMessage((await api('deleteTemplate', {template: item.id}, 'POST')).message, 'success');
loadNewPage();
} catch (error) { showMessage(error.message, 'error'); }
}
async function setPageTemplate(uri, on) {
try {
showMessage((await api('setPageTemplate', {uri: uri, value: on ? '1' : '0'}, 'POST')).message, 'success');
openPages();
} catch (error) { showMessage(error.message, 'error'); }
}
async function discardNewPage(uri) {
if (!window.confirm('Discard this unpublished page and its draft? This cannot be undone.')) return;
try {
showMessage((await api('discardNewPage', {uri: uri}, 'POST')).message, 'success');
openPages();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Load the current page's revision history and render restore controls for publishers. */
async function openRevisions() {
try {
@@ -568,42 +680,111 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Build the media library with previews, metadata controls, use counts, and deletion. */
async function openMedia() {
try {
const media = await api('media');
const list = $('#mediaList').empty();
media.forEach(function (item) {
const card = $('<div class="media-card">');
$('<img>').attr({src: item.url, alt: item.alt || ''}).on('click', function () { insertMedia(item); }).appendTo(card);
$('<strong>').text(item.name).appendTo(card);
$('<small>').text(formatBytes(item.size) + ' - used ' + item.uses + ' time(s)').appendTo(card);
const alt = $('<input type="text" placeholder="Alternative text">').val(item.alt).appendTo(card);
$('<button>').text('Save alt text').on('click', async function () {
try { showMessage((await api('updateMedia', {name: item.name, alt: alt.val()}, 'POST')).message, 'success'); } catch (error) { showMessage(error.message, 'error'); }
}).appendTo(card);
if (permissions.manage) $('<button class="danger-text">').text('Delete').on('click', function () { deleteMedia(item); }).appendTo(card);
list.append(card);
});
/** Media categories in display order; the server derives each file's category from its extension. */
const MEDIA_CATEGORIES = [['imagery', 'Imagery'], ['documents', 'Documents'], ['video', 'Video'], ['audio', 'Audio']];
let mediaItems = [];
let mediaCategory = 'all';
/** When set, the library is open to choose a file for the editor: called with the chosen item. */
let mediaPick = null;
/** Open the media library, optionally to pick a file for the open editor. */
async function openMedia(pick) {
mediaPick = typeof pick === 'function' ? pick : null;
$('#mediaUploadReport').empty();
await loadMedia();
$('#mediaHint').text(mediaPick ? 'Choose a file to insert at the cursor.' : 'Upload files and organise them by type. Open a page and use the Media library button in the editor to insert them.');
$('#mediaDialog').dialog('open');
}
/** Fetch the library and draw the category tabs and cards. */
async function loadMedia() {
try {
mediaItems = await api('media');
renderMedia();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Append a library image to the currently selected editable region. */
function insertMedia(item) {
if (!currentElement) {
showMessage('Open an editable region first, then select an image.', 'error');
return;
function mediaIcon(category) {
if (category === 'video') return svgIcon('<rect x="3" y="5" width="14" height="14" rx="2"/><path d="M17 10l4-2v8l-4-2z"/>');
if (category === 'audio') return svgIcon('<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>');
return docIcon();
}
currentElement.append($('<img>').attr({src: item.url, alt: item.alt || ''}));
/** HTML inserted into the editor for a library file, by category. */
function mediaHtml(item) {
const label = item.alt || item.original || item.name;
if (item.category === 'imagery') return $('<img>').attr({src: item.url, alt: item.alt || ''})[0].outerHTML;
if (item.category === 'video') return $('<video controls preload="metadata" style="max-width:100%">').attr('src', item.url)[0].outerHTML;
if (item.category === 'audio') return $('<audio controls preload="none">').attr('src', item.url)[0].outerHTML;
return $('<a>').attr('href', item.url).text(label + ' (' + item.ext.toUpperCase() + ', ' + formatBytes(item.size) + ')')[0].outerHTML;
}
function renderMedia() {
const counts = {all: mediaItems.length};
MEDIA_CATEGORIES.forEach(function (c) { counts[c[0]] = mediaItems.filter(function (item) { return item.category === c[0]; }).length; });
const tabs = $('#mediaTabs').empty();
[['all', 'All']].concat(MEDIA_CATEGORIES).forEach(function (c) {
$('<button type="button" role="tab" class="media-tab">').text(c[1] + ' (' + counts[c[0]] + ')').attr('aria-selected', c[0] === mediaCategory ? 'true' : 'false')
.on('click', function () { mediaCategory = c[0]; renderMedia(); }).appendTo(tabs);
});
const list = $('#mediaList').empty();
const shown = mediaItems.filter(function (item) { return mediaCategory === 'all' || item.category === mediaCategory; });
if (!shown.length) $('<p class="empty-state">').text(mediaItems.length ? 'Nothing in this category yet.' : 'No media yet. Use Upload files to add some.').appendTo(list);
shown.forEach(function (item) {
const card = $('<div class="media-card">');
const thumb = item.category === 'imagery'
? $('<img>').attr({src: item.url, alt: item.alt || ''})
: $('<div class="media-thumb">').append(mediaIcon(item.category), $('<span>').text(item.ext.toUpperCase()));
thumb.on('click', function () { if (mediaPick) chooseMedia(item); }).appendTo(card);
$('<strong>').text(item.original || item.name).attr('title', item.name).appendTo(card);
$('<small>').text(item.ext.toUpperCase() + ' - ' + formatBytes(item.size) + ' - used ' + item.uses + ' time(s)').appendTo(card);
const alt = $('<input type="text">').attr('placeholder', item.category === 'imagery' ? 'Alternative text' : 'Link text or description').val(item.alt).appendTo(card);
const actions = $('<div class="media-actions">').appendTo(card);
if (mediaPick) $('<button type="button">').text('Insert').addClass('media-insert').on('click', function () { item.alt = alt.val(); chooseMedia(item); }).appendTo(actions);
else $('<button type="button">').text('Copy link').on('click', function () { copyText(location.origin + item.url); }).appendTo(actions);
$('<button type="button">').text('Save description').on('click', async function () {
try { showMessage((await api('updateMedia', {name: item.name, alt: alt.val()}, 'POST')).message, 'success'); item.alt = alt.val(); } catch (error) { showMessage(error.message, 'error'); }
}).appendTo(actions);
if (permissions.manage) $('<button type="button" class="danger-text">').text('Delete').on('click', function () { deleteMedia(item); }).appendTo(actions);
list.append(card);
});
}
function chooseMedia(item) {
const pick = mediaPick;
mediaPick = null;
$('#mediaDialog').dialog('close');
commitEdit();
if (pick) pick(item);
}
function copyText(text) {
(navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).then(function () { showMessage('Link copied.', 'success'); }, function () { window.prompt('Copy this link:', text); });
}
/** Upload the chosen files one request each; every refusal is reported with its reason. */
async function uploadMedia(files) {
const report = $('#mediaUploadReport').empty();
let done = 0;
for (const file of files) {
const form = new FormData();
form.append('file', file, file.name);
form.append('csrf_token', csrfToken);
try {
await $.ajax({url: basePath + '/cms/image_upload.php', method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'});
done++;
} catch (xhr) {
$('<div class="issue">').text(file.name + ': ' + ((xhr.responseJSON && xhr.responseJSON.error) || 'Upload failed')).appendTo(report);
}
}
if (done) showMessage('Uploaded ' + done + ' of ' + files.length + ' file(s).', 'success');
else showMessage('No files were uploaded.', 'error');
await loadMedia();
}
/** Delete a media file, warning more firmly when public pages still reference it. */
async function deleteMedia(item) {
if (item.uses && !window.confirm('This image is used on ' + item.uses + ' page location(s). Delete it anyway?')) return;
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); openMedia(); } catch (error) { showMessage(error.message, 'error'); }
if (item.uses && !window.confirm('This file is used on ' + item.uses + ' page location(s). Delete it anyway?')) return;
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); await loadMedia(); } catch (error) { showMessage(error.message, 'error'); }
}
// Pages per request: small enough for smooth progress updates, large enough to keep request overhead low.
@@ -696,6 +877,19 @@
[s.brokenRefs, 'broken references', 'Local image, CSS, or script files that do not exist.'],
[s.seoGaps, 'missing title or description', ''], [scan.uploads.files + ' (' + scan.uploads.unused + ' unused)', 'uploaded files', '']
].forEach(function (stat) { $('<div class="stat">').attr('title', stat[2]).append($('<strong>').text(stat[0]), document.createTextNode(' ' + stat[1])).appendTo(stats); });
const menus = {};
scan.pages.forEach(function (page) {
(page.navs || []).forEach(function (nav) {
const menu = menus[nav.menu] = menus[nav.menu] || {pages: 0, links: nav.links, sigs: {}};
menu.pages++;
menu.sigs[nav.sig] = (menu.sigs[nav.sig] || 0) + 1;
});
});
const menuText = Object.keys(menus).map(function (name) {
const menu = menus[name], variants = Object.keys(menu.sigs).length;
return name + ' (' + menu.links + ' links on ' + menu.pages + ' pages' + (variants > 1 ? ', ' + variants + ' different versions' : '') + ')';
});
if (menuText.length) $('<p class="scan-note">').text('Navigation: ' + menuText.join(', ')).appendTo(summary);
$('<p class="scan-note">').text('Folders: ' + Object.keys(scan.structure).map(function (dir) { return dir + ' (' + scan.structure[dir] + ')'; }).join(', ')).appendTo(summary);
// After the first run only pages with something new to do are listed; finished pages stay out of the way.
@@ -728,7 +922,7 @@
const uris = $('#siteScanPages tbody input:checked').map(function () { return $(this).val(); }).get();
if (!uris.length) { showMessage('Select at least one page.', 'error'); return; }
if (!window.confirm('Add editing markers to ' + uris.length + ' page(s)? A revision of each is kept.')) return;
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked')};
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked'), menus: $('#scanMenus').is(':checked')};
setScanBusy(true);
let updated = 0;
try {
@@ -755,7 +949,7 @@
$('#imageAlt').val(image.is('img') ? image.attr('alt') || '' : '');
const picker = $('#imagePicker').empty();
try {
(await api('media')).forEach(function (item) {
(await api('media')).filter(function (item) { return item.category === 'imagery'; }).forEach(function (item) {
$('<img>').attr({src: item.url, alt: item.alt || '', title: item.name}).on('click', function () {
$('#imageSrc').val(item.url);
if (!$('#imageAlt').val()) $('#imageAlt').val(item.alt || '');
@@ -898,25 +1092,188 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Own account, role descriptions, and (administrators) account management. Secrets never come back from the server. */
async function openUsers() {
try {
$('#userForm, #confirmBox, #inviteResult').prop('hidden', true);
$('#profileCurrent, #pwCurrent, #pwNew, #pwConfirm').val('');
await loadUsers();
$('#toolsDialog').dialog('close');
$('#usersDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); }
}
async function loadUsers() {
const data = await api('users');
usersData = data;
$('#profileLogin').val(data.me.username);
$('#profileName').val(data.me.name || data.me.username);
$('#profileEmail').val(data.me.email);
$('#passwordForm').prop('hidden', data.me.managed);
$('#passwordManaged').prop('hidden', !data.me.managed);
const roles = dataTable($('#rolesList').empty(), ['Role', 'What it can do']);
Object.keys(data.roles).forEach(function (role) {
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(role.charAt(0).toUpperCase() + role.slice(1)));
if (role === data.me.role) name.append($('<span class="badge-pending">').text('You'));
roles.append($('<tr>').append(name, $('<td class="page-title">').text(data.roles[role])));
});
$('#userAdmin').toggle(permissions.manage);
const list = $('#userList').empty();
if (!permissions.manage) return;
const body = dataTable(list, ['Name and login name', 'Email', 'Role', 'Status', 'Actions']);
data.users.forEach(function (account) {
const isMe = account.username === data.me.username;
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(account.name || account.username), $('<span class="page-sub">').text(account.username));
const status = $('<td>');
if (account.managed) $('<span class="badge-draft">').attr('title', 'Managed in config.local.php').text('Config').appendTo(status);
else if (account.status === 'blocked') $('<span class="badge-failed">').text('Blocked').appendTo(status);
else if (account.status === 'invited') $('<span class="badge-draft">').text(account.inviteExpired ? 'Invite expired' : 'Invited').appendTo(status);
else $('<span class="badge-pending">').text('Active').appendTo(status);
const actions = $('<td class="row-actions">');
if (!account.managed && !isMe) {
if (account.status === 'invited') actions.append(rowButton('Re-invite', function () { showUserForm('reinvite', account); }));
else actions.append(rowButton('Edit', function () { showUserForm('edit', account); }));
if (account.status !== 'invited') actions.append(rowButton(account.status === 'blocked' ? 'Unblock' : 'Block', function () { blockUser(account); }));
actions.append(rowButton('Delete', function () { deleteUser(account); }, true));
}
body.append($('<tr>').append(name, $('<td class="page-date">').text(account.email), $('<td>').text(account.role.charAt(0).toUpperCase() + account.role.slice(1)), status, actions));
});
}
/** Show the add/edit/invite form; the username and password fields appear only where they apply. */
function showUserForm(mode, account) {
const isNew = mode === 'add' || mode === 'invite';
const invite = mode === 'invite' || mode === 'reinvite';
$('#inviteResult, #confirmBox').prop('hidden', true);
$('#userForm').data('mode', mode).prop('hidden', false);
$('#userFormTitle').text({add: 'Add user', edit: 'Edit user', invite: 'Invite user', reinvite: 'Re-invite user'}[mode]);
$('#userExisting').val(isNew ? '' : account.username);
$('#userUsername').val('').prop('required', mode === 'add');
$('#userUsernameRow').prop('hidden', mode !== 'add');
$('#userName').val(isNew ? '' : account.name);
$('#userEmail').val(isNew ? '' : account.email).prop('required', invite);
$('#userRole').val(isNew ? 'editor' : account.role);
$('#userPassword').val('').prop('required', mode === 'add');
$('#userPasswordRow').prop('hidden', invite);
$('#userPasswordNote').text(mode === 'edit' ? '(leave blank to keep the current password; 12 to 72 characters)' : '(12 to 72 characters)');
$('#userConfirm').val('');
$('#userSubmit').text(invite ? 'Create invitation' : 'Save user');
$('#userName').trigger('focus');
}
async function saveUserForm(event) {
event.preventDefault();
const mode = $('#userForm').data('mode');
const invite = mode === 'invite' || mode === 'reinvite';
const data = {existing: $('#userExisting').val(), name: $('#userName').val(), email: $('#userEmail').val(), role: $('#userRole').val(), confirm_password: $('#userConfirm').val()};
if (!invite) { data.password = $('#userPassword').val(); if (mode === 'add') data.username = $('#userUsername').val(); }
try {
const result = await api(invite ? 'inviteUser' : 'saveUser', data, 'POST');
$('#userForm').prop('hidden', true);
$('#userPassword, #userConfirm').val('');
if (invite) {
$('#inviteLink').val(location.origin + result.link);
$('#inviteLogin').text(result.username);
$('#inviteResult').prop('hidden', false);
}
showMessage(result.message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Ask for the administrator's own password inline; resolves to the password, or null when cancelled. */
function askPassword(title) {
return new Promise(function (resolve) {
$('#userForm, #inviteResult').prop('hidden', true);
$('#confirmTitle').text(title);
$('#confirmPassword').val('');
$('#confirmBox').prop('hidden', false).off('submit.ask').on('submit.ask', function (event) {
event.preventDefault();
const value = $('#confirmPassword').val();
$('#confirmPassword').val('');
$('#confirmBox').prop('hidden', true);
resolve(value);
});
$('#confirmCancel').off('click.ask').on('click.ask', function () { $('#confirmPassword').val(''); $('#confirmBox').prop('hidden', true); resolve(null); });
$('#confirmPassword').trigger('focus');
});
}
async function blockUser(account) {
const blocking = account.status !== 'blocked';
const password = await askPassword((blocking ? 'Block ' : 'Unblock ') + account.username + '? Confirm with your password');
if (password === null) return;
try {
showMessage((await api('blockUser', {username: account.username, blocked: blocking ? '1' : '0', confirm_password: password}, 'POST')).message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
}
async function deleteUser(account) {
if (!window.confirm('Delete ' + account.username + '? This cannot be undone.')) return;
const password = await askPassword('Delete ' + account.username + '? Confirm with your password');
if (password === null) return;
try {
showMessage((await api('deleteUser', {username: account.username, confirm_password: password}, 'POST')).message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Wire the Users dialogue forms once. */
function bindUsers() {
$('#profileForm').on('submit', async function (event) {
event.preventDefault();
try {
const result = await api('saveProfile', {name: $('#profileName').val(), email: $('#profileEmail').val(), current_password: $('#profileCurrent').val()}, 'POST');
$('#profileCurrent').val('');
$('#whoami').text(result.name);
showMessage(result.message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
});
$('#passwordForm').on('submit', async function (event) {
event.preventDefault();
try {
const result = await api('changePassword', {current_password: $('#pwCurrent').val(), new_password: $('#pwNew').val(), confirm_password: $('#pwConfirm').val()}, 'POST');
$('#pwCurrent, #pwNew, #pwConfirm').val('');
showMessage(result.message, 'success');
} catch (error) { showMessage(error.message, 'error'); }
});
$('#userAddButton').on('click', function () { showUserForm('add'); });
$('#userInviteButton').on('click', function () { showUserForm('invite'); });
$('#userCancel').on('click', function () { $('#userForm').prop('hidden', true); $('#userPassword, #userConfirm').val(''); });
$('#userForm').on('submit', saveUserForm);
$('#inviteCopy').on('click', function () {
const field = $('#inviteLink')[0];
field.select();
(navigator.clipboard ? navigator.clipboard.writeText(field.value) : Promise.reject()).then(function () { showMessage('Invitation link copied.', 'success'); }, function () { document.execCommand('copy'); showMessage('Invitation link selected; press Ctrl+C to copy.', 'success'); });
});
}
/** Load named navigation menus and their editable line-based representation. */
async function openMenus() {
try {
const menus = await api('menus');
const names = Object.keys(menus);
const list = $('#menuList').empty();
$('#menuForm').prop('hidden', true);
if (!names.length) {
$('<p class="empty-state">').text('No menus yet. Use the form below to create one.').appendTo(list);
$('<p class="empty-state">').text('No menus yet. Use "Scan site for new navigation" to find them.').appendTo(list);
} else {
const body = dataTable(list, ['Menu', 'Items', 'Last updated', 'Actions']);
names.forEach(function (name) {
const load = function () {
$('#menuName').val(name);
$('#menuKey').text(name);
$('#menuTitle').val(menus[name].title || '');
$('#menuForm').prop('hidden', false);
$('#menuItems').val(menus[name].items.map(function (item) { return item.label + ' | ' + item.url + (item.parent ? ' | ' + item.parent : ''); }).join('\n'));
};
const row = $('<tr class="page-row">').on('click', load);
$('<td class="page-cell">').append(docIcon(), $('<strong>').text(name)).appendTo(row);
const cell = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(menus[name].title || name)).appendTo(row);
if (menus[name].title) $('<span class="page-sub">').text(name).appendTo(cell);
row.append($('<td>').text(menus[name].items.length), $('<td class="page-date">').text(formatDate(menus[name].updated)),
$('<td class="row-actions">').append(rowButton('Edit', load)));
$('<td class="row-actions">').append(rowButton('Edit', load), rowButton('Delete', function () { deleteMenu(name); }, true)));
body.append(row);
});
}
@@ -926,6 +1283,48 @@
}
/** Parse menu lines, save the structure, and optionally insert its rendered navigation. */
/** Find navigation blocks no menu is attached to yet (for example, one a developer added to a page) and offer to expose them. */
async function scanForNavigation() {
const button = $('#menuScanButton').prop('disabled', true).text('Scanning...');
try {
const listing = await api('listSitePages');
const found = {};
for (let i = 0; i < listing.pages.length; i += SCAN_BATCH) {
const batch = listing.pages.slice(i, i + SCAN_BATCH);
button.text('Scanning ' + Math.min(i + SCAN_BATCH, listing.pages.length) + ' of ' + listing.pages.length + '...');
(await api('analyseSitePages', {uris: JSON.stringify(batch), allNavs: 1})).pages.forEach(function (page) {
page.navs.filter(function (nav) { return !nav.tagged; }).forEach(function (nav) {
const entry = found[nav.menu] = found[nav.menu] || {links: nav.links, pages: []};
entry.pages.push(page.uri);
});
});
}
const names = Object.keys(found);
if (!names.length) { showMessage('No new navigation found. Every navigation block is already a menu.', 'success'); return; }
const known = await api('menus');
const summary = names.map(function (name) {
return '- ' + name + ': ' + found[name].links + ' links on ' + found[name].pages.length + ' page(s)' + (known[name] ? ' (joins the existing menu)' : ' (new menu)');
}).join('\n');
if (!window.confirm('New navigation found:\n' + summary + '\n\nAdd these to Navigation Menus? Each changed page keeps a revision.')) return;
const pages = Array.from(new Set([].concat.apply([], names.map(function (name) { return found[name].pages; }))));
for (let i = 0; i < pages.length; i += APPLY_BATCH) {
await api('applySiteTagging', {uris: JSON.stringify(pages.slice(i, i + APPLY_BATCH)), options: JSON.stringify({menus: true, allNavs: true})}, 'POST');
}
showMessage('Added ' + names.length + ' menu(s) from ' + pages.length + ' page(s).', 'success');
if (!dirty) document.getElementById('frameContainer').contentWindow.location.reload();
openMenus();
} catch (error) { showMessage(error.message, 'error'); }
finally { button.prop('disabled', false).text('Scan site for new navigation'); }
}
async function deleteMenu(name) {
if (!window.confirm("Delete the menu '" + name + "'? Pages keep the navigation they already have, but it will no longer update from this menu.")) return;
try {
showMessage((await api('deleteMenu', {name: name}, 'POST')).message, 'success');
openMenus();
} catch (error) { showMessage(error.message, 'error'); }
}
async function saveMenu(event) {
event.preventDefault();
const items = $('#menuItems').val().split('\n').map(function (line) {
@@ -933,7 +1332,7 @@
return {label: (parts[0] || '').trim(), url: (parts[1] || '').trim(), parent: (parts[2] || '').trim()};
}).filter(function (item) { return item.url; });
try {
const result = await api('saveMenu', {name: $('#menuName').val(), items: JSON.stringify(items)}, 'POST');
const result = await api('saveMenu', {name: $('#menuName').val(), title: $('#menuTitle').val(), items: JSON.stringify(items)}, 'POST');
if (currentElement && window.confirm('Insert this menu into the currently selected editable region?')) {
currentElement.html(result.html);
await commitEdit(result.message + ' This page was also saved as a draft.');
@@ -987,6 +1386,15 @@
});
}
if ((dashboard.deleted || []).length) {
$('<h3 class="dialog-section">').text('Deleted pages').appendTo(content);
const body = dataTable(content, ['Page', 'Deleted', 'By', 'Actions']);
dashboard.deleted.forEach(function (item) {
body.append($('<tr>').append($('<td class="page-cell">').append(docIcon(), document.createTextNode(item.uri)), $('<td class="page-date">').text(formatDate(item.created)),
$('<td>').text(item.user || ''), $('<td class="row-actions">').append(rowButton('Recover', function () { recoverPage(item); }))));
});
}
$('<h3 class="dialog-section">').text('Recent activity').appendTo(content);
if (!dashboard.activity.length) {
$('<p class="empty-state">').text('No activity has been recorded yet.').appendTo(content);
@@ -997,6 +1405,10 @@
$('<td>').text(entry.action), $('<td class="page-title">').text(entry.target)));
});
}
if ((dashboard.notices || []).length) {
$('<h3 class="dialog-section important-note">').text('Recommendations').appendTo(content);
dashboard.notices.forEach(function (notice) { $('<p class="scan-note important-note">').text(notice).appendTo(content); });
}
if (dashboard.problems.length) {
$('<h3 class="dialog-section">').text('System problems').appendTo(content);
dashboard.problems.forEach(function (problem) { $('<div class="issue">').text(problem).appendTo(content); });
@@ -1005,6 +1417,17 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Recreate a deleted page from its snapshot, then open it. */
async function recoverPage(item) {
if (!window.confirm('Recover ' + item.uri + '? It will be published again as it was when deleted.')) return;
try {
const result = await api('restoreRevision', {id: item.id}, 'POST');
$('#dashboardDialog').dialog('close');
navigateTo(result.url);
showMessage('Page recovered: ' + result.url, 'success');
} catch (error) { showMessage(error.message, 'error'); }
}
/** Cancel one queued publication and refresh the dashboard. */
async function cancelSchedule(id) {
try { showMessage((await api('cancelSchedule', {id: id}, 'POST')).message, 'success'); loadDashboard(true); } catch (error) { showMessage(error.message, 'error'); }
+50 -7
View File
@@ -8,17 +8,23 @@ use NeoCMS\Authentication;
use NeoCMS\LoginRateLimiter;
use NeoCMS\Logger;
use NeoCMS\SecurityHeaders;
use NeoCMS\UserStore;
// Keep one escaped, user-facing error message for the rendered form.
$error = '';
// Authentication owns the session; the logger records successful and failed attempts.
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
$logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
$dataDirectory = (string) ($config['dataDirectory'] ?? (__DIR__ . '/../data'));
$rateLimiter = new LoginRateLimiter($dataDirectory, $config['security'] ?? []);
SecurityHeaders::html(false, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
// An invitation link (?invite=TOKEN) shows a set-password form instead of the login form.
$users = UserStore::fromConfig($config);
$invite = is_string($_POST['invite'] ?? null) ? $_POST['invite'] : (is_string($_GET['invite'] ?? null) ? $_GET['invite'] : '');
$invite = preg_match('/^[a-f0-9]{64}$/', $invite) ? $invite : '';
// GET displays the form, while POST validates its token and submitted credentials.
$maxLoginRequestBytes = 16 * 1024;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $maxLoginRequestBytes) {
@@ -33,6 +39,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
'anonymous'
);
$error = "Invalid request token";
} elseif ($invite !== '' && isset($_POST['invite'])) {
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
$confirm = is_string($_POST['confirm'] ?? null) ? $_POST['confirm'] : '';
if (($retryAfter = $rateLimiter->retryAfter($address, 'invite')) > 0) {
http_response_code(429);
header('Retry-After: ' . $retryAfter);
$error = 'Too many attempts. Please try again later.';
} elseif (!hash_equals($password, $confirm)) {
$error = 'The passwords do not match';
} else {
try {
$username = $users->acceptInvite($invite, $password);
$rateLimiter->clear($address, 'invite');
$authentication = new Authentication(...UserStore::authArgs($config));
$authentication->login($username, $password);
$logger->write("Invitation accepted for {$username} from {$address}", $username);
header('Location: ' . $config['basePath'] . '/cms/');
exit;
} catch (\RuntimeException $exception) {
$rateLimiter->recordFailure($address, 'invite');
$logger->write('Invitation rejected from ' . $address, 'anonymous');
$error = $exception->getMessage();
}
}
} else {
$username = is_string($_POST['username'] ?? null) ? trim($_POST['username']) : '';
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
@@ -40,7 +71,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
if (strlen($username) > 128 || strlen($password) > 4096) {
http_response_code(400);
$error = 'Invalid username or password';
$error = 'Invalid login name or password';
} elseif (($retryAfter = $rateLimiter->retryAfter($address, $username)) > 0) {
http_response_code(429);
header('Retry-After: ' . $retryAfter);
@@ -48,6 +79,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
$error = 'Too many login attempts. Please try again later.';
} elseif ($authentication->login($username, $password)) {
$rateLimiter->clear($address, $username);
$users->rehashIfNeeded($username, $password);
// Record the source address before entering the privileged interface.
$logger->write(
@@ -66,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
$username
);
$error = "Invalid username or password";
$error = "Invalid login name or password";
}
}
}
@@ -82,20 +114,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
<!-- The compact login card is intentionally independent of the heavier administration UI. -->
<div class="login-container">
<img class="logo" src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/img/loginlogo.png" alt="NeoCMS logo"/>
<h2>NeoCMS Login</h2>
<h2><?php echo $invite !== '' ? 'Set your password' : 'NeoCMS Login'; ?></h2>
<?php if (!empty($error)): ?>
<div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<!-- CSRF protection applies to login too, preventing forced authentication state changes. -->
<form method="post">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($authentication->getCsrfToken(), ENT_QUOTES, 'UTF-8'); ?>">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
<?php if ($invite !== ''): ?>
<input type="hidden" name="invite" value="<?php echo htmlspecialchars($invite, ENT_QUOTES, 'UTF-8'); ?>">
<label for="password">New password (12 to 72 characters)</label>
<input type="password" id="password" name="password" required minlength="12" maxlength="72" autocomplete="new-password" autofocus>
<label for="confirm">Confirm password</label>
<input type="password" id="confirm" name="confirm" required minlength="12" maxlength="72" autocomplete="new-password">
<button type="submit">Set password and sign in</button>
<?php else: ?>
<label for="username">Login name</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Log In</button>
<?php endif; ?>
<p class="shamelessPlug">NeoCMS &copy;<?php echo date('Y'); ?> Andy Dixon</p>
</form>
</div>
+20 -11
View File
@@ -16,7 +16,7 @@ class Authentication
/** Map of usernames to password hashes. */
private array $credentials;
/** Map of usernames to editor, publisher, or administrator roles. */
/** Map of usernames to editor or administrator roles (the legacy publisher role counts as editor). */
private array $roles;
/** Maximum permitted inactivity for an authenticated session. */
@@ -91,10 +91,7 @@ class Authentication
session_regenerate_id(true);
$_SESSION['loggedIn'] = true;
$_SESSION['loggedInUser'] = $username;
$configuredRole = $this->roles[$username] ?? 'editor';
$_SESSION['role'] = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
? $configuredRole
: 'editor';
$_SESSION['role'] = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
$_SESSION['authenticatedAt'] = time();
@@ -131,10 +128,7 @@ class Authentication
public function getRole(): string
{
$username = $this->getLoggedInUser();
$configuredRole = $this->roles[$username] ?? 'editor';
$role = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
? $configuredRole
: 'editor';
$role = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
$_SESSION['role'] = $role;
return $role;
}
@@ -147,14 +141,29 @@ class Authentication
public function can(string $capability): bool
{
$permissions = [
'editor' => ['draft', 'upload'],
'publisher' => ['draft', 'upload', 'publish', 'schedule'],
'editor' => ['draft', 'upload', 'publish', 'schedule'],
'administrator' => ['draft', 'upload', 'publish', 'schedule', 'manage'],
];
return in_array($capability, $permissions[$this->getRole()] ?? [], true);
}
/** Re-bind the current session to a changed credential so the user stays signed in while their other sessions end. */
public function renewSession(string $credentialHash): void
{
$_SESSION['credentialFingerprint'] = hash('sha256', $credentialHash);
if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
session_regenerate_id(true);
}
$_SESSION['lastRegeneratedAt'] = time();
}
/** Verify the signed-in user's password (for re-authentication before sensitive actions). */
public function verifyCurrentPassword(string $password): bool
{
return $this->credentialsAreValid($this->getLoggedInUser(), $password);
}
/** Return the session's CSRF token, generating a cryptographically random token when absent. */
public function getCsrfToken(): string
{
+494 -39
View File
@@ -45,15 +45,19 @@ final class CMSController
/** Maximum aggregate revision storage. */
private int $maxRevisionBytes;
/** Account registry (config accounts plus CMS-managed accounts). */
private UserStore $users;
/** Build the controller services and normalise all configured filesystem paths. */
public function __construct(array $config)
{
$this->config = $config;
$this->authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$this->authentication = new Authentication(...UserStore::authArgs($config));
$this->logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
// A custom data directory is primarily useful for tests and hardened deployments.
$dataDirectory = $config['dataDirectory'] ?? (__DIR__ . '/../../data');
$this->store = new FileStore((string) $dataDirectory);
$this->users = new UserStore($this->store, $config);
$this->activity = new Activity($this->store, $this->logger);
$templatesDir = realpath(__DIR__ . '/../../templates');
$documentRoot = realpath((string) ($config['siteRoot'] ?? $_SERVER['DOCUMENT_ROOT'] ?? ''));
@@ -147,6 +151,14 @@ final class CMSController
$this->requirePost('draft');
$uri = $this->requiredPost('uri');
$content = $this->requiredContentPost('content');
$this->storeDraft($uri, $content);
$this->activity('Saved draft', $uri);
$this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]);
}
/** Write a private draft (within the draft quota) and record it in the drafts index. */
private function storeDraft(string $uri, string $content): void
{
$draftName = hash('sha256', $this->paths->normaliseUri($uri)) . '.html';
$draftPath = $this->store->directory('drafts') . $draftName;
$draftBytes = $this->directoryBytes('drafts') - (is_file($draftPath) ? (int) filesize($draftPath) : 0);
@@ -161,8 +173,6 @@ final class CMSController
$drafts[$draftUri] = $entry;
return $drafts;
});
$this->activity('Saved draft', $uri);
$this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]);
}
/** Return the saved draft for one URI, if present. */
@@ -179,7 +189,7 @@ final class CMSController
$this->requirePost('schedule');
$uri = $this->requiredPost('uri');
$content = $this->requiredContentPost('content');
$this->paths->existing($uri);
$this->assertPublishable($uri);
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
if ($publishAt <= new \DateTimeImmutable()) {
throw new \RuntimeException('Publish time must be in the future');
@@ -225,35 +235,211 @@ final class CMSController
$templates[] = ['id' => $file, 'name' => pathinfo($file, PATHINFO_FILENAME)];
}
}
// Pages an administrator has marked as templates are offered too.
foreach (array_keys($this->store->read('pagetemplates')) as $uri) {
try {
$html = (string) file_get_contents($this->paths->existing($uri));
} catch (PageNotFoundException) {
continue;
}
$templates[] = ['id' => 'page:' . $uri, 'name' => $this->extractTitle($html) ?: $uri, 'detail' => $uri];
}
$this->respond($templates);
}
/** Create a new page from a selected template; administrators only. */
/** Delete a template file, or stop using a page as a template (the page itself is kept); administrators only. */
private function deleteTemplateAction(): void
{
$this->requirePost('manage');
$template = $this->requiredPost('template');
if (str_starts_with($template, 'page:')) {
$uri = $this->paths->normaliseUri(substr($template, 5));
$this->store->update('pagetemplates', function (array $marked) use ($uri) {
unset($marked[$uri]);
return $marked;
});
$this->activity('Unmarked page as template', $uri);
$this->respond(['message' => 'Page is no longer a template']);
return;
}
$file = basename($template);
$path = realpath($this->templatesDir . $file);
if (!$path || !str_starts_with($path, $this->templatesDir) || !preg_match('/\.html?$/i', $file) || !unlink($path)) {
throw new \RuntimeException('Template not found');
}
$this->activity('Deleted template', $file);
$this->respond(['message' => 'Template deleted']);
}
/** Mark a page as a template for New Page, or remove the mark; administrators only. */
private function setPageTemplateAction(): void
{
$this->requirePost('manage');
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
$this->paths->existing($uri);
$on = ($_POST['value'] ?? '') === '1';
$this->store->update('pagetemplates', function (array $marked) use ($uri, $on) {
if ($on) {
$marked[$uri] = ['user' => $this->user(), 'created' => date(DATE_ATOM)];
} else {
unset($marked[$uri]);
}
return $marked;
});
$this->activity($on ? 'Marked page as template' : 'Unmarked page as template', $uri);
$this->respond(['message' => $on ? 'Template created. It is now available in New Page.' : 'Page is no longer a template']);
}
/**
* Start a new page from a template. Nothing is written to the site: the page exists only as a private draft
* and a pending record until it is first published, so it is not visible on the front end before then.
*/
private function newPageAction(): void
{
$this->requirePost('manage');
$uri = $this->paths->normaliseNewUri($this->requiredPost('filename'));
$template = basename($this->requiredPost('template'));
$name = mb_substr(trim((string) preg_replace('/\s+/u', ' ', $this->requiredPost('name'))), 0, 80);
if ($name === '') {
throw new \RuntimeException('A page name is required');
}
$template = $this->requiredPost('template');
if (str_starts_with($template, 'page:')) {
// A page marked as a template: it must still be marked, and still exist.
$templateUri = $this->paths->normaliseUri(substr($template, 5));
if (!isset($this->store->read('pagetemplates')[$templateUri])) {
throw new \RuntimeException('Template not found');
}
$source = $this->paths->existing($templateUri);
} else {
$template = basename($template);
$source = realpath($this->templatesDir . $template);
if (!$source || !str_starts_with($source, $this->templatesDir)) {
throw new \RuntimeException('Template not found');
}
if (count($this->paths->files()) >= $this->maxManagedPages) {
}
$menu = trim((string) ($_POST['menu'] ?? ''));
if ($menu !== '' && !isset($this->store->read('menus')[$menu])) {
throw new \RuntimeException('Navigation group not found');
}
if (count($this->paths->files()) + count($this->store->read('newpages')) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit has been reached');
}
$destination = $this->paths->newPath($uri);
if (file_exists($destination)) {
throw new \RuntimeException('Page already exists');
}
$this->ensureParentDirectory($destination);
$this->paths->assertNoSymlinks($destination);
if (!copy($source, $destination)) {
throw new \RuntimeException('Template copy failed');
}
$this->activity('Created page', $uri);
$this->respond(['message' => 'Page created', 'url' => $uri]);
$uri = $this->uniqueUri($name);
$html = (string) file_get_contents($source);
$safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html = (string) preg_replace_callback('#(<title\b[^>]*>).*?(</title>)#is', fn(array $m) => $m[1] . $safeName . $m[2], $html, 1);
$this->storeDraft($uri, $html);
$entry = ['title' => $name, 'menu' => $menu, 'template' => $template, 'user' => $this->user(), 'created' => date(DATE_ATOM)];
$this->store->update('newpages', function (array $pending) use ($uri, $entry) {
$pending[$uri] = $entry;
return $pending;
});
$this->activity('Created page (unpublished)', $uri);
$this->respond(['message' => 'Page created as a draft. It is not visible on the site until you publish it.', 'url' => $uri]);
}
/** A filename derived from the page name (about-us.html), numbered when a page or pending page already uses it. */
private function uniqueUri(string $name): string
{
$ascii = function_exists('iconv') ? (string) @iconv('UTF-8', 'ASCII//TRANSLIT', $name) : $name;
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($ascii)), '-') ?: 'page';
$pending = $this->store->read('newpages');
for ($n = 1; $n < 1000; $n++) {
$uri = $this->paths->normaliseNewUri('/' . $slug . ($n > 1 ? '-' . $n : '') . '.html');
if (!isset($pending[$uri]) && !file_exists($this->paths->newPath($uri))) {
return $uri;
}
}
throw new \RuntimeException('Could not find a free filename for this page');
}
/** The pending record for a page that has been created but never published, or null. */
private function pendingPage(string $uri): ?array
{
return $this->store->read('newpages')[$this->paths->normaliseUri($uri)] ?? null;
}
/** Whether a page can be published or scheduled: it exists, or it is a new page waiting for its first publication. */
private function assertPublishable(string $uri): void
{
try {
$this->paths->existing($uri);
} catch (PageNotFoundException $exception) {
if (!$this->pendingPage($uri)) {
throw $exception;
}
}
}
/** Throw away a new page that was never published: its pending record, draft and any scheduled publication. */
private function discardNewPageAction(): void
{
$this->requirePost('manage');
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
if (!$this->pendingPage($uri)) {
throw new \RuntimeException('Unpublished page not found');
}
$this->store->update('newpages', function (array $pending) use ($uri) {
unset($pending[$uri]);
return $pending;
});
$this->deleteDraft($uri);
$staged = [];
$this->store->update('schedules', function (array $jobs) use ($uri, &$staged) {
foreach ($jobs as $id => $job) {
if (($job['uri'] ?? '') === $uri) {
$staged[] = (string) $id;
unset($jobs[$id]);
}
}
return $jobs;
});
foreach ($staged as $id) {
@unlink($this->store->directory('scheduled') . basename($id) . '.html');
}
$this->activity('Discarded unpublished page', $uri);
$this->respond(['message' => 'Unpublished page discarded']);
}
/** After a new page's first publication: add its link to the chosen menu on every page, then forget the pending record. */
private function finishNewPage(string $uri): void
{
$uri = $this->paths->normaliseUri($uri);
$pending = $this->pendingPage($uri);
if (!$pending) {
return;
}
try {
$menu = (string) ($pending['menu'] ?? '');
$items = null;
if ($menu !== '') {
$url = (string) ($this->config['basePath'] ?? '') . $uri;
$this->store->update('menus', function (array $menus) use ($menu, $url, $pending, &$items) {
if (!isset($menus[$menu])) {
return $menus;
}
$menus[$menu]['items'] = $menus[$menu]['items'] ?? [];
if (!in_array($url, array_column($menus[$menu]['items'], 'url'), true)) {
$menus[$menu]['items'][] = ['label' => (string) $pending['title'], 'url' => $url, 'parent' => ''];
$menus[$menu]['updated'] = date(DATE_ATOM);
}
$items = $menus[$menu]['items'];
return $menus;
});
if ($items !== null) {
$this->propagateMenu($menu, $items);
$this->activity('Added page to menu', $uri . ' -> ' . $menu);
}
}
} catch (\Throwable $exception) {
// The page is already live; a menu problem is reported in the log rather than failing the publish.
$this->logger->write("Adding {$uri} to its menu failed: {$exception->getMessage()}", $this->user());
}
$this->store->update('newpages', function (array $all) use ($uri) {
unset($all[$uri]);
return $all;
});
$this->deleteDraft($uri);
}
/** Rename, duplicate, or revision-then-delete an existing managed page. */
private function pageAction(): void
{
@@ -268,6 +454,10 @@ final class CMSController
if (!unlink($source)) {
throw new \RuntimeException('Unable to delete page');
}
$this->store->update('pagetemplates', function (array $marked) use ($sourceUri) {
unset($marked[$sourceUri]);
return $marked;
});
$this->activity('Deleted page', $sourceUri);
$this->respond(['message' => 'Page deleted']);
return;
@@ -284,6 +474,15 @@ final class CMSController
if (!$ok) {
throw new \RuntimeException('Page operation failed');
}
if ($operation === 'rename') {
$this->store->update('pagetemplates', function (array $marked) use ($sourceUri, $targetUri) {
if (isset($marked[$sourceUri])) {
$marked[$targetUri] = $marked[$sourceUri];
unset($marked[$sourceUri]);
}
return $marked;
});
}
$this->activity(ucfirst($operation) . 'd page', $sourceUri . ' -> ' . $targetUri);
$this->respond(['message' => 'Page ' . $operation . 'd', 'url' => $targetUri]);
}
@@ -307,6 +506,9 @@ final class CMSController
'draft' => isset($drafts[$uri]),
];
}
foreach ($this->store->read('newpages') as $uri => $entry) {
$pages[] = ['name' => $uri, 'url' => $uri, 'title' => (string) ($entry['title'] ?? ''), 'modified' => $entry['created'] ?? date(DATE_ATOM), 'draft' => true, 'pending' => true];
}
usort($pages, fn(array $a, array $b) => strcmp($a['name'], $b['name']));
$this->respond($pages);
}
@@ -368,7 +570,8 @@ final class CMSController
$this->requireCapability('manage');
$uris = $this->uriBatch($this->requiredRequest('uris'));
$basePath = (string) ($this->config['basePath'] ?? '');
$all = ['content' => true, 'images' => true, 'seo' => true];
$allNavs = !empty($_REQUEST['allNavs']);
$all = ['content' => true, 'images' => true, 'seo' => true, 'menus' => true, 'allNavs' => $allNavs];
$pages = [];
$usedUploads = [];
foreach ($uris as $uri) {
@@ -378,21 +581,21 @@ final class CMSController
continue;
}
$html = (string) file_get_contents($path);
$a = SiteAnalyser::analyse($html, $this->editableClass);
$a = SiteAnalyser::analyse($html, $this->editableClass, $allNavs);
$broken = [];
foreach ($a['refs'] as $ref) {
if ($this->paths->refExists($path, $ref['url'], $basePath) === false) {
$broken[] = $ref['url'];
}
}
if (preg_match_all('#/uploads/([a-f0-9]{32}\.(?:jpg|png|gif|webp))#', $html, $matches)) {
if (preg_match_all('#/uploads/(' . MediaTypes::nameRegex() . ')#', $html, $matches)) {
array_push($usedUploads, ...$matches[1]);
}
$plan = SiteAnalyser::apply($html, $this->editableClass, $all);
$pages[] = [
'uri' => $this->paths->uriFor($path), 'title' => $a['title'], 'editableRegions' => $a['existingRegions'], 'proposed' => $a['proposed'],
'images' => count($a['images']), 'missingAlt' => count(array_filter($a['images'], fn($i) => !$i['hasAlt'])),
'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
'navs' => $a['navs'], 'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
'changes' => $plan['changes'] ?? [],
];
}
@@ -420,18 +623,37 @@ final class CMSController
if (!is_array($options)) {
throw new \RuntimeException('Choose at least one kind of change');
}
$flags = ['content' => !empty($options['content']), 'images' => !empty($options['images']), 'seo' => !empty($options['seo'])];
$flags = ['content' => !empty($options['content']), 'images' => !empty($options['images']), 'seo' => !empty($options['seo']), 'menus' => !empty($options['menus']), 'allNavs' => !empty($options['allNavs'])];
if (!array_filter($flags)) {
throw new \RuntimeException('Choose at least one kind of change');
}
$pages = [];
$count = $this->rewritePages(function (string $html, string $uri) use ($flags, &$pages) {
$seed = [];
$basePath = (string) ($this->config['basePath'] ?? '');
$count = $this->rewritePages(function (string $html, string $uri) use ($flags, &$pages, &$seed, $basePath) {
$result = SiteAnalyser::apply($html, $this->editableClass, $flags);
if ($result) {
$pages[$uri] = $result['changes'];
// The first page seen supplies a menu's items; links become site paths so they work from any folder.
foreach ($result['menus'] as $name => $links) {
$seed[$name] ??= array_map(function (array $link) use ($uri, $basePath) {
$path = ContentDom::resolveLink($link['url'], $uri);
return ['label' => $link['label'], 'url' => $path === null ? $link['url'] : $basePath . $path, 'parent' => $link['parent']];
}, $links);
}
}
return $result['html'] ?? null;
}, 'Before auto-tagging', $uris);
if ($seed) {
// A menu someone has already saved is never replaced by a scan.
$this->store->update('menus', function (array $menus) use ($seed) {
foreach ($seed as $name => $items) {
$menus[$name] ??= ['items' => $items, 'updated' => date(DATE_ATOM)];
}
return $menus;
});
$this->activity('Detected navigation menus', implode(', ', array_keys($seed)));
}
$this->activity('Auto-tagged pages', $count . ' page(s)');
$this->respond(['message' => "Updated {$count} page(s). Each has a revision to restore.", 'updated_pages' => $count, 'pages' => $pages]);
}
@@ -466,6 +688,24 @@ final class CMSController
$this->respond($this->store->read('menus'));
}
/** Remove a saved menu. Pages keep the navigation they already contain; only the stored definition goes. */
private function deleteMenuAction(): void
{
$this->requirePost('manage');
$name = preg_replace('/[^a-zA-Z0-9_-]/', '', $this->requiredPost('name'));
$found = false;
$this->store->update('menus', function (array $menus) use ($name, &$found) {
$found = isset($menus[$name]);
unset($menus[$name]);
return $menus;
});
if (!$found) {
throw new \RuntimeException('Menu not found');
}
$this->activity('Deleted menu', $name);
$this->respond(['message' => "Menu '{$name}' deleted. Pages keep their current navigation."]);
}
/** Validate, save, render, and propagate a named navigation menu. */
private function saveMenuAction(): void
{
@@ -483,12 +723,23 @@ final class CMSController
}
$clean[] = ['label' => trim((string) ($item['label'] ?? $item['url'])), 'url' => $this->paths->normaliseLink((string) $item['url']), 'parent' => trim((string) ($item['parent'] ?? ''))];
}
$this->store->update('menus', function (array $menus) use ($name, $clean) {
$menus[$name] = ['items' => $clean, 'updated' => date(DATE_ATOM)];
// Menus come from navigation in the site's pages; the CMS edits and renames them but never creates them.
if (!isset($this->store->read('menus')[$name])) {
throw new \RuntimeException("Menu not found. New menus are added to the pages by the web developer, then found with the navigation scan.");
}
$title = mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 60);
$this->store->update('menus', function (array $menus) use ($name, $clean, $title) {
$entry = ['items' => $clean, 'updated' => date(DATE_ATOM)] + $menus[$name];
if ($title !== '') {
$entry['title'] = $title;
} else {
unset($entry['title']);
}
$menus[$name] = $entry;
return $menus;
});
$html = ContentDom::renderMenu($name, $clean);
$updated = $this->propagateMenu($name, $html);
$updated = $this->propagateMenu($name, $clean);
$this->activity('Updated menu', $name);
$this->respond(['message' => "Menu saved and updated on {$updated} page(s)", 'html' => $html, 'updated_pages' => $updated]);
}
@@ -510,6 +761,8 @@ final class CMSController
'name' => basename($file), 'url' => ($this->config['basePath'] ?? '') . $url, 'size' => filesize($file),
'modified' => date(DATE_ATOM, filemtime($file)), 'alt' => $metadata[basename($file)]['alt'] ?? '',
'uses' => $usage[$url] ?? 0,
'category' => MediaTypes::categoryForName(basename($file)), 'ext' => strtolower(pathinfo($file, PATHINFO_EXTENSION)),
'original' => $metadata[basename($file)]['original'] ?? '',
];
}
usort($items, fn(array $a, array $b) => strcmp($b['modified'], $a['modified']));
@@ -524,10 +777,10 @@ final class CMSController
$alt = is_string($_POST['alt'] ?? null) ? trim($_POST['alt']) : '';
$entry = ['alt' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $alt) ?? '', 0, 500)];
$this->store->update('media', function (array $metadata) use ($name, $entry) {
$metadata[$name] = $entry;
$metadata[$name] = $entry + ($metadata[$name] ?? []);
return $metadata;
});
$this->activity('Updated image alt text', $name);
$this->activity(MediaTypes::categoryForName($name) === 'imagery' ? 'Updated image alt text' : 'Updated media description', $name);
$this->respond(['message' => 'Media details saved']);
}
@@ -555,18 +808,201 @@ final class CMSController
$schedules = $this->store->read('schedules');
$activity = array_slice(array_reverse($this->store->read('activity')), 0, 20);
$problems = [];
foreach ([__DIR__ . '/../../data', dirname(__DIR__, 3) . '/uploads'] as $directory) {
foreach ([(string) ($this->config['dataDirectory'] ?? (__DIR__ . '/../../data')), dirname(__DIR__, 3) . '/uploads'] as $directory) {
if (!is_dir($directory) || !is_writable($directory)) {
$problems[] = $directory . ' is not writable';
}
}
$notices = [];
$dataPath = realpath($this->config['dataDirectory'] ?? (__DIR__ . '/../../data'));
$sitePath = realpath((string) ($this->config['siteRoot'] ?? dirname(__DIR__, 3)));
if ($this->authentication->can('manage') && $dataPath !== false && $sitePath !== false
&& str_starts_with(str_replace('\\', '/', $dataPath) . '/', rtrim(str_replace('\\', '/', $sitePath), '/') . '/')) {
$notices[] = 'Account and page data is stored inside the web root (cms/data). For a live site, set dataDirectory in config.local.php to a folder outside the web root so it can never be served, even if the web server ignores the .htaccess rules.';
}
$this->respond([
'notices' => $notices,
'deleted' => $this->authentication->can('publish') ? $this->deletedPages() : [],
'user' => $this->user(), 'role' => $this->authentication->getRole(),
'permissions' => ['draft' => $this->authentication->can('draft'), 'publish' => $this->authentication->can('publish'), 'schedule' => $this->authentication->can('schedule'), 'manage' => $this->authentication->can('manage')],
'drafts' => $drafts, 'schedules' => $schedules, 'activity' => $activity, 'problems' => $problems,
]);
}
/** Pages whose newest revision is a "Before delete" snapshot and whose file is gone, newest deletion first. */
private function deletedPages(): array
{
$latest = [];
foreach ($this->store->read('revisions') as $revision) {
$uri = $revision['uri'] ?? '';
if (!isset($latest[$uri]) || self::revisionTime($revision) > self::revisionTime($latest[$uri])) {
$latest[$uri] = $revision;
}
}
$deleted = [];
foreach ($latest as $uri => $revision) {
if (($revision['reason'] ?? '') !== 'Before delete') {
continue;
}
try {
$this->paths->existing($uri);
} catch (PageNotFoundException) {
$deleted[] = $revision;
} catch (\Throwable) {
}
}
usort($deleted, fn(array $a, array $b) => self::revisionTime($b) <=> self::revisionTime($a));
return $deleted;
}
/** The signed-in user's account, the role descriptions, and (administrators only) every account. Never includes hashes or tokens. */
private function usersAction(): void
{
$me = $this->user();
$profile = $this->users->profile($me);
$this->respond([
'me' => ['username' => $me, 'name' => $profile['name'], 'email' => $profile['email'], 'role' => $this->authentication->getRole(), 'managed' => $profile['managed']],
'roles' => UserStore::ROLE_INFO,
'users' => $this->authentication->can('manage') ? $this->users->accounts() : [],
]);
}
/** Update the signed-in user's own display name and email; changing the email needs the current password. */
private function saveProfileAction(): void
{
$this->requirePost();
$me = $this->user();
$current = $this->users->profile($me);
$name = $this->requiredPost('name');
$email = is_string($_POST['email'] ?? null) ? trim($_POST['email']) : '';
if (strtolower($email) !== strtolower($current['email'])) {
$this->confirmPassword('current_password');
}
$this->users->setProfile($me, $name, $email);
$this->activity('Updated own account details', $me);
$this->respond(['message' => 'Account updated', 'name' => $this->users->profile($me)['name']]);
}
/** Change the signed-in user's own password. The current password is required and other sessions end. */
private function changePasswordAction(): void
{
$this->requirePost();
$me = $this->user();
if ($this->users->isManaged($me)) {
throw new \RuntimeException('This password is managed in config.local.php');
}
$new = is_string($_POST['new_password'] ?? null) ? $_POST['new_password'] : '';
if (!hash_equals($new, is_string($_POST['confirm_password'] ?? null) ? $_POST['confirm_password'] : "\0")) {
throw new \RuntimeException('The new password and confirmation do not match');
}
$this->confirmPassword('current_password');
$hash = $this->users->setPassword($me, $new);
$this->authentication->renewSession($hash);
$this->activity('Changed own password', $me);
$this->respond(['message' => 'Password changed. Your other sessions have been signed out.']);
}
/** Add a CMS account with a password, or edit one (blank password keeps it). Administrators only, with password confirmation. */
private function saveUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$existing = is_string($_POST['existing'] ?? null) ? trim($_POST['existing']) : '';
$name = $this->requiredPost('name');
$email = is_string($_POST['email'] ?? null) ? $_POST['email'] : '';
$role = $this->requiredPost('role');
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
if ($existing === '') {
$username = $this->requiredPost('username');
$this->users->create($username, $name, $email, $role, $password);
$this->activity('Added user', $username . ' (' . $role . ')');
$this->respond(['message' => 'User added']);
return;
}
$this->refuseSelf($existing);
$before = null;
foreach ($this->users->accounts() as $account) {
if ($account['username'] === $existing) {
$before = $account;
}
}
$this->users->update($existing, $name, $email, $role, $password);
$this->activity('Updated user', $existing);
if ($before && $before['role'] !== $role) {
$this->activity('Changed user role', $existing . ': ' . $before['role'] . ' -> ' . $role);
}
if ($password !== '') {
$this->activity('Reset user password', $existing);
}
$this->respond(['message' => 'User updated']);
}
/** Create or re-issue a one-time invitation link (shown once; only its hash is stored). */
private function inviteUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$existing = is_string($_POST['existing'] ?? null) ? trim($_POST['existing']) : '';
[$username, $token] = $this->users->invite($this->requiredPost('name'), $this->requiredPost('email'), $this->requiredPost('role'), $existing);
$this->activity($existing === '' ? 'Invited user' : 'Re-issued invitation', $username);
$this->respond([
'message' => 'Invitation created. Copy the link now; it is shown only once and expires in 7 days.',
'link' => (string) ($this->config['basePath'] ?? '') . '/cms/login/?invite=' . $token,
'username' => $username,
]);
}
/** Block or unblock a CMS account; a blocked account is signed out on its next request. */
private function blockUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$username = $this->requiredPost('username');
$this->refuseSelf($username);
$blocked = ($_POST['blocked'] ?? '') === '1';
$this->users->setBlocked($username, $blocked);
$this->activity($blocked ? 'Blocked user' : 'Unblocked user', $username);
$this->respond(['message' => $blocked ? 'User blocked' : 'User unblocked']);
}
/** Delete a CMS account. */
private function deleteUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$username = $this->requiredPost('username');
$this->refuseSelf($username);
$this->users->delete($username);
$this->activity('Deleted user', $username);
$this->respond(['message' => 'User deleted']);
}
/** Stop administrators locking themselves out through the account-management actions. */
private function refuseSelf(string $username): void
{
if ($username === $this->user()) {
throw new \RuntimeException('You cannot do that to your own account. Use My account for your own details.');
}
}
/** Require the signed-in user's own password before a sensitive account action; failures are rate limited and audited. */
private function confirmPassword(string $field): void
{
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$key = 'reauth:' . $this->user();
$limiter = new LoginRateLimiter((string) ($this->config['dataDirectory'] ?? (__DIR__ . '/../../data')), $this->config['security'] ?? []);
if (($wait = $limiter->retryAfter($address, $key)) > 0) {
throw new \RuntimeException("Too many incorrect passwords. Try again in {$wait} seconds.");
}
$password = is_string($_POST[$field] ?? null) ? $_POST[$field] : '';
if ($password === '' || strlen($password) > 4096 || !$this->authentication->verifyCurrentPassword($password)) {
$limiter->recordFailure($address, $key);
$this->activity('Password confirmation failed', 'users');
throw new \RuntimeException('Your password is incorrect');
}
$limiter->clear($address, $key);
}
/** End the authenticated session after CSRF validation. */
private function logoutAction(): void
{
@@ -584,14 +1020,32 @@ final class CMSController
*/
private function publishContent(string $uri, string $content, string $reason): void
{
$isNew = false;
try {
$path = $this->paths->existing($uri);
$old = (string) file_get_contents($path);
if ($old !== $content) {
} catch (PageNotFoundException $exception) {
// A new page has no file until its first publication.
if (!$this->pendingPage($uri)) {
throw $exception;
}
if (count($this->paths->files()) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit has been reached');
}
$path = $this->paths->newPath($this->paths->normaliseNewUri($uri));
$this->ensureParentDirectory($path);
$this->paths->assertNoSymlinks($path);
$isNew = true;
}
$old = $isNew ? '' : (string) file_get_contents($path);
if (!$isNew && $old !== $content) {
$this->createRevision($uri, $old, $reason);
}
$this->atomicWrite($path, $content);
$this->captureSharedBlocks($content);
$this->activity($reason, $uri);
if ($isNew) {
$this->finishNewPage($uri);
}
}
/** Write beside the destination and rename, so a public page is never served half-written. */
@@ -764,10 +1218,11 @@ final class CMSController
}
/** Replace matching generated menus across public pages, revisioning each page first. */
private function propagateMenu(string $name, string $menuHtml): int
private function propagateMenu(string $name, array $items): int
{
$basePath = (string) ($this->config['basePath'] ?? '');
return $this->rewritePages(
fn(string $html) => ContentDom::withMenu($html, $name, $menuHtml),
fn(string $html, string $uri) => ContentDom::withMenu($html, $name, $items, $uri, $basePath),
'Before menu update'
);
}
@@ -830,7 +1285,7 @@ final class CMSController
$uses = [];
foreach ($this->paths->files() as $path) {
$html = (string) file_get_contents($path);
if (preg_match_all('#/uploads/[a-f0-9]{32}\.(?:jpg|png|gif|webp)#', $html, $matches)) {
if (preg_match_all('#/uploads/' . MediaTypes::nameRegex() . '#', $html, $matches)) {
foreach ($matches[0] as $url) {
$uses[$url] = ($uses[$url] ?? 0) + 1;
}
@@ -839,7 +1294,7 @@ final class CMSController
return $uses;
}
/** Validate that a filename belongs to the randomised image namespace created by uploads. */
/** Validate that a filename belongs to the generated media namespace created by uploads. */
private function managedMediaName(string $name): string
{
$name = basename($name);
@@ -849,10 +1304,10 @@ final class CMSController
return $name;
}
/** Return whether a filename is a random NeoCMS image name with an allowed extension. */
/** Return whether a filename is a NeoCMS-generated media name with an allowed extension. */
private function isManagedMediaName(string $name): bool
{
return preg_match('/^[a-f0-9]{32}\.(?:jpg|png|gif|webp)$/', $name) === 1;
return MediaTypes::isManagedName($name);
}
/** Extract a plain-text document title for the page picker. */
+64 -15
View File
@@ -36,35 +36,50 @@ final class ContentDom
return $dom->saveHTML();
}
/** Replace every element marked data-neo-menu="$name" with the rendered <nav>; null when nothing to change. */
public static function withMenu(string $html, string $name, string $menuHtml): ?string
/**
* Replace the list inside every element marked data-neo-menu="$name", keeping the element's own attributes
* and every other byte of the page. Null when nothing needs to change.
*/
public static function withMenu(string $html, string $name, array $items, string $uri = '', string $basePath = ''): ?string
{
if (!str_contains($html, 'data-neo-menu')) {
return null;
}
$dom = self::load($html);
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-menu="' . $name . '"]');
$sourceNav = self::load($menuHtml)->getElementsByTagName('nav')->item(0);
if ($nodes->length === 0 || !$sourceNav) {
return null;
$list = self::menuList($items, $uri, $basePath);
$flat = fn(string $s) => preg_replace('/>\s+</', '><', trim($s));
$edits = [];
foreach (SiteAnalyser::navs($html) as $nav) {
if ($nav['menu'] !== $name || $nav['close'] === null) {
continue;
}
foreach (iterator_to_array($nodes) as $node) {
$node->parentNode->replaceChild($dom->importNode($sourceNav, true), $node);
if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) {
$edits[] = [$nav['end'], $nav['close'] - $nav['end']];
}
return $dom->saveHTML();
}
rsort($edits);
foreach ($edits as [$offset, $length]) {
$html = substr($html, 0, $offset) . $list . substr($html, $offset + $length);
}
return $edits ? $html : null;
}
/** A menu as a standalone <nav> (the list itself comes from menuList). */
public static function renderMenu(string $name, array $items): string
{
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . self::menuList($items) . '</nav>';
}
/**
* Render a nested, escaped navigation list from flat parent-labelled items.
* Render a nested, escaped list from flat parent-labelled items, marking the link to the page being written.
* Circular parent references are stopped by the ancestor list rather than pursued forever.
*/
public static function renderMenu(string $name, array $items): string
public static function menuList(array $items, string $uri = '', string $basePath = ''): string
{
$children = [];
foreach ($items as $item) {
$children[$item['parent'] ?? ''][] = $item;
}
$render = function (string $parent, array $ancestors = []) use (&$render, $children): string {
$render = function (string $parent, array $ancestors = []) use (&$render, $children, $uri, $basePath): string {
if (empty($children[$parent])) {
return '';
}
@@ -72,14 +87,48 @@ final class ContentDom
foreach ($children[$parent] as $item) {
$label = htmlspecialchars($item['label'], ENT_QUOTES);
$url = htmlspecialchars($item['url'], ENT_QUOTES);
$current = $uri !== '' && self::isPage($item['url'], $uri, $basePath) ? ' aria-current="page"' : '';
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
$html .= '<li><a href="' . $url . '">' . $label . '</a>' . $nested . '</li>';
$html .= '<li><a href="' . $url . '"' . $current . '>' . $label . '</a>' . $nested . '</li>';
}
return $html . '</ul>';
};
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . $render('') . '</nav>';
return $render('');
}
/** Turn a page link into a site path (relative links resolved against the page, query/fragment kept); null for external or fragment-only links. */
public static function resolveLink(string $url, string $pageUri): ?string
{
$url = trim($url);
$path = parse_url($url, PHP_URL_PATH);
if ($url === '' || $url[0] === '#' || preg_match('#^([a-z][a-z0-9+.-]*:|//)#i', $url) || !is_string($path) || $path === '') {
return null;
}
if ($path[0] !== '/') {
$path = rtrim(str_replace('\\', '/', dirname($pageUri)), '/') . '/' . $path;
}
$out = [];
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
$segment === '..' ? array_pop($out) : $out[] = $segment;
}
return '/' . implode('/', $out) . (str_ends_with($path, '/') && $out ? '/' : '') . substr($url, strcspn($url, '?#'));
}
/** Whether a menu link points at the page with this URI (a folder link means its index.html; a subfolder install's prefix is ignored). */
private static function isPage(string $url, string $uri, string $basePath): bool
{
$resolved = self::resolveLink($url, $uri);
if ($resolved === null) {
return false;
}
$index = fn(string $p) => str_ends_with($p, '/') ? $p . 'index.html' : $p;
$path = $index((string) parse_url($resolved, PHP_URL_PATH));
$uri = $index($uri);
return $path === $uri || ($basePath !== '' && $path === $basePath . $uri);
}
/** Determine whether a document contains at least one element carrying exactly this CSS class token. */
public static function hasClass(string $html, string $class): bool
{
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace NeoCMS;
/**
* The one list of uploadable media: allowed extensions, their categories, and the safety checks applied to every upload.
*
* Only extensions listed here are accepted, so executables, scripts, archives, HTML/SVG and macro-enabled Office files are refused
* by omission. On top of that, content is sniffed and scanned for embedded code. This is best-effort hardening, not a malware scanner.
*/
final class MediaTypes
{
public const CATEGORIES = ['imagery' => 'Imagery', 'documents' => 'Documents', 'video' => 'Video', 'audio' => 'Audio'];
/** extension => category */
private const EXTENSIONS = [
'jpg' => 'imagery', 'jpeg' => 'imagery', 'png' => 'imagery', 'gif' => 'imagery', 'webp' => 'imagery',
'pdf' => 'documents', 'docx' => 'documents', 'xlsx' => 'documents', 'pptx' => 'documents',
'odt' => 'documents', 'ods' => 'documents', 'odp' => 'documents', 'txt' => 'documents', 'csv' => 'documents',
'mp4' => 'video', 'm4v' => 'video', 'webm' => 'video', 'mov' => 'video', 'ogv' => 'video',
'mp3' => 'audio', 'wav' => 'audio', 'ogg' => 'audio', 'oga' => 'audio', 'm4a' => 'audio', 'aac' => 'audio', 'flac' => 'audio',
];
/** Extensions that must never appear anywhere in a filename (for example report.exe.pdf), even though the file is renamed. */
private const DENIED_PARTS = [
'exe', 'dll', 'bat', 'cmd', 'com', 'msi', 'scr', 'ps1', 'vbs', 'vbe', 'js', 'jse', 'jar', 'wsf', 'hta', 'cpl', 'reg',
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'cgi', 'pl', 'py', 'rb', 'sh', 'bash', 'asp', 'aspx', 'jsp', 'jspx',
'html', 'htm', 'xhtml', 'shtml', 'svg', 'xml', 'swf', 'lnk', 'apk', 'app', 'dmg', 'iso', 'so', 'bin',
'docm', 'xlsm', 'pptm', 'dotm', 'xltm', 'potm', 'xlam', 'ppam',
];
/** MIME types that always mean executable or markup content, whatever the extension claims. */
private const BLOCKED_MIMES = [
'application/x-dosexec', 'application/x-executable', 'application/x-sharedlib', 'application/x-mach-binary', 'application/x-msdownload',
'application/x-elf', 'application/x-msi', 'application/java-archive', 'application/x-httpd-php', 'application/javascript',
'text/javascript', 'text/x-php', 'text/x-shellscript', 'text/x-script.python', 'text/html', 'image/svg+xml', 'text/xml', 'application/xml',
];
/** Case-insensitive markers of server-side or browser code, refused anywhere in any upload. Long enough never to occur by chance in compressed data. */
private const CODE_MARKERS = ['<?php', '<script'];
/** Three-byte markers also refused, but only in plain-text uploads: in compressed media they appear by chance in any file of a few megabytes. */
private const TEXT_CODE_MARKERS = ['<?=', '<%@', '<%='];
/** Entry names refused inside Office/OpenDocument packages. */
private const DENIED_ENTRY_ENDINGS = ['.exe', '.dll', '.bat', '.cmd', '.com', '.msi', '.scr', '.ps1', '.vbs', '.js', '.jar', '.php', '.html', '.htm', '.sh', '.py', '.pl', '.bin'];
/** Expected ODF mimetype entry per extension. */
private const ODF = ['odt' => 'application/vnd.oasis.opendocument.text', 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', 'odp' => 'application/vnd.oasis.opendocument.presentation'];
/** Expected marker entry per OOXML extension. */
private const OOXML = ['docx' => 'word/document.xml', 'xlsx' => 'xl/workbook.xml', 'pptx' => 'ppt/presentation.xml'];
public static function extensions(): array
{
return array_keys(self::EXTENSIONS);
}
public static function categoryForExtension(string $extension): ?string
{
return self::EXTENSIONS[strtolower($extension)] ?? null;
}
/** Category of a stored filename, from its extension. */
public static function categoryForName(string $name): ?string
{
return self::categoryForExtension(pathinfo($name, PATHINFO_EXTENSION));
}
/** Regular-expression fragment (unanchored) matching every stored media filename: legacy 32-hex names or slug-8hex names. */
public static function nameRegex(): string
{
return '(?:[a-f0-9]{32}|[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?-[a-f0-9]{8})\.(?:' . implode('|', self::extensions()) . ')';
}
public static function isManagedName(string $name): bool
{
return preg_match('/^' . self::nameRegex() . '$/', $name) === 1;
}
/** New stored filename: a slug of the original name, a random suffix, and exactly one dot. */
public static function storedName(string $originalName, string $extension): string
{
$base = pathinfo(basename($originalName), PATHINFO_FILENAME);
if (function_exists('iconv')) {
$base = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $base) ?: $base;
}
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($base)), '-');
$slug = trim(substr($slug, 0, 60), '-');
return ($slug !== '' ? $slug : 'file') . '-' . bin2hex(random_bytes(4)) . '.' . $extension;
}
/**
* Check an uploaded file and report how to store it.
*
* @param array $limits maxBytes, maxWidth, maxHeight, maxPixels
* @return array{ext: string, category: string, mime: string}
* @throws \RuntimeException with a message safe to show the user
*/
public static function inspect(string $path, string $originalName, array $limits = []): array
{
$parts = explode('.', strtolower(basename(str_replace('\\', '/', $originalName))));
$extension = count($parts) > 1 ? (string) end($parts) : '';
$category = self::EXTENSIONS[$extension] ?? null;
foreach (array_slice($parts, 1) as $part) {
if (in_array($part, self::DENIED_PARTS, true)) {
throw new \RuntimeException('Files of this type (.' . $part . ') are not allowed.');
}
}
if ($category === null) {
throw new \RuntimeException('This file type is not allowed. Accepted: images, documents (PDF, Word, Excel, PowerPoint, OpenDocument, text, CSV), video, and audio.');
}
$size = @filesize($path);
if ($size === false || $size < 1) {
throw new \RuntimeException('The file is empty.');
}
if (isset($limits['maxBytes']) && $size > $limits['maxBytes']) {
throw new \RuntimeException('This file is larger than the ' . round($limits['maxBytes'] / 1048576, 1) . ' MB limit.');
}
$head = (string) file_get_contents($path, false, null, 0, 8);
foreach (["MZ", "\x7fELF", "#!", "\xCF\xFA\xED\xFE", "\xFE\xED\xFA\xCF", "\xCA\xFE\xBA\xBE"] as $signature) {
if (str_starts_with($head, $signature)) {
throw new \RuntimeException('Executable files are not allowed.');
}
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string) finfo_file($finfo, $path);
finfo_close($finfo);
if (in_array($mime, self::BLOCKED_MIMES, true)) {
throw new \RuntimeException('Executable or script content is not allowed.');
}
self::assertNoCode($path, $extension === 'txt' || $extension === 'csv');
match (true) {
$category === 'imagery' => self::checkImage($path, $extension, $mime, $limits),
$extension === 'pdf' => self::checkPdf($path, $mime),
isset(self::OOXML[$extension]) || isset(self::ODF[$extension]) => self::checkPackage($path, $extension),
$extension === 'txt' || $extension === 'csv' => self::checkText($path, $mime),
default => self::checkAv($path, $extension, $mime, $category),
};
return ['ext' => $extension === 'jpeg' ? 'jpg' : $extension, 'category' => $category, 'mime' => $mime];
}
private static function assertNoCode(string $path, bool $plainText): void
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new \RuntimeException('The file could not be read.');
}
$carry = '';
while (!feof($handle)) {
$chunk = (string) fread($handle, 1048576);
$window = strtolower($carry . $chunk);
foreach ($plainText ? array_merge(self::CODE_MARKERS, self::TEXT_CODE_MARKERS) : self::CODE_MARKERS as $marker) {
if (str_contains($window, $marker)) {
fclose($handle);
throw new \RuntimeException('Files containing code are not allowed.');
}
}
$carry = substr($chunk, -8);
}
fclose($handle);
}
private static function checkImage(string $path, string $extension, string $mime, array $limits): void
{
$expected = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp'][$extension];
if ($mime !== $expected) {
throw new \RuntimeException('The file contents do not match its image type.');
}
$info = @getimagesize($path);
if ($info === false) {
throw new \RuntimeException('Uploaded file is not a valid image.');
}
[$width, $height] = $info;
if ($width > ($limits['maxWidth'] ?? 8192) || $height > ($limits['maxHeight'] ?? 8192) || $width * $height > ($limits['maxPixels'] ?? 24 * 1024 * 1024)) {
throw new \RuntimeException('Image dimensions exceed the configured limit.');
}
}
private static function checkPdf(string $path, string $mime): void
{
$data = (string) file_get_contents($path);
if ($mime !== 'application/pdf' || !str_contains(substr($data, 0, 1024), '%PDF-')) {
throw new \RuntimeException('The file contents do not match a PDF.');
}
if (preg_match('#/JavaScript|/JS\b|/Launch|/EmbeddedFile|/RichMedia#', $data)) {
throw new \RuntimeException('PDFs containing scripts, launch actions or embedded files are not allowed.');
}
}
private static function checkText(string $path, string $mime): void
{
$data = (string) file_get_contents($path);
if (str_contains($data, "\0") || !preg_match('//u', $data)) {
throw new \RuntimeException('Text files must be plain UTF-8 text.');
}
if (!str_starts_with($mime, 'text/') && $mime !== 'application/csv') {
throw new \RuntimeException('The file contents do not match a text file.');
}
}
/** Audio and video must sniff as such; when libmagic only says "octet-stream", the container signature must match the extension. */
private static function checkAv(string $path, string $extension, string $mime, string $category): void
{
if (str_starts_with($mime, 'audio/') || str_starts_with($mime, 'video/') || $mime === 'application/ogg') {
return;
}
$head = (string) file_get_contents($path, false, null, 0, 12);
$sync = strlen($head) > 1 && ord($head[0]) === 0xFF;
$matches = match ($extension) {
'mp4', 'm4v', 'mov', 'm4a' => in_array(substr($head, 4, 4), ['ftyp', 'moov', 'mdat', 'free', 'wide', 'skip', 'pnot'], true),
'webm' => str_starts_with($head, "\x1A\x45\xDF\xA3"),
'ogv', 'ogg', 'oga' => str_starts_with($head, 'OggS'),
'mp3' => str_starts_with($head, 'ID3') || ($sync && (ord($head[1]) & 0xE0) === 0xE0),
'aac' => $sync && (ord($head[1]) & 0xF0) === 0xF0,
'wav' => str_starts_with($head, 'RIFF'),
'flac' => str_starts_with($head, 'fLaC'),
default => false,
};
if ($mime !== 'application/octet-stream' || !$matches) {
throw new \RuntimeException('The file contents do not match ' . ($category === 'video' ? 'a video' : 'an audio') . ' file.');
}
}
/** OOXML and OpenDocument files are ZIP packages: read the directory only, and refuse macros, embedded objects, and executables. */
private static function checkPackage(string $path, string $extension): void
{
$entries = self::zipEntries($path);
if (isset(self::ODF[$extension])) {
$type = self::zipRead($path, $entries['mimetype'] ?? null);
if ($type !== self::ODF[$extension]) {
throw new \RuntimeException('The file contents do not match an OpenDocument file.');
}
} elseif (!isset($entries['[Content_Types].xml']) || !isset($entries[self::OOXML[$extension]])) {
throw new \RuntimeException('The file contents do not match an Office document.');
}
foreach (array_keys($entries) as $name) {
$lower = strtolower((string) $name);
$bad = str_contains($lower, 'vbaproject') || str_contains($lower, 'activex') || str_contains($lower, 'embeddings/')
|| str_starts_with($lower, 'basic/') || str_starts_with($lower, 'scripts/') || str_contains($lower, '..') || str_starts_with($lower, '/');
foreach (self::DENIED_ENTRY_ENDINGS as $ending) {
$bad = $bad || str_ends_with($lower, $ending);
}
if ($bad) {
throw new \RuntimeException('Documents containing macros, embedded objects or scripts are not allowed.');
}
}
}
/** @return array<string, array{offset: int, csize: int, usize: int, method: int}> */
private static function zipEntries(string $path): array
{
$size = (int) filesize($path);
$tail = (string) file_get_contents($path, false, null, max(0, $size - 65557));
$eocd = strrpos($tail, "PK\x05\x06");
if ($eocd === false || strlen($tail) < $eocd + 22 || !str_starts_with((string) file_get_contents($path, false, null, 0, 4), "PK\x03\x04")) {
throw new \RuntimeException('The file is not a valid document package.');
}
$count = unpack('v', substr($tail, $eocd + 10, 2))[1];
$directorySize = unpack('V', substr($tail, $eocd + 12, 4))[1];
$directoryOffset = unpack('V', substr($tail, $eocd + 16, 4))[1];
if ($count === 0xFFFF || $directorySize === 0xFFFFFFFF || $count > 5000 || $directorySize > 5 * 1048576) {
throw new \RuntimeException('The document package is too complex.');
}
$directory = (string) file_get_contents($path, false, null, $directoryOffset, $directorySize);
$entries = [];
$position = 0;
while ($position + 46 <= strlen($directory) && substr($directory, $position, 4) === "PK\x01\x02") {
$method = unpack('v', substr($directory, $position + 10, 2))[1];
$csize = unpack('V', substr($directory, $position + 20, 4))[1];
$usize = unpack('V', substr($directory, $position + 24, 4))[1];
[$nameLength, $extraLength, $commentLength] = array_values(unpack('v3', substr($directory, $position + 28, 6)));
$offset = unpack('V', substr($directory, $position + 42, 4))[1];
$entries[substr($directory, $position + 46, $nameLength)] = ['offset' => $offset, 'csize' => $csize, 'usize' => $usize, 'method' => $method];
$position += 46 + $nameLength + $extraLength + $commentLength;
}
if (!$entries) {
throw new \RuntimeException('The file is not a valid document package.');
}
return $entries;
}
/** Read one stored (uncompressed) entry, used only for the small ODF mimetype file. */
private static function zipRead(string $path, ?array $entry): string
{
if ($entry === null || $entry['method'] !== 0 || $entry['usize'] > 256) {
return '';
}
$header = (string) file_get_contents($path, false, null, $entry['offset'], 30);
if (strlen($header) < 30) {
return '';
}
[$nameLength, $extraLength] = array_values(unpack('v2', substr($header, 26, 4)));
return (string) file_get_contents($path, false, null, $entry['offset'] + 30 + $nameLength + $extraLength, $entry['usize']);
}
}
+93 -4
View File
@@ -16,7 +16,7 @@ final class SiteAnalyser
private const MIN_SECTION_TEXT = 40;
/** Describe a page: existing and proposed editable regions, images, local references, and SEO tags present. */
public static function analyse(string $html, string $editableClass): array
public static function analyse(string $html, string $editableClass, bool $allNavs = false): array
{
$c = self::scan($html, $editableClass, true);
return [
@@ -26,14 +26,24 @@ final class SiteAnalyser
'images' => array_map(fn(array $i) => ['src' => $i['src'], 'hasAlt' => $i['hasAlt'], 'inRegion' => $i['inRegion'], 'tagged' => $i['tagged']], $c['images']),
'refs' => $c['refs'],
'seo' => $c['seo'],
'navs' => array_values(array_map(
fn(array $n) => ['menu' => $n['menu'] ?? ($allNavs ? $n['name'] : $n['slot']), 'links' => count($n['links']), 'tagged' => $n['menu'] !== null, 'sig' => md5(implode("\n", array_column($n['links'], 'url')))],
array_filter($c['navs'], fn(array $n) => $n['menu'] !== null || ($allNavs ? $n['name'] : $n['slot']) !== null)
)),
];
}
/** Every navigation block in a page with its byte offsets, links and any data-neo-menu name (used to replace menus in place). */
public static function navs(string $html): array
{
return self::navList($html, self::elements($html));
}
/**
* Insert the requested markers into a page.
*
* @param array $options Booleans: content, images, seo.
* @return array|null ['html' => string, 'changes' => string[]], or null when the page needs no change.
* @param array $options Booleans: content, images, seo, menus; allNavs also names navs that are not clearly header or footer menus.
* @return array|null ['html' => string, 'changes' => string[], 'menus' => [name => links]], or null when the page needs no change.
*/
public static function apply(string $html, string $editableClass, array $options): ?array
{
@@ -64,6 +74,19 @@ final class SiteAnalyser
$edits[] = $seo['edit'];
array_push($changes, ...$seo['changes']);
}
$menus = [];
if (!empty($options['menus'])) {
$named = array_filter(array_column($c['navs'], 'menu'));
foreach ($c['navs'] as $nav) {
$name = !empty($options['allNavs']) ? $nav['name'] : $nav['slot'];
// One nav per name per page, and never over a menu the page already marks by hand.
if ($nav['menu'] === null && $name !== null && !isset($menus[$name]) && !in_array($name, $named, true)) {
$edits[] = [$nav['insertAt'], ' data-neo-menu="' . $name . '"'];
$changes[] = 'Marked ' . $name . ' navigation as a menu (' . count($nav['links']) . ' links)';
$menus[$name] = $nav['links'];
}
}
}
if (!$edits) {
return null;
}
@@ -72,7 +95,72 @@ final class SiteAnalyser
foreach ($edits as [$offset, $text]) {
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
}
return ['html' => $html, 'changes' => $changes];
return ['html' => $html, 'changes' => $changes, 'menus' => $menus];
}
/** Navigation blocks: <nav> or role=navigation, classified as the primary (header) or secondary (footer) menu when recognisable. */
private static function navList(string $html, array $els): array
{
$navs = [];
foreach ($els as $k => $e) {
if ($e['name'] !== 'nav' && strtolower((string) self::attr($e['attrs'], 'role')) !== 'navigation') {
continue;
}
$links = [];
foreach ($els as $a) {
if ($a['name'] !== 'a' || ($href = self::attr($a['attrs'], 'href')) === null) {
continue;
}
$inside = false;
$lis = [];
for ($p = $a['parent']; $p >= 0; $p = $els[$p]['parent']) {
if ($p === $k) {
$inside = true;
break;
}
if ($els[$p]['name'] === 'li') {
$lis[] = $p;
}
}
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$label = self::text($html, $a) ?: trim((string) self::attr($a['attrs'], 'aria-label'));
if (!$inside || $href === '' || $label === '' || stripos($href, 'javascript:') === 0) {
continue;
}
// A link inside a nested list is a child of the link that owns the enclosing list item.
$parent = '';
if (isset($lis[1])) {
foreach ($els as $b) {
if ($b['name'] === 'a' && $b['parent'] === $lis[1]) {
$parent = self::text($html, $b);
break;
}
}
}
$links[] = ['label' => $label, 'url' => $href, 'parent' => $parent];
}
$ancestors = [];
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
$ancestors[] = $els[$p]['name'] . ':' . strtolower((string) self::attr($els[$p]['attrs'], 'role'));
}
$in = fn(string $tag, string $role) => in_array($tag . ':', $ancestors, true) || in_array(':' . $role, $ancestors, true) || in_array($tag . ':' . $role, $ancestors, true);
$label = strtolower(self::attr($e['attrs'], 'aria-label') . ' ' . self::attr($e['attrs'], 'class') . ' ' . self::attr($e['attrs'], 'id'));
$slot = null;
if ($links) {
if ($in('footer', 'contentinfo') || preg_match('/\b(footer|secondary|legal)\b/', $label)) {
$slot = 'secondary';
} elseif ($in('header', 'banner') || preg_match('/\b(main|primary|site-nav|top)\b/', $label)) {
$slot = 'primary';
}
}
$navs[] = [
'menu' => ($m = self::attr($e['attrs'], 'data-neo-menu')) !== null && $m !== '' ? $m : null,
'slot' => $slot, 'name' => $slot ?? ($links ? (trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(substr((string) (self::attr($e['attrs'], 'aria-label') ?: self::attr($e['attrs'], 'id')), 0, 40))), '-') ?: 'navigation') : null), 'links' => $links, 'attrs' => $e['attrs'],
'end' => $e['end'], 'close' => $e['close'], 'insertAt' => $e['start'] + 1 + strlen($e['name']),
];
}
return $navs;
}
/** Tokenise a page and gather everything analyse() and apply() need. */
@@ -163,6 +251,7 @@ final class SiteAnalyser
}
return [
'navs' => self::navList($html, $els),
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
'seo' => [
+430
View File
@@ -0,0 +1,430 @@
<?php
namespace NeoCMS;
/**
* Account registry: accounts from config.local.php are authoritative and locked; accounts created in the CMS live in data/users.json.
*
* Only active CMS accounts and config accounts ever reach Authentication, so blocking or deleting an account also ends its sessions.
* Password hashes and invite tokens never leave this class except as the hash handed to Authentication.
*/
final class UserStore
{
public const ROLES = ['editor', 'administrator'];
public const INVITE_LIFETIME = 604800;
public const MIN_PASSWORD = 12;
public const MAX_PASSWORD = 72;
/** Plain-language role descriptions shown on the Users screen. */
public const ROLE_INFO = [
'editor' => 'Edits content and saves drafts, uploads images, publishes and schedules pages, and restores revisions or deleted pages. Cannot change the site structure or manage users.',
'administrator' => 'Everything an editor can do, plus creating, duplicating, renaming and deleting pages, templates, navigation menus, shared content, site scan, deleting media, and managing users.',
];
public function __construct(private FileStore $store, private array $config)
{
}
/** Build a store from the site configuration. */
public static function fromConfig(array $config): self
{
$directory = (string) ($config['dataDirectory'] ?? (dirname(__DIR__, 2) . '/data'));
return new self(new FileStore($directory), $config);
}
/** Arguments for `new Authentication(...)`: config credentials plus active CMS accounts, roles, and session options. */
public static function authArgs(array $config): array
{
[$credentials, $roles] = self::fromConfig($config)->forAuth();
return [$credentials, $roles, $config['security'] ?? []];
}
/** Map an arbitrary configured role onto the two supported roles (the old publisher role is an editor). */
public static function normaliseRole(mixed $role): string
{
return $role === 'administrator' ? 'administrator' : 'editor';
}
/** Credentials and roles of every account allowed to sign in. */
public function forAuth(): array
{
$credentials = [];
$roles = [];
foreach ($this->data()['users'] as $username => $record) {
if (($record['status'] ?? '') === 'active' && is_string($record['hash'] ?? null) && $record['hash'] !== '') {
$credentials[$username] = $record['hash'];
$roles[$username] = self::normaliseRole($record['role'] ?? 'editor');
}
}
foreach ($this->configAccounts() as $username => $account) {
$credentials[$username] = $account['hash'];
$roles[$username] = $account['role'];
}
return [$credentials, $roles];
}
/** The name and email shown for an account, or blanks. */
public function profile(string $username): array
{
foreach ($this->accounts() as $account) {
if ($account['username'] === $username) {
return ['name' => $account['name'], 'email' => $account['email'], 'managed' => $account['managed']];
}
}
return ['name' => '', 'email' => '', 'managed' => false];
}
/** Every account without secrets: config accounts first (locked), then CMS accounts. */
public function accounts(): array
{
$data = $this->data();
$list = [];
foreach ($this->configAccounts() as $username => $account) {
$profile = $data['profiles'][$username] ?? [];
$list[] = [
'username' => $username, 'name' => (string) ($profile['name'] ?? ''), 'email' => (string) ($profile['email'] ?? ''),
'role' => $account['role'], 'status' => 'active', 'managed' => true, 'created' => '', 'inviteExpired' => false,
];
}
foreach ($data['users'] as $username => $record) {
if (isset($this->configAccounts()[$username])) {
continue;
}
$list[] = [
'username' => $username, 'name' => (string) ($record['name'] ?? ''), 'email' => (string) ($record['email'] ?? ''),
'role' => self::normaliseRole($record['role'] ?? 'editor'), 'status' => (string) ($record['status'] ?? 'active'),
'managed' => false, 'created' => (string) ($record['created'] ?? ''),
'inviteExpired' => ($record['status'] ?? '') === 'invited' && (int) ($record['invite']['expires'] ?? 0) < time(),
];
}
return $list;
}
public function isManaged(string $username): bool
{
return isset($this->configAccounts()[$username]);
}
/** Set display name and email. Config accounts keep their credentials in config; only this profile overlay is stored. */
public function setProfile(string $username, string $name, string $email): void
{
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
$managed = $this->isManaged($username);
$this->store->update('users', function (array $data) use ($username, $name, $email, $managed): array {
$data = $this->shape($data);
$this->assertEmailFree($data, $email, $username);
if ($managed) {
$data['profiles'][$username] = ['name' => $name, 'email' => $email];
} elseif (isset($data['users'][$username])) {
$data['users'][$username]['name'] = $name;
$data['users'][$username]['email'] = $email;
$data['users'][$username]['updated'] = date(DATE_ATOM);
} else {
throw new \RuntimeException('Account not found');
}
return $data;
});
}
/** Create an active CMS account with a password. */
public function create(string $username, string $name, string $email, string $role, string $password): void
{
$username = trim($username);
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
$this->assertRole($role);
$this->assertPassword($password, $username, $email);
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
$data = $this->shape($data);
$this->assertUsernameFree($data, $username);
$this->assertEmailFree($data, $email, $username);
$now = date(DATE_ATOM);
$data['users'][$username] = ['hash' => $hash, 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'active', 'created' => $now, 'updated' => $now];
return $data;
});
}
/** Edit a CMS account; a blank password leaves the current one. Config accounts are refused. */
public function update(string $username, string $name, string $email, string $role, string $password = ''): void
{
$this->assertNotManaged($username);
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
$this->assertRole($role);
$hash = null;
if ($password !== '') {
$this->assertPassword($password, $username, $email);
$hash = password_hash($password, PASSWORD_DEFAULT);
}
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
$data = $this->shape($data);
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
$this->assertEmailFree($data, $email, $username);
$record['name'] = $name;
$record['email'] = $email;
$record['role'] = $role;
if ($hash !== null) {
$record['hash'] = $hash;
}
$record['updated'] = date(DATE_ATOM);
$data['users'][$username] = $record;
return $data;
});
}
/** Change a CMS account's own password. Returns the new hash so the caller can refresh the session. */
public function setPassword(string $username, string $password): string
{
$this->assertNotManaged($username, 'This password is managed in config.local.php');
$profile = $this->profile($username);
$this->assertPassword($password, $username, $profile['email']);
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($username, $hash): array {
$data = $this->shape($data);
if (!isset($data['users'][$username])) {
throw new \RuntimeException('Account not found');
}
$data['users'][$username]['hash'] = $hash;
$data['users'][$username]['updated'] = date(DATE_ATOM);
return $data;
});
return $hash;
}
/** Upgrade a stored hash after a successful login when PHP's default algorithm or cost has changed. */
public function rehashIfNeeded(string $username, string $password): void
{
$record = $this->data()['users'][$username] ?? null;
if ($record && !$this->isManaged($username) && is_string($record['hash'] ?? null) && $record['hash'] !== '' && password_needs_rehash($record['hash'], PASSWORD_DEFAULT)) {
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($username, $hash): array {
$data = $this->shape($data);
if (isset($data['users'][$username])) {
$data['users'][$username]['hash'] = $hash;
}
return $data;
});
}
}
public function setBlocked(string $username, bool $blocked): void
{
$this->assertNotManaged($username);
$this->store->update('users', function (array $data) use ($username, $blocked): array {
$data = $this->shape($data);
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
if ($blocked) {
$record['status'] = 'blocked';
} elseif (($record['status'] ?? '') === 'blocked') {
$record['status'] = ($record['hash'] ?? '') === '' ? 'invited' : 'active';
}
$record['updated'] = date(DATE_ATOM);
$data['users'][$username] = $record;
return $data;
});
}
public function delete(string $username): void
{
$this->assertNotManaged($username);
$this->store->update('users', function (array $data) use ($username): array {
$data = $this->shape($data);
if (!isset($data['users'][$username])) {
throw new \RuntimeException('Account not found');
}
unset($data['users'][$username]);
return $data;
});
}
/** Create (or re-issue) a pending invitation. Returns the username and the one-time token, which is stored only as a hash. */
public function invite(string $name, string $email, string $role, string $existing = ''): array
{
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
if ($email === '') {
throw new \RuntimeException('An email address is required to invite someone');
}
$this->assertRole($role);
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$username = '';
$this->store->update('users', function (array $data) use ($name, $email, $role, $existing, $tokenHash, &$username): array {
$data = $this->shape($data);
$now = date(DATE_ATOM);
$invite = ['token' => $tokenHash, 'expires' => time() + self::INVITE_LIFETIME];
if ($existing !== '') {
$record = $data['users'][$existing] ?? null;
if (!$record || ($record['status'] ?? '') !== 'invited') {
throw new \RuntimeException('Only a pending invitation can be re-issued');
}
$this->assertEmailFree($data, $email, $existing);
$username = $existing;
$data['users'][$username] = array_merge($record, ['name' => $name, 'email' => $email, 'role' => $role, 'invite' => $invite, 'updated' => $now]);
return $data;
}
$this->assertEmailFree($data, $email, '');
$username = $this->generateUsername($data, $email);
$data['users'][$username] = ['hash' => '', 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'invited', 'invite' => $invite, 'created' => $now, 'updated' => $now];
return $data;
});
return [$username, $token];
}
/** Redeem an invitation: the token is single use and expires. Returns the username. Every failure gives the same message. */
public function acceptInvite(string $token, string $password): string
{
$failure = 'This invitation is invalid or has expired';
$wanted = hash('sha256', $token);
$accepted = null;
$hash = null;
$data = $this->data();
foreach ($data['users'] as $username => $record) {
if (($record['status'] ?? '') === 'invited' && is_string($record['invite']['token'] ?? null)
&& hash_equals($record['invite']['token'], $wanted) && (int) ($record['invite']['expires'] ?? 0) >= time()) {
$accepted = $username;
}
}
if ($accepted === null || $token === '') {
throw new \RuntimeException($failure);
}
$this->assertPassword($password, $accepted, (string) ($data['users'][$accepted]['email'] ?? ''));
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($accepted, $wanted, $hash, $failure): array {
$data = $this->shape($data);
$record = $data['users'][$accepted] ?? null;
if (!$record || ($record['status'] ?? '') !== 'invited' || !hash_equals((string) ($record['invite']['token'] ?? ''), $wanted)) {
throw new \RuntimeException($failure);
}
unset($record['invite']);
$record['hash'] = $hash;
$record['status'] = 'active';
$record['updated'] = date(DATE_ATOM);
$data['users'][$accepted] = $record;
return $data;
});
return $accepted;
}
/** Password policy shared by every path that sets a password. */
public function assertPassword(string $password, string $username = '', string $email = ''): void
{
$length = strlen($password);
if ($length < self::MIN_PASSWORD) {
throw new \RuntimeException('Passwords must be at least ' . self::MIN_PASSWORD . ' characters');
}
if ($length > self::MAX_PASSWORD) {
throw new \RuntimeException('Passwords can be at most ' . self::MAX_PASSWORD . ' bytes long');
}
$lower = strtolower($password);
if (($username !== '' && $lower === strtolower($username)) || ($email !== '' && $lower === strtolower($email))) {
throw new \RuntimeException('The password must differ from the login name and email address');
}
}
private function configAccounts(): array
{
$accounts = [];
foreach ((array) ($this->config['authentication'] ?? []) as $username => $hash) {
if (is_string($username) && is_string($hash) && $hash !== '') {
$accounts[$username] = ['hash' => $hash, 'role' => self::normaliseRole(($this->config['roles'] ?? [])[$username] ?? 'editor')];
}
}
return $accounts;
}
private function data(): array
{
return $this->shape($this->store->read('users'));
}
private function shape(array $data): array
{
$data['users'] = is_array($data['users'] ?? null) ? $data['users'] : [];
$data['profiles'] = is_array($data['profiles'] ?? null) ? $data['profiles'] : [];
return $data;
}
private function cleanName(string $name): string
{
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
if ($name === '' || mb_strlen($name) > 80 || preg_match('/[\x00-\x1f\x7f<>]/u', $name)) {
throw new \RuntimeException('Enter a display name of up to 80 characters');
}
return $name;
}
private function cleanEmail(string $email): string
{
$email = strtolower(trim($email));
if ($email !== '' && (strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL))) {
throw new \RuntimeException('Enter a valid email address');
}
return $email;
}
private function assertRole(string $role): void
{
if (!in_array($role, self::ROLES, true)) {
throw new \RuntimeException('Unknown role');
}
}
private function assertNotManaged(string $username, string $message = 'This account is managed in config.local.php and cannot be changed here'): void
{
if ($this->isManaged($username)) {
throw new \RuntimeException($message);
}
}
private function assertUsernameFree(array $data, string $username): void
{
if (!preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username)) {
throw new \RuntimeException('Login names are 3-32 letters, numbers, dots, dashes or underscores');
}
foreach ([$data['users'], $this->configAccounts()] as $existing) {
foreach (array_keys($existing) as $known) {
if (strcasecmp((string) $known, $username) === 0) {
throw new \RuntimeException('That login name is already in use');
}
}
}
}
/** Emails are unique across config overlays and CMS accounts, ignoring case. */
private function assertEmailFree(array $data, string $email, string $exceptUsername): void
{
if ($email === '') {
return;
}
$taken = [];
foreach ($data['profiles'] as $username => $profile) {
$taken[$username] = strtolower((string) ($profile['email'] ?? ''));
}
foreach ($data['users'] as $username => $record) {
$taken[$username] = strtolower((string) ($record['email'] ?? ''));
}
foreach ($taken as $username => $known) {
if ($known === $email && (string) $username !== $exceptUsername) {
throw new \RuntimeException('That email address is already in use');
}
}
}
private function generateUsername(array $data, string $email): string
{
$base = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', explode('@', $email)[0]) ?? '');
$base = substr(str_pad($base, 3, 'x'), 0, 28);
for ($i = 0; $i < 1000; $i++) {
$candidate = $i === 0 ? $base : $base . $i;
try {
$this->assertUsernameFree($data, $candidate);
return $candidate;
} catch (\RuntimeException) {
}
}
throw new \RuntimeException('Unable to generate a login name');
}
}
+11 -7
View File
@@ -16,6 +16,8 @@
main { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; margin-top: 22px; }
section { padding: 22px; border: 1px solid #d4dde4; border-radius: 10px; background: #fff; }
code { padding: 2px 5px; border-radius: 4px; background: #e8edf1; }
.important { color: #b3141f; }
.important code { color: inherit; background: #fbe4e6; }
footer { margin-top: 24px; color: #586673; text-align: center; }
</style>
</head>
@@ -37,33 +39,35 @@
<!-- Content creation and rollback tools. -->
<section>
<h2>Pages and revisions</h2>
<p>Administrators can create, duplicate, rename, and delete pages. Publishers can restore earlier revisions from <strong>Tools</strong>.</p>
<p>Deleted pages retain a restorable snapshot, because accidents should be reversible rather than memorable.</p>
<p>Administrators can create, duplicate, rename, and delete pages. The <strong>New page</strong> section under the list in <strong>Pages</strong> is a three-step wizard: choose a template, name the page (the filename is generated for you), then choose a navigation group. The new page stays a private draft until it is published; publishing creates the file and adds it to the chosen menu.</p>
<p>Use <strong>Create Template</strong> on any page in the list to offer it as a template, and Delete in the template list to stop offering one. Editors can restore earlier revisions from <strong>Tools</strong>.</p>
<p>Deleted pages retain a restorable snapshot: editors can bring one back with <strong>Recover</strong> in the <strong>Dashboard</strong>'s Deleted pages list, because accidents should be reversible rather than memorable.</p>
</section>
<!-- Asset and metadata management. -->
<section>
<h2>Media and SEO</h2>
<p>The media library reuses uploaded images, records alternative text, and reports usage counts.</p>
<p>The media library uploads images, documents, video and audio, sorts them by type, records alternative text or link text, and reports usage counts. In the editor, the <strong>Media library</strong> button inserts an uploaded file at the cursor. Executables and files containing code are refused.</p>
<p>The SEO dialogue manages titles, descriptions, canonical URLs, Open Graph images, and indexing instructions.</p>
</section>
<!-- Site-wide content management. -->
<section>
<h2>Shared content and menus</h2>
<p>Regions marked with <code>data-neo-shared</code> can be updated across every page. Named menus may be nested and propagated in the same fashion.</p>
<p>Regions marked with <code>data-neo-shared</code> can be updated across every page. Open <strong>Navigation Menus</strong> to edit, rename, or delete menus; changes propagate to every page. Menus are created by your web developer, not the CMS: <strong>Site Scan</strong> and the menu dialogue's scan button find navigation in the site files (primary and secondary are detected automatically).</p>
</section>
<!-- Publication timing and preview controls. -->
<section>
<h2>Schedule and preview</h2>
<p>Publishers may queue a future release. Desktop, tablet, and mobile controls resize the preview without altering page markup.</p>
<p>Editors may queue a future release. The desktop, tablet, and mobile icon buttons resize the preview without altering page markup.</p>
</section>
<!-- Quality and operational tools. -->
<section>
<h2>Checks and activity</h2>
<p>The accessibility checker catches common authoring problems before publication. The dashboard lists drafts, schedules, recent activity, and writable-directory warnings.</p>
<h2>Users, checks and activity</h2>
<p>The accessibility checker catches common authoring problems before publication. The dashboard lists drafts, schedules, deleted pages you can recover, recent activity, writable-directory warnings, and recommendations.</p>
<p>Everyone can update their display name, email address and password from <strong>Tools &gt; Users</strong>. An <strong>Editor</strong> edits and publishes content; an <strong>Administrator</strong> can also manage pages, menus and shared content, and add, invite, edit, block or delete users. Accounts in <code>config.local.php</code> stay managed there. <strong class="important">Important: CMS-created accounts are stored in the data folder, so on a live site set <code>dataDirectory</code> in <code>config.local.php</code> to an absolute path outside the web root; administrators see a Dashboard recommendation until it is.</strong></p>
</section>
</main>
+115 -15
View File
@@ -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.
+91
View File
@@ -0,0 +1,91 @@
# Changelog
All notable changes made to NeoCMS in this contribution: the `cms/` directory, `uploads/.htaccess`, `README.md`, `docs/`, and the updated `tests/run.php`.
This round builds on pull request #39 (site scan, editor workflow, subfolder support), which is already merged upstream and is summarised at the end.
## Unreleased
### Added
- **Navigation menus from the scan**: the site scan detects each page's header `<nav>` (primary) and footer `<nav>` (secondary), marks it `data-neo-menu` in place, and pre-fills Navigation Menus from it (a saved menu is never overwritten). Menu saves now keep the nav's own attributes, leave other bytes alone, skip unchanged pages and set `aria-current` per page. The menu form only appears after choosing Edit on an existing menu, and the CMS cannot create menus (they come from the site's pages via the scan); the display name can be changed without affecting the menu's key. Menus can be deleted (pages keep their current navigation). Navigation Menus has a "Scan site for new navigation" button that finds navigation blocks not yet attached to a menu (including ones a developer adds later) and exposes them. The Navigation Menus button moved to the main toolbar.
- **New page wizard**: New Page is now three steps (template, page name, navigation group). The filename is generated from the name (`about-us.html`, numbered if taken). The page is saved only as a private draft, so it is not visible on the site until first published; publishing creates the file and adds a link to the chosen menu on every page. Unpublished pages show as "New - unpublished" in Pages and can be discarded. Scheduling works for them too.
- **Recover deleted pages**: the Dashboard lists deleted pages (those whose newest revision is "Before delete" and whose file is gone) with a Recover button for editors, which recreates the page from its snapshot. New `deleted` field in the `dashboard` response.
- **Consistent buttons**: row buttons (Edit, Delete, ...) and other dialogue buttons use one small size (12px, 4px 9px padding, 4px radius); action buttons (editor Save Draft/Schedule/Publish/Cancel, dialogue Save/Next/Back/Apply) are a step larger (13px, 6px 14px). Secondary buttons are white with a border, destructive ones have red text, and primary actions (Save, Next, Apply) are filled blue. Editor buttons keep their Save Draft blue text and green Publish Now.
- **Top bar**: a full-width grid. The logo and page path fill the left; the user name and Log out are always at the top right, with the menu buttons and device icons directly beneath them. Below 1100px the buttons take their own row; below 760px everything stacks but the user block stays top right. The logo URL is now relative so it shows in a subfolder install.
- **Users** (Tools > Users): every user can update their display name and email address and change their password; administrators can add, edit, invite (one-time link, 7 days), block, unblock and delete accounts. Sign-in uses the login name only; the email address is contact information. Accounts in `config.local.php` stay controlled by config (only display name and email can be set for them); CMS accounts live in `data/users.json`. Roles are now **Editor** (draft, upload, publish, schedule, restore) and **Administrator**; `publisher` remains an alias of editor. Security: password hashes only (12 to 72 characters), no secrets in responses, invite tokens stored hashed and single use, administrator password confirmation for sensitive actions, rate-limited failures, self-lockout guards, sessions of blocked/deleted/re-passworded users end on their next request, every account event audited. New `UserStore` class; actions `users`, `saveProfile`, `changePassword`, `saveUser`, `inviteUser`, `blockUser`, `deleteUser`.
- **Documentation**: README.md and docs/ARCHITECTURE.md are now kept in step with the changes (roles and Users, navigation menus, New Page wizard and templates, deleted-page recovery, subfolder installs, interface) and are tracked in the local history.
- **Data outside the web root**: `dataDirectory` is honoured everywhere (verified live: accounts, rate limiting, activity and the cron worker all use it; the dashboard writable check now checks it too). Administrators see a Dashboard recommendation while the data folder is inside the web root, and the README install steps and `config.local.php.example` describe the setting.
- **Login name wording**: the Users screen and login page call the username a "login name" (shown read-only in My account and in the account list); the email field is labelled as contact details, not a sign-in identifier.
- **Media library for images, documents, video and audio**: Media now uploads files (multiple at once) into four categories chosen by extension (Imagery, Documents, Video, Audio), with category tabs and counts, per-file description, Copy link and Delete. The editor has a **Media library** button that inserts an image, a document link, a video or an audio player at the cursor. Safety: an extension allow-list (no `.exe`, scripts, HTML/SVG, archives, legacy or macro Office files), filenames such as `report.exe.pdf` refused, content sniffing, refusal of executable signatures and of any file containing `<?php`, `<?=`, `<script` or `<%`, PDFs with JavaScript/launch/embedded files refused, and Office/OpenDocument packages inspected for macros, embedded objects and scripts (built-in ZIP reader, no `zip` extension needed). New `MediaTypes` class; `image_upload.php` now handles all media and returns `name` and `category`; `mediaAction` adds `category`, `ext` and `original`; new stored names are `slug-8hex.ext` (legacy names still work). `uploads/.htaccess` (outside `cms/`) denies more executable extensions and serves documents as downloads. TinyMCE toolbar now wraps so all buttons are visible. The code scan only looks for the long markers (`<?php`, `<script`) in binary files and adds the short ones (`<?=`, `<%@`, `<%=`) for text files only, because compressed video and audio contain short byte sequences by chance; audio and video whose type libmagic cannot name are accepted when their container signature matches; oversize files report the limit.
- **New page merged into Pages**: the New Page toolbar button and its separate dialogue are gone. Pages is now one dialogue with the page list on top and, for administrators, a **New page** section beneath it holding the same three-step wizard (template, page name, navigation group). Creating a page closes the dialogue and opens the private draft for editing. The dialogue scrolls inside the window when it is tall.
- **Welcome page**: updated the built-in welcome sections for the New Page wizard, templates, navigation menus, site scan and device icons.
- **Pages as templates**: each row in the page list has a "Create Template" action (administrators). The page is then offered in New Page beside the files in `cms/templates/`; the new page is a private copy with its title replaced. Renaming or deleting a template page keeps the list in step.
- **Template list**: the New Page template and navigation-group lists use the page-table styling; each template has a Delete action (a template file is deleted; a page used as a template is only unmarked).
- **Device preview**: the Desktop/Tablet/Mobile buttons are icons (with tooltips and accessible names) and the active one is highlighted.
### Migration notes
- New Page is now a section of the Pages dialogue (there is no New Page toolbar button).
- Roles: `publisher` is merged into `editor` (editors can now publish and schedule). Existing `publisher` assignments keep working as an alias; review any account that was a plain `editor`, because it can now publish.
- Accounts: config accounts are unchanged. CMS-created accounts are stored in `cms/data/users.json`. Sign-in is by login name only.
- Media: new uploads are named `slug-8hex.ext` and accept documents, video and audio; existing 32-hex image names keep working. Copy the updated `uploads/.htaccess` (outside `cms/`).
- New optional setting `dataDirectory`: set it to a folder outside the web root on live sites. The default `cms/data/` keeps working.
- Navigation menus can no longer be created from the CMS; they are discovered by the site scan (existing menus are kept). New pages are private drafts until published.
- New API actions: `users`, `saveProfile`, `changePassword`, `saveUser`, `inviteUser`, `blockUser`, `deleteUser`, `newPage` (changed), `discardNewPage`, `setPageTemplate`, `deleteTemplate`, `deleteMenu`; the dashboard response gains `deleted` and `notices`; `media` items gain `category`, `ext` and `original`.
### Verification
- The updated integration suite (`tests/run.php`, included in this contribution) passes, covering concurrency, site scan and tagging, menus, new pages
and templates, deleted-page recovery, users and roles, invitations, media type checks, and audit-log cases. Upstream's earlier `tests/run.php` expects
behaviour that has intentionally changed (editors cannot publish, menus can be created, New Page copies immediately), so the updated suite replaces it.
On Windows the Linux-only permission assertions are skipped.
## Merged in pull request #39
### Added
- **Site scan** (Tools > Site scan, administrators): analyses every managed page and the media library, then makes pages editable
in one step.
- Reports page and folder counts, pages that can be made editable, images without alt text, broken local image/CSS/script
references, unused uploads, and missing title, description and Open Graph tags.
- Tags content containers (`<main>`, else top-level `<article>`/text-bearing `<section>`; header, nav, footer, aside and
forms are skipped, `<body>` is never tagged), images outside editable regions (`data-neo-image`) and photo placeholders
(`role="img"` boxes), and inserts only missing SEO tags. Existing tags are never overwritten.
- Edits page source byte-for-byte (a tokenizer splices markers in; no DOM re-serialisation), keeps a revision of every changed
page, and after the first run lists only pages that still have something to change.
- Runs in small batches from the browser with a progress bar and "N of M pages" count, for both analysing and updating.
- New administrator-only actions: `listSitePages`, `analyseSitePages`, `applySiteTagging`.
- **Click-to-edit images and photo placeholders**: clicking an image (or a photo-placeholder box) opens an image dialog to pick from
the Media Library or change the address and alt text. Clicking other text still opens the content editor.
- **Subfolder installs**: the site can live in a subfolder (for example `http://localhost/neocms/`). The URL prefix is detected
automatically (`basePath`), the site root is the folder containing `cms/` (`siteRoot`), and the session cookie path follows.
Root installs behave exactly as before.
- **Audit log coverage**: cancelling a schedule, uploading an image, changing alt text and failed scheduled publications are now
recorded in the Dashboard activity feed and the daily audit log (new shared `Activity` class).
- **New interface**: bottom-centre snackbar with success/error states and a close button; table-style dialogs (page picker, revision
history, dashboard, shared content, navigation menus, site scan) with icons, draft/status badges and per-row actions.
### Changed
- **Editing workflow**: Save Draft, Schedule Publish and Publish Now moved from the main toolbar into the content editor, which now
offers `Save Draft | Schedule Publish | Publish Now | Cancel`. Saving in the editor (and applying image/SEO changes) stores a
private draft; the header shows "Draft:" when one exists. Publish Now keeps the accessibility confirmation and the editor stays
open if it is cancelled.
- **Structure**: `CMSController` split into `PagePaths` (URI/path safety), `ContentDom` (HTML transforms) and `Activity`; entry
points share `cms/bootstrap.php` (config + autoloader) instead of four copies.
- Restoring a deleted page now uses a `PageNotFoundException` instead of matching an error message.
### Fixed
- **Lost updates**: read-modify-write of JSON metadata is now serialised with `FileStore::update()` (file locking).
- **Non-atomic site-wide writes**: shared-content and menu propagation now replace pages atomically, and non-ASCII text survives.
- **Scheduled publishing** no longer contends for a lock on every request and cannot fail unrelated API calls.
- **Revision pruning** could drop the newest revision when several were created in the same second; revisions now carry a
precise `ts`.
- **Stale preview**: pages replaced on disk could show an old cached copy in the editor preview; each newly opened page is revalidated.
- **Draft reload**: a saved draft loaded into the preview lost its stylesheets and page path (relative URLs resolved inside `/cms/`),
and could make Publish target the wrong address.
- Page titles containing HTML entities are decoded in the page list; PHP `dirname()` backslashes no longer leak into folder names.
- Draft prompts are offered again after a draft is saved within the same session (previously Publish could discard the draft).
### Migration notes
- No manual steps. New optional config keys `basePath` and `siteRoot` are auto-detected (`bootstrap.php`); set `basePath` only
behind a path-rewriting proxy.
- `revisions.json` entries gain a `ts` field (older entries continue to work).
- The `analyseSite` action was replaced by `listSitePages` and `analyseSitePages`.
- The toolbar no longer has Save Draft, Schedule or Publish buttons; they are in the content editor.
+42 -3
View File
@@ -5,20 +5,29 @@
| Path | Responsibility |
| --- | --- |
| `cms/src/NeoCMS/Authentication.php` | Sessions, credentials, roles, capabilities, and CSRF tokens. |
| `cms/src/NeoCMS/CMSController.php` | API dispatch and all page, draft, revision, schedule, media, menu, and shared-content operations. |
| `cms/src/NeoCMS/FileStore.php` | Atomic JSON metadata storage and managed directories. |
| `cms/src/NeoCMS/CMSController.php` | API dispatch and the page, draft, revision, schedule, media, menu, and shared-content actions. |
| `cms/src/NeoCMS/FileStore.php` | Atomic JSON metadata storage, locked `update()` read-modify-write, and managed directories. |
| `cms/src/NeoCMS/UserStore.php` | Accounts: locked config accounts plus CMS accounts in `data/users.json`, invitations, and the password policy. |
| `cms/src/NeoCMS/MediaTypes.php` | Allowed media extensions and categories, and the upload safety checks (`inspect()`). |
| `cms/src/NeoCMS/Activity.php` | Records an event in both the dashboard feed and the audit log. |
| `cms/src/NeoCMS/PagePaths.php` | URI normalisation and containment of page paths inside the document root. |
| `cms/src/NeoCMS/SiteAnalyser.php` | Byte-exact analysis and tagging of content regions, images, and SEO tags for the Site scan. |
| `cms/src/NeoCMS/ContentDom.php` | Pure HTML transforms for shared regions, menus, and editable-class detection. |
| `cms/bootstrap.php` | Shared entry-point setup: configuration and autoloader. |
| `cms/src/NeoCMS/LoginRateLimiter.php` | Locked, filesystem-backed login throttling. |
| `cms/src/NeoCMS/Logger.php` | Daily audit logging with field normalisation, size rotation, and retention. |
| `cms/src/NeoCMS/SecurityHeaders.php` | CSP, anti-framing, cache, transport, and browser-policy headers. |
| `cms/js/cms.js` | Iframe editing, dialogues, workflow state, and API client. |
| `cms/index.php` | Authenticated administration shell. |
| `cms/image_upload.php` | Multipart image upload endpoint. |
| `cms/image_upload.php` | Multipart media upload endpoint (images, documents, video, audio); also used by TinyMCE for pasted images. |
| `cms/publish_scheduled.php` | CLI scheduled-publication worker. |
| `tests/run.php` | Isolated integration suite. |
TinyMCE 8.6.0 beneath `cms/tinymce/` is vendored third-party code and should not be reformatted or documented as first-party
source. Its package metadata, changelog, and licence information remain in that directory.
Every read-modify-write of a JSON document must go through `FileStore::update()`; plain `read()` then `write()` loses concurrent updates.
## Documentation Style
Comments use British English and explain intent, constraints, side effects, and security decisions. They should not narrate
@@ -74,3 +83,33 @@ ceilings. New HTML entry points should use `SecurityHeaders` before emitting out
The integration suite creates unique public and metadata directories under the operating system's temporary directory. It
seeds an authenticated administrator session, invokes controller actions directly, and cleans up in a `finally` block.
Tests must never rely on or modify the repository's public HTML, uploads, logs, or data files.
## Local Testing on XAMPP (Windows)
Place the project in XAMPP's `htdocs` folder (for example `htdocs/neocms/`) and browse to `http://localhost/neocms/cms/`. No virtual host is
needed: NeoCMS detects the `/neocms` URL prefix from the request and uses the folder containing `cms/` as the site root. On a
live site at the domain root the prefix is empty and behaviour is unchanged. If a proxy hides the real path, set `'basePath'`
in `cms/config.local.php`.
1. Copy `cms/config.local.php.example` to `cms/config.local.php`.
2. Generate a hash (use XAMPP's `php.exe` if `php` is not on your PATH): `php -r "echo password_hash('choose-a-password', PASSWORD_DEFAULT);"`.
3. Add the hash under `authentication` and a role under `roles`, then browse to `/cms/`. This config account is the main administrator; further accounts are added in Tools > Users.
Windows ignores `chmod`, so private-file permissions are not enforced there; the permission assertions in `tests/run.php`
run only on Linux. Keep `.htaccess` enabled (`AllowOverride All`) so `cms/data/` and `cms/logs/` stay private.
## Production Checklist (Linux + Apache)
- Serve over HTTPS only. Behind a TLS-terminating proxy set `'security' => ['cookieSecure' => true]` in `config.local.php`.
- Create `cms/config.local.php` with password hashes for the main administrator(s) (`editor` or `administrator` roles); never commit it. Other accounts are created in Tools > Users.
- Set `'dataDirectory'` in `config.local.php` to an absolute folder **outside the web root** (for example `/var/neocms-data`, writable by PHP). Accounts, drafts and revisions then can never be served. Administrators see a Dashboard recommendation until this is done.
- Copy `uploads/.htaccess` with the site: it denies executable and markup extensions and serves documents as downloads. On Nginx add the `/uploads/` rule from the README.
- Enable `AllowOverride All` for the site so the shipped `.htaccess` files apply, and confirm `mod_headers` is loaded.
- Own the writable paths (`cms/data/`, `cms/logs/`, `uploads/`, and managed HTML) by the PHP user, for example `www-data`.
- Set PHP `post_max_size` and `upload_max_filesize` to at least `uploads.maxFileBytes` (10 MiB by default; raise both together for video). The PHP `fileinfo` extension is required for upload checks; the `zip` extension is not.
- Add the cron entry from the README for `cms/publish_scheduled.php`.
- The admin loads jQuery from `code.jquery.com`; self-host it and update `cms/index.php` and the CSP if the server must work offline.
- Back up `cms/data/` together with the public HTML.
- Verify after deploying: `/cms/data/`, `/cms/logs/`, `/cms/config.php`, and any `.php` or `.exe` placed in `/uploads/` return 403; the session cookie is `Secure`;
five wrong passwords trigger throttling; `php tests/run.php` passes on the server.
- Upload `cms/`, `uploads/`, and the HTML to the site root. Nothing needs changing from the XAMPP setup. Links written as root-relative (`/about.html`) resolve under `/neocms/` only on the live site, so use relative links or test them after deployment.
+412 -8
View File
@@ -197,12 +197,6 @@ try {
if (function_exists('symlink') && @symlink($outside . '/secret.html', $root . '/leak.html')) {
assertTrue(count($request('getPages')) === 1, 'Page discovery followed an external file symlink');
}
if (function_exists('symlink') && @symlink($outside, $root . '/linked')) {
assertRejected(
fn() => $request('newPage', ['template' => 'example-template.html', 'filename' => '/linked/escaped.html'], 'POST'),
'New-page creation followed an external directory symlink'
);
}
// Drafts must remain private and leave the public HTML untouched.
$draftContent = str_replace('Original', 'Draft', $page);
@@ -229,20 +223,30 @@ try {
assertTrue(is_file($root . '/renamed.html'), 'Duplicate or rename failed');
$request('page', ['operation' => 'delete', 'uri' => '/renamed.html'], 'POST');
assertTrue(!is_file($root . '/renamed.html'), 'Page delete failed');
assertTrue(in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Deleted page was not listed on the dashboard');
$deletedRevisions = $request('revisions', ['uri' => '/renamed.html']);
$request('restoreRevision', ['id' => $deletedRevisions[0]['id']], 'POST');
assertTrue(is_file($root . '/renamed.html'), 'Deleted page revision was not restored');
assertTrue(!in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Recovered page is still listed as deleted');
// Shared content must propagate ordinary forgiving HTML fragments to marked pages.
$request('saveShared', ['key' => 'footer', 'content' => '<strong>Shared<br>footer</strong>'], 'POST');
assertTrue(str_contains((string) file_get_contents($root . '/index.html'), '<strong>Shared<br>footer</strong>'), 'Shared HTML was not propagated');
// Non-ASCII text must survive propagation unchanged (libxml assumes Latin-1 without a hint).
$request('saveShared', ['key' => 'footer', 'content' => '<em>Café – 日本語</em>'], 'POST');
// libxml may write entities (&eacute;); they must decode back to the original text, not to Latin-1 mojibake.
assertTrue(str_contains(html_entity_decode((string) file_get_contents($root . '/index.html'), ENT_QUOTES, 'UTF-8'), '<em>Café – 日本語</em>'), 'Non-ASCII shared content was mangled');
// Nested menus must render and replace existing generated navigation regions.
$items = json_encode([
['label' => 'Home', 'url' => '/', 'parent' => ''],
['label' => 'Team', 'url' => '/team.html', 'parent' => 'Home'],
]);
$request('saveMenu', ['name' => 'main', 'items' => $items], 'POST');
(new NeoCMS\FileStore($data))->write('menus', ['main' => ['items' => [], 'updated' => date(DATE_ATOM)]]);
assertRejected(fn() => $request('saveMenu', ['name' => 'brand-new', 'items' => $items], 'POST'), 'The CMS created a menu that does not exist');
$request('saveMenu', ['name' => 'main', 'title' => 'Main navigation', 'items' => $items], 'POST');
assertTrue(($request('menus')['main']['title'] ?? '') === 'Main navigation' && !isset($request('menus')['brand-new']), 'Menu was not renamed in place');
$menuPage = (string) file_get_contents($root . '/index.html');
assertTrue(str_contains($menuPage, 'href="/team.html"') && str_contains($menuPage, 'data-neo-menu="main"'), 'Menu was not propagated');
@@ -255,7 +259,20 @@ try {
$dashboard = $request('dashboard');
assertTrue(isset($dashboard['schedules'][$schedule['id']]), 'Schedule was not visible on the dashboard');
$request('cancelSchedule', ['id' => $schedule['id']], 'POST');
assertTrue(!isset($request('dashboard')['schedules'][$schedule['id']]), 'Schedule was not cancelled');
$afterCancel = $request('dashboard');
assertTrue(!isset($afterCancel['schedules'][$schedule['id']]), 'Schedule was not cancelled');
assertTrue(in_array('Cancelled scheduled publication', array_column($afterCancel['activity'], 'action'), true), 'Cancelling a schedule was not recorded in the activity log');
$request('cancelSchedule', ['id' => $schedule['id']], 'POST'); // cancelling twice must not log a second event
assertTrue(count(array_filter($request('dashboard')['activity'], fn($e) => $e['action'] === 'Cancelled scheduled publication')) === 1, 'A schedule that no longer exists was logged as cancelled');
// Alt-text edits and uploads are recorded too.
$request('updateMedia', ['name' => str_repeat('a', 32) . '.png', 'alt' => 'A test image'], 'POST');
assertTrue(in_array('Updated image alt text', array_column($request('dashboard')['activity'], 'action'), true), 'Alt text change was not recorded');
$uploadActivityDir = sys_get_temp_dir() . '/neocms-activity-' . bin2hex(random_bytes(4));
(new NeoCMS\Activity(new NeoCMS\FileStore($uploadActivityDir), new NeoCMS\Logger(false, [])))->record('tester', 'Uploaded image', 'x.png (5 KB)');
$recorded = (new NeoCMS\FileStore($uploadActivityDir))->read('activity');
assertTrue(count($recorded) === 1 && $recorded[0]['action'] === 'Uploaded image' && $recorded[0]['user'] === 'tester', 'Upload activity entry was not written');
removeTree($uploadActivityDir);
assertRejected(
fn() => $request('schedule', ['uri' => '/missing.html', 'content' => $published, 'publish_at' => date(DATE_ATOM, time() + 3600)], 'POST'),
'A schedule targeting a missing page was accepted'
@@ -292,6 +309,7 @@ try {
assertTrue((new NeoCMS\CMSController($config))->publishScheduled() === 0, 'Invalid scheduled target was reported as published');
$dashboardAfterFailure = $request('dashboard');
assertTrue(($dashboardAfterFailure['schedules'][$failed['id']]['status'] ?? '') === 'failed', 'Failed schedule was not isolated');
assertTrue(in_array('Scheduled publication failed', array_column($dashboardAfterFailure['activity'], 'action'), true), 'A failed scheduled publication was not shown in the activity feed');
// Revision retention limits must cap both per-page and total private storage metadata.
$revisionIndex = json_decode((string) file_get_contents($data . '/revisions.json'), true);
@@ -300,8 +318,394 @@ try {
assertTrue($indexRevisionCount <= 2, 'Per-page revision retention limit was exceeded');
// Private CMS state should not be readable by unrelated operating-system users.
// ---- Site scan: byte-exact tagging of content containers, images, and SEO tags ----
$all = ['content' => true, 'images' => true, 'seo' => true];
$scanPage = "<!DOCTYPE html>\r\n<html><head>\r\n <meta charset=\"utf-8\">\r\n</head>\r\n<body>\r\n"
. "<header class='top'><nav><a href=\"/\">Home</a></nav></header>\r\n"
. "<script>var s = '<main>';</script>\r\n"
. "<main class=\"wide\">\r\n<h1>Café – Über uns</h1>\r\n<p>Wir sind ein kleines Team mit grossen Ideen.</p>\r\n<img src=\"/photo.jpg\" alt=\"\">\r\n</main>\r\n"
. "<footer><p>Footer text that is long enough to count as text.</p></footer>\r\n"
. "<img src=\"/logo.png\">\r\n<img src=\"x.gif\" width=\"1\" height=\"1\">\r\n</body></html>";
$tagged = NeoCMS\SiteAnalyser::apply($scanPage, 'cms-content', $all);
assertTrue($tagged !== null, 'Site scan found nothing to tag');
$out = $tagged['html'];
assertTrue(str_contains($out, '<main class="wide cms-content">'), 'Existing class attribute was not merged');
assertTrue(!str_contains($out, "<header class='top cms-content'"), 'Header was tagged as content');
assertTrue(str_contains($out, "var s = '<main>';"), 'Script content was altered');
assertTrue(substr_count($out, 'data-neo-image') === 1 && str_contains($out, '<img data-neo-image src="/logo.png">'), 'Only the image outside the region should be tagged');
assertTrue(str_contains($out, '<title>Café – Über uns</title>') && str_contains($out, 'name="description"'), 'Missing SEO tags were not inserted');
assertTrue(substr_count($out, "\n") === substr_count($out, "\r\n"), 'Line endings became mixed');
assertTrue(NeoCMS\SiteAnalyser::apply($out, 'cms-content', $all) === null, 'Second site scan was not idempotent');
// Removing exactly what was inserted must give back the original bytes.
$restored = str_replace([' cms-content"', ' data-neo-image'], ['"', ''], $out);
$restored = preg_replace('#^ {4}<(title|meta (name="description"|property="og:[a-z]+")).*\r\n#m', '', $restored);
assertTrue($restored === $scanPage, 'Site scan changed bytes other than the inserted markers');
// Existing SEO tags are never overwritten; a page with no safe container is reported, not guessed.
$seoPage = '<html><head><title>Mine</title><meta name="description" content="Keep me"></head><body><main><h1>H</h1></main></body></html>';
$seoOut = NeoCMS\SiteAnalyser::apply($seoPage, 'cms-content', ['seo' => true]);
assertTrue($seoOut === null || (str_contains($seoOut['html'], '<title>Mine</title>') && str_contains($seoOut['html'], 'content="Keep me"') && substr_count($seoOut['html'], 'name="description"') === 1), 'Existing SEO tags were overwritten');
assertTrue(NeoCMS\SiteAnalyser::apply('<body><div>' . str_repeat('text ', 30) . '</div></body>', 'cms-content', ['content' => true]) === null, 'A page without a safe container was tagged');
// Photo placeholders (role="img" boxes) outside a region are marked; icons, real images, and in-region boxes are not.
$placeholderPage = '<body><div class="portrait" role="img" aria-label="Photo placeholder"><svg role="img"></svg><span>Your photo here</span></div>'
. '<div role="img" aria-label="has one"><img src="/a.jpg" alt=""></div><svg role="img"></svg><span role="img" aria-hidden="true"></span>'
. '<main><div role="img" aria-label="inside"></div></main></body>';
$placeholderOut = NeoCMS\SiteAnalyser::apply($placeholderPage, 'cms-content', ['images' => true]);
assertTrue($placeholderOut !== null && substr_count($placeholderOut['html'], 'data-neo-image') === 3, 'Images-only pass should mark the portrait, the real image, and the placeholder in <main>');
assertTrue(str_contains($placeholderOut['html'], '<div data-neo-image class="portrait" role="img"'), 'Photo placeholder was not marked');
assertTrue(str_contains($placeholderOut['html'], '<img data-neo-image src="/a.jpg"'), 'Real image was not marked');
$regionOut = NeoCMS\SiteAnalyser::apply($placeholderPage, 'cms-content', ['content' => true, 'images' => true]);
assertTrue(substr_count($regionOut['html'], 'data-neo-image') === 2 && !str_contains($regionOut['html'], '<div data-neo-image role="img" aria-label="inside"'), 'A placeholder inside a newly editable region was marked');
// Controller: analyse reports pages; apply writes them, keeps a revision, and needs the manage capability.
file_put_contents($root . '/scan-a.html', $scanPage);
file_put_contents($root . '/scan-b.html', $scanPage);
$listing = $request('listSitePages');
assertTrue(in_array('/scan-a.html', $listing['pages'], true) && in_array('/scan-b.html', $listing['pages'], true), 'Page listing missed the sample pages');
$analysis = $request('analyseSitePages', ['uris' => json_encode(['/scan-a.html', '/scan-b.html', '/no-such-page.html'])]);
$byUri = array_column($analysis['pages'], null, 'uri');
assertTrue(count($byUri) === 2, 'A page that does not exist should be skipped, not fail the batch');
assertTrue(isset($byUri['/scan-a.html']) && $byUri['/scan-a.html']['proposed'] === ['main'], 'Analysis did not propose the main region');
assertTrue(in_array('/logo.png', $byUri['/scan-a.html']['broken'], true), 'Missing local image was not reported as broken');
assertRejected(fn() => $request('analyseSitePages', ['uris' => json_encode(array_map(fn($i) => "/p{$i}.html", range(1, 101)))]), 'An oversized batch was accepted');
$asRole = function (string $role, string $action, array $parameters) use ($config): array {
$cfg = $config;
$cfg['roles']['tester'] = $role;
$_SERVER['REQUEST_METHOD'] = 'POST';
$_POST = array_merge(['action' => $action, 'csrf_token' => 'test-token'], $parameters);
$_GET = [];
$_REQUEST = $_POST;
ob_start();
(new NeoCMS\CMSController($cfg))->handleRequest();
return json_decode((string) ob_get_clean(), true) ?? [];
};
foreach (['listSitePages' => [], 'analyseSitePages' => ['uris' => json_encode(['/scan-a.html'])]] as $readAction => $readParams) {
assertTrue(isset($asRole('publisher', $readAction, $readParams)['error']), "A non-administrator could run {$readAction}");
}
$before = file_get_contents($root . '/scan-b.html');
$denied = $asRole('publisher', 'applySiteTagging', ['uris' => json_encode(['/scan-b.html']), 'options' => json_encode($all)]);
assertTrue(isset($denied['error']) && file_get_contents($root . '/scan-b.html') === $before, 'A non-administrator could auto-tag pages');
$traversal = $asRole('administrator', 'applySiteTagging', ['uris' => json_encode(['/scan-a.html', '../etc/passwd']), 'options' => json_encode($all)]);
assertTrue(isset($traversal['error']) && !str_contains((string) file_get_contents($root . '/scan-a.html'), 'cms-content'), 'A traversal URI was not rejected before any page changed');
$result = $request('applySiteTagging', ['uris' => json_encode(['/scan-a.html']), 'options' => json_encode($all)], 'POST');
assertTrue($result['updated_pages'] === 1, 'Only the selected page should have been updated');
assertTrue(str_contains((string) file_get_contents($root . '/scan-a.html'), 'cms-content') && file_get_contents($root . '/scan-b.html') === $before, 'Unselected page changed or selected page was not tagged');
$revisions = $request('revisions', ['uri' => '/scan-a.html']);
assertTrue(count($revisions) >= 1 && $revisions[0]['reason'] === 'Before auto-tagging', 'No revision was kept before auto-tagging: ' . json_encode($revisions));
unlink($root . '/scan-a.html');
unlink($root . '/scan-b.html');
// Navigation detection seeds the menus store, tags the nav in place, and later saves keep its attributes.
$navPage = fn(string $cur) => '<!doctype html><html><head><title>T</title></head><body><header><nav class="site-nav" aria-label="Main"><ul><li><a href="a.html"' . ($cur === 'a' ? ' aria-current="page"' : '') . '>A</a></li><li><a href="b.html"' . ($cur === 'b' ? ' aria-current="page"' : '') . '>B</a></li></ul></nav></header><main><p>' . str_repeat('word ', 20) . '</p></main></body></html>';
file_put_contents($root . '/nav-a.html', $navPage('a'));
file_put_contents($root . '/nav-b.html', $navPage('b'));
$navUris = json_encode(['/nav-a.html', '/nav-b.html']);
$seen = $request('analyseSitePages', ['uris' => $navUris])['pages'];
assertTrue($seen[0]['navs'][0]['menu'] === 'primary' && $seen[0]['navs'][0]['links'] === 2, 'Primary navigation was not detected');
$request('applySiteTagging', ['uris' => $navUris, 'options' => json_encode(['menus' => true])], 'POST');
$menus = $request('menus');
assertTrue(isset($menus['primary']) && count($menus['primary']['items']) === 2 && $menus['primary']['items'][0]['url'] === '/a.html', 'Detected menu was not seeded: ' . json_encode($menus));
assertTrue(str_contains((string) file_get_contents($root . '/nav-b.html'), '<nav data-neo-menu="primary" class="site-nav" aria-label="Main">'), 'Nav was not marked in place');
assertTrue(!str_contains(json_encode(array_column($request('analyseSitePages', ['uris' => $navUris])['pages'], 'changes')), 'navigation'), 'Menu detection is not settled on the second run');
$request('saveMenu', ['name' => 'primary', 'items' => json_encode([['label' => 'A', 'url' => '/a.html'], ['label' => 'B', 'url' => '/b.html'], ['label' => 'C', 'url' => '/c.html']])], 'POST');
$navB = (string) file_get_contents($root . '/nav-b.html');
assertTrue(str_contains($navB, 'class="site-nav" aria-label="Main"') && str_contains($navB, '<a href="/c.html">C</a>') && substr_count($navB, 'aria-current') === 0, 'Saved menu lost the nav attributes or wrongly marked the current page');
$request('deleteMenu', ['name' => 'primary'], 'POST');
assertTrue(!isset($request('menus')['primary']) && str_contains((string) file_get_contents($root . '/nav-b.html'), '/c.html'), 'Menu was not deleted, or pages lost their navigation');
assertTrue(isset($asRole('publisher', 'deleteMenu', ['name' => 'secondary'])['error']), 'A non-administrator could delete a menu');
// Scan-for-navigation mode also exposes navs that are not header/footer menus.
file_put_contents($root . '/nav-c.html', '<html><head><title>T</title></head><body><nav aria-label="Side bar"><ul><li><a href="/x.html">X</a></li></ul></nav><p>hi</p></body></html>');
$plain = $request('analyseSitePages', ['uris' => json_encode(['/nav-c.html'])])['pages'][0];
$wide = $request('analyseSitePages', ['uris' => json_encode(['/nav-c.html']), 'allNavs' => 1])['pages'][0];
assertTrue($plain['navs'] === [] && $wide['navs'][0]['menu'] === 'side-bar' && !$wide['navs'][0]['tagged'], 'allNavs did not expose an unclassified nav');
$request('applySiteTagging', ['uris' => json_encode(['/nav-c.html']), 'options' => json_encode(['menus' => true, 'allNavs' => true])], 'POST');
assertTrue(isset($request('menus')['side-bar']) && str_contains((string) file_get_contents($root . '/nav-c.html'), 'data-neo-menu="side-bar"'), 'Unclassified nav was not exposed as a menu');
unlink($root . '/nav-c.html');
unlink($root . '/nav-a.html');
unlink($root . '/nav-b.html');
// A new page stays private (no file, not in its menu) until it is first published.
$mainBefore = count($request('menus')['main']['items']);
$created = $request('newPage', ['template' => 'example-template.html', 'name' => 'About Us!', 'menu' => 'main'], 'POST');
assertTrue($created['url'] === '/about-us.html' && !file_exists($root . '/about-us.html'), 'New page was made public before publication');
assertTrue($request('newPage', ['template' => 'example-template.html', 'name' => 'About us', 'menu' => ''], 'POST')['url'] === '/about-us-2.html', 'Duplicate name did not get a numbered filename');
assertTrue($request('newPage', ['template' => 'example-template.html', 'name' => '???', 'menu' => ''], 'POST')['url'] === '/page.html', 'Symbol-only name did not fall back to page.html');
assertRejected(fn() => $request('newPage', ['template' => 'example-template.html', 'name' => 'X', 'menu' => 'nope'], 'POST'), 'Unknown navigation group was accepted');
$listed = array_values(array_filter($request('getPages'), fn($p) => $p['url'] === '/about-us.html'));
assertTrue(count($listed) === 1 && !empty($listed[0]['pending']), 'Pending page was not listed as unpublished');
$newDraft = $request('getDraft', ['uri' => '/about-us.html']);
assertTrue($newDraft['exists'] && str_contains($newDraft['content'], '<title>About Us!</title>'), 'New page draft is missing or untitled');
assertTrue(count($request('menus')['main']['items']) === $mainBefore && !str_contains((string) file_get_contents($root . '/index.html'), 'about-us.html'), 'Link appeared in the menu before publication');
$request('save', ['uri' => '/about-us.html', 'content' => $newDraft['content']], 'POST');
assertTrue(file_exists($root . '/about-us.html'), 'First publication did not create the page');
assertTrue(in_array('/about-us.html', array_column($request('menus')['main']['items'], 'url'), true) && str_contains((string) file_get_contents($root . '/index.html'), 'href="/about-us.html"'), 'Published page was not added to its menu');
$published = array_values(array_filter($request('getPages'), fn($p) => $p['url'] === '/about-us.html'));
assertTrue(!array_filter($published, fn($p) => !empty($p['pending'])), 'Published page is still marked unpublished');
$request('schedule', ['uri' => '/about-us-2.html', 'content' => $newDraft['content'], 'publish_at' => (new DateTimeImmutable('+1 day'))->format(DATE_ATOM)], 'POST');
$request('discardNewPage', ['uri' => '/about-us-2.html'], 'POST');
$request('discardNewPage', ['uri' => '/page.html'], 'POST');
assertTrue(!file_exists($root . '/about-us-2.html') && !in_array('/about-us-2.html', array_column($request('dashboard')['schedules'], 'uri'), true) && !array_filter($request('getPages'), fn($p) => !empty($p['pending'])), 'Discard left a schedule or pending page behind');
unlink($root . '/about-us.html');
// A page marked as a template is offered by New Page and copied (title replaced) into a private draft.
assertRejected(fn() => $request('newPage', ['template' => 'page:/index.html', 'name' => 'Nope', 'menu' => ''], 'POST'), 'An unmarked page was usable as a template');
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '1'], 'POST');
assertTrue(in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true), 'Marked page is not offered as a template');
$fromPage = $request('newPage', ['template' => 'page:/index.html', 'name' => 'From Page', 'menu' => ''], 'POST');
$copied = $request('getDraft', ['uri' => $fromPage['url']])['content'];
assertTrue(str_contains($copied, 'cms-content') && str_contains($copied, '<title>From Page</title>') && !file_exists($root . $fromPage['url']), 'Page template was not copied into a private draft');
$request('discardNewPage', ['uri' => $fromPage['url']], 'POST');
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '0'], 'POST');
assertTrue(!in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true), 'Unmarked page is still offered as a template');
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '1'], 'POST');
$request('deleteTemplate', ['template' => 'page:/index.html'], 'POST');
assertTrue(!in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true) && file_exists($root . '/index.html'), 'Deleting a page template removed the page or left it listed');
assertRejected(fn() => $request('deleteTemplate', ['template' => '../config.php'], 'POST'), 'A non-template file could be deleted');
assertRejected(fn() => $request('deleteTemplate', ['template' => 'missing.html'], 'POST'), 'A missing template file was reported deleted');
// Parallel read-modify-write must not lose updates: 6 processes x 20 increments = 120.
$counterDir = sys_get_temp_dir() . '/neocms-counter-' . bin2hex(random_bytes(4));
$worker = $counterDir . '.php';
file_put_contents($worker, '<?php require ' . var_export(__DIR__ . '/../cms/src/NeoCMS/FileStore.php', true)
. '; $s = new NeoCMS\FileStore($argv[1]); for ($i = 0; $i < 20; $i++) { $s->update("counter", fn($d) => ["n" => ($d["n"] ?? 0) + 1]); }');
$procs = [];
for ($i = 0; $i < 6; $i++) {
$procs[] = proc_open([PHP_BINARY, $worker, $counterDir], [], $pipes);
}
foreach ($procs as $proc) {
proc_close($proc);
}
$counted = (new NeoCMS\FileStore($counterDir))->read('counter')['n'] ?? 0;
unlink($worker);
removeTree($counterDir);
assertTrue($counted === 120, "FileStore::update lost updates under concurrency ({$counted}/120)");
// ---- Users: roles, self-service, administrator management, invitations, and account protection ----
$as = function (string $user, string $action, array $params = [], string $method = 'POST') use ($config): array {
$saved = $_SESSION;
$_SESSION = ['loggedIn' => true, 'loggedInUser' => $user, 'csrfToken' => 'test-token', 'authenticatedAt' => time(), 'lastActivityAt' => time(), 'lastRegeneratedAt' => time()];
$_SERVER['REQUEST_METHOD'] = $method;
$_POST = $method === 'POST' ? array_merge(['action' => $action, 'csrf_token' => 'test-token'], $params) : [];
$_GET = $method === 'GET' ? array_merge(['action' => $action], $params) : [];
$_REQUEST = array_merge($_GET, $_POST);
ob_start();
(new NeoCMS\CMSController($config))->handleRequest();
$body = (string) ob_get_clean();
$_SESSION = $saved;
return (json_decode($body, true) ?? []) + ['_raw' => $body];
};
$ok = fn(array $r, string $why) => assertTrue(!isset($r['error']), $why . ' (' . ($r['error'] ?? '') . ')');
$err = fn(array $r, string $why) => assertTrue(isset($r['error']), $why);
$store = new NeoCMS\UserStore(new NeoCMS\FileStore($data), $config);
$pw = 'unused';
// Two roles: the old publisher role is an editor (can publish, cannot manage).
$alias = new NeoCMS\Authentication(['x' => $limitedHash], ['x' => 'publisher']);
$alias->login('x', 'secret');
assertTrue($alias->getRole() === 'editor' && $alias->can('publish') && $alias->can('schedule') && !$alias->can('manage'), 'Publisher did not map to a publishing editor');
$list = $as('tester', 'users', [], 'GET');
$ok($list, 'users list failed');
assertTrue($list['me']['managed'] === true && $list['me']['role'] === 'administrator' && array_keys($list['roles']) === ['editor', 'administrator'], 'Users response is missing the config account or roles');
assertTrue(!str_contains($list['_raw'], 'hash') && !str_contains($list['_raw'], '$2y$'), 'A password hash leaked into the users response');
// Sensitive admin actions need the administrator's own password.
$newUser = ['name' => 'Ed Example', 'email' => 'Ed@Example.com', 'role' => 'editor', 'username' => 'ed1', 'password' => 'correct horse 1'];
$err($as('tester', 'saveUser', $newUser), 'Adding a user worked without password confirmation');
$err($as('tester', 'saveUser', $newUser + ['confirm_password' => 'wrong']), 'Adding a user worked with a wrong confirmation password');
$ok($as('tester', 'saveUser', $newUser + ['confirm_password' => $pw]), 'Adding a user failed');
$accounts = array_column($as('tester', 'users', [], 'GET')['users'], null, 'username');
assertTrue(($accounts['ed1']['email'] ?? '') === 'ed@example.com' && $accounts['ed1']['status'] === 'active' && !isset($accounts['ed1']['hash']), 'New account was not listed correctly');
assertTrue(str_starts_with((string) json_decode((string) file_get_contents($data . '/users.json'), true)['users']['ed1']['hash'], '$2y$'), 'Password was not stored as a hash');
// Validation: duplicates, weak or over-long passwords, password equal to the username.
$err($as('tester', 'saveUser', ['username' => 'ED1', 'name' => 'Dup', 'email' => 'other@example.com', 'role' => 'editor', 'password' => 'correct horse 2', 'confirm_password' => $pw]), 'A case-variant duplicate username was accepted');
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Dup', 'email' => 'ED@example.com', 'role' => 'editor', 'password' => 'correct horse 2', 'confirm_password' => $pw]), 'A duplicate email was accepted');
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Short', 'email' => '', 'role' => 'editor', 'password' => 'elevenchars', 'confirm_password' => $pw]), 'An 11-character password was accepted');
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Long', 'email' => '', 'role' => 'editor', 'password' => str_repeat('a', 73), 'confirm_password' => $pw]), 'A 73-byte password was accepted');
$err($as('tester', 'saveUser', ['username' => 'samepassword1', 'name' => 'Same', 'email' => '', 'role' => 'editor', 'password' => 'samepassword1', 'confirm_password' => $pw]), 'A password equal to the username was accepted');
$err($as('tester', 'saveUser', ['username' => 'ed3', 'name' => 'Bad role', 'email' => '', 'role' => 'root', 'password' => 'correct horse 3', 'confirm_password' => $pw]), 'An unknown role was accepted');
$err($as('tester', 'saveUser', ['username' => 'tester', 'name' => 'Shadow', 'email' => '', 'role' => 'editor', 'password' => 'correct horse 4', 'confirm_password' => $pw]), 'A CMS account could shadow a config username');
// An editor can publish but cannot manage users, and sees only their own account.
$perms = $as('ed1', 'dashboard', [], 'GET')['permissions'];
assertTrue($perms['publish'] === true && $perms['schedule'] === true && $perms['manage'] === false, 'Editor permissions are wrong');
$mine = $as('ed1', 'users', [], 'GET');
assertTrue($mine['users'] === [] && $mine['me']['username'] === 'ed1' && $mine['me']['role'] === 'editor', 'An editor could list other accounts');
foreach (['saveUser' => $newUser + ['username' => 'ed9', 'confirm_password' => 'correct horse 1'], 'inviteUser' => ['name' => 'X', 'email' => 'x@example.com', 'role' => 'editor', 'confirm_password' => 'correct horse 1'], 'blockUser' => ['username' => 'tester', 'blocked' => '1', 'confirm_password' => 'correct horse 1'], 'deleteUser' => ['username' => 'tester', 'confirm_password' => 'correct horse 1']] as $action => $params) {
assertTrue(($as('ed1', $action, $params)['error'] ?? '') === 'Your role cannot perform this action', "An editor could run {$action}");
}
// Self-service: display name freely, email and password need the current password.
$ok($as('ed1', 'saveProfile', ['name' => 'Edward Example', 'email' => 'ed@example.com']), 'Editor could not change their display name');
$err($as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com']), 'Email changed without the current password');
$ok($as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com', 'current_password' => 'correct horse 1']), 'Email change with the current password failed');
$as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com', 'username' => 'tester']);
assertTrue($store->profile('tester')['name'] === '' && $store->profile('ed1')['name'] === 'Edward', 'A posted username redirected a self-service profile edit');
$err($as('ed1', 'changePassword', ['current_password' => 'correct horse 1', 'new_password' => 'brand new pass 2', 'confirm_password' => 'different pass 2']), 'Mismatched confirmation was accepted');
$err($as('ed1', 'changePassword', ['current_password' => 'wrong', 'new_password' => 'brand new pass 2', 'confirm_password' => 'brand new pass 2']), 'Password changed with a wrong current password');
$ok($as('ed1', 'changePassword', ['current_password' => 'correct horse 1', 'new_password' => 'brand new pass 2', 'confirm_password' => 'brand new pass 2']), 'Password change failed');
[$credentials] = $store->forAuth();
assertTrue(password_verify('brand new pass 2', $credentials['ed1']) && !password_verify('correct horse 1', $credentials['ed1']), 'New password did not replace the old one');
// Config accounts are locked: profile overlay only, never credentials.
$ok($as('tester', 'saveProfile', ['name' => 'Root Admin', 'email' => '']), 'Config admin could not set a display name');
assertTrue($store->profile('tester')['name'] === 'Root Admin', 'Config account profile overlay was not stored');
assertTrue(($as('tester', 'changePassword', ['current_password' => $pw, 'new_password' => 'brand new pass 2', 'confirm_password' => 'brand new pass 2'])['error'] ?? '') === 'This password is managed in config.local.php', 'A config account password was changed in the UI');
$two = new NeoCMS\UserStore(new NeoCMS\FileStore($data . '-two'), ['authentication' => ['a' => $limitedHash, 'b' => $limitedHash], 'roles' => ['a' => 'administrator', 'b' => 'administrator']]);
foreach ([fn() => $two->update('b', 'B', '', 'editor'), fn() => $two->setBlocked('b', true), fn() => $two->delete('b'), fn() => $two->create('B', 'B', '', 'editor', 'correct horse 5')] as $attempt) {
assertRejected($attempt, 'A config account was modified through the store');
}
removeTree($data . '-two');
// Administrators cannot lock themselves out.
foreach (['blockUser' => ['username' => 'tester', 'blocked' => '1'], 'deleteUser' => ['username' => 'tester'], 'saveUser' => ['existing' => 'tester', 'name' => 'T', 'email' => '', 'role' => 'editor']] as $action => $params) {
$err($as('tester', $action, $params + ['confirm_password' => $pw]), "An administrator could run {$action} on themselves");
}
// Edit, role change, block, unblock, delete; a blocked or deleted user loses access on the next request.
$ok($as('tester', 'saveUser', ['existing' => 'ed1', 'name' => 'Edward', 'email' => 'new@example.com', 'role' => 'administrator', 'password' => '', 'confirm_password' => $pw]), 'Editing a user failed');
assertTrue($as('ed1', 'dashboard', [], 'GET')['permissions']['manage'] === true, 'Role change did not apply to the next request');
$ok($as('tester', 'saveUser', ['existing' => 'ed1', 'name' => 'Edward', 'email' => 'new@example.com', 'role' => 'editor', 'password' => '', 'confirm_password' => $pw]), 'Demoting a user failed');
$ok($as('tester', 'blockUser', ['username' => 'ed1', 'blocked' => '1', 'confirm_password' => $pw]), 'Blocking failed');
assertTrue(isset($as('ed1', 'dashboard', [], 'GET')['error']), 'A blocked user kept access');
$ok($as('tester', 'blockUser', ['username' => 'ed1', 'blocked' => '0', 'confirm_password' => $pw]), 'Unblocking failed');
assertTrue(isset($as('ed1', 'dashboard', [], 'GET')['permissions']), 'An unblocked user could not use the CMS');
$ok($as('tester', 'deleteUser', ['username' => 'ed1', 'confirm_password' => $pw]), 'Deleting failed');
assertTrue(isset($as('ed1', 'dashboard', [], 'GET')['error']), 'A deleted user kept access');
// Invitations: single use, expiring, re-issuable, stored only as a hash.
$invite = $as('tester', 'inviteUser', ['name' => 'New Person', 'email' => 'New.Person@example.com', 'role' => 'editor', 'confirm_password' => $pw]);
$ok($invite, 'Inviting failed');
preg_match('/invite=([a-f0-9]{64})$/', $invite['link'], $m);
$token = $m[1] ?? '';
assertTrue($token !== '' && $invite['username'] === 'new.person', 'Invite link or generated username is wrong');
assertTrue(!str_contains((string) file_get_contents($data . '/users.json'), $token), 'The invite token was stored in plain text');
$pending = array_column($as('tester', 'users', [], 'GET')['users'], null, 'username')['new.person'];
assertTrue($pending['status'] === 'invited' && !str_contains(json_encode($pending), $token), 'Pending invitation is listed wrongly');
assertTrue(!isset($store->forAuth()[0]['new.person']), 'An invited account could sign in before accepting');
assertRejected(fn() => $store->acceptInvite(str_repeat('0', 64), 'correct horse 6'), 'A wrong invite token was accepted');
assertRejected(fn() => $store->acceptInvite($token, 'short'), 'A weak password was accepted on an invite');
$reissued = $as('tester', 'inviteUser', ['existing' => 'new.person', 'name' => 'New Person', 'email' => 'new.person@example.com', 'role' => 'editor', 'confirm_password' => $pw]);
preg_match('/invite=([a-f0-9]{64})$/', $reissued['link'], $m2);
assertRejected(fn() => $store->acceptInvite($token, 'correct horse 6'), 'A superseded invite token still worked');
assertTrue($store->acceptInvite($m2[1], 'correct horse 6') === 'new.person', 'A valid invitation was not accepted');
assertRejected(fn() => $store->acceptInvite($m2[1], 'correct horse 7'), 'An invite token could be used twice');
assertTrue(password_verify('correct horse 6', $store->forAuth()[0]['new.person']), 'The invitee password was not set');
$expired = $as('tester', 'inviteUser', ['name' => 'Late', 'email' => 'late@example.com', 'role' => 'editor', 'confirm_password' => $pw]);
preg_match('/invite=([a-f0-9]{64})$/', $expired['link'], $m3);
(new NeoCMS\FileStore($data))->update('users', function (array $d) { $d['users']['late']['invite']['expires'] = time() - 5; return $d; });
assertRejected(fn() => $store->acceptInvite($m3[1], 'correct horse 8'), 'An expired invitation was accepted');
foreach (['new.person', 'late'] as $cleanup) {
$as('tester', 'deleteUser', ['username' => $cleanup, 'confirm_password' => $pw]);
}
// Wrong confirmation passwords are rate limited, and every account event is audited without secrets.
for ($i = 0; $i < 6; $i++) {
$locked = $as('tester', 'deleteUser', ['username' => 'nobody', 'confirm_password' => 'wrong-' . $i]);
}
assertTrue(str_contains($locked['error'] ?? '', 'Too many'), 'Repeated wrong confirmation passwords were not rate limited');
@unlink($data . '/login-attempts.json');
$activity = json_encode((new NeoCMS\FileStore($data))->read('activity'));
foreach (['Added user', 'Blocked user', 'Deleted user', 'Invited user', 'Changed own password', 'Password confirmation failed', 'Changed user role'] as $event) {
assertTrue(str_contains($activity, $event), "Account event not audited: {$event}");
}
assertTrue(!str_contains($activity, 'correct horse') && !str_contains($activity, $token), 'A secret reached the activity log');
// Administrators are told when account data sits inside the web root.
assertTrue($as('tester', 'dashboard', [], 'GET')['notices'] === [], 'A data directory outside the web root raised a recommendation');
$insideConfig = $config;
$insideConfig['dataDirectory'] = $root . '/zz-inside-data';
mkdir($insideConfig['dataDirectory'], 0755, true);
$insideConfig['siteRoot'] = $root;
$_SESSION = ['loggedIn' => true, 'loggedInUser' => 'tester', 'csrfToken' => 'test-token', 'authenticatedAt' => time(), 'lastActivityAt' => time(), 'lastRegeneratedAt' => time()];
$_SERVER['REQUEST_METHOD'] = 'GET';
$_GET = ['action' => 'dashboard'];
$_POST = [];
$_REQUEST = $_GET;
ob_start();
(new NeoCMS\CMSController($insideConfig))->handleRequest();
$insideDashboard = json_decode((string) ob_get_clean(), true);
assertTrue(count($insideDashboard['notices'] ?? []) === 1 && str_contains($insideDashboard['notices'][0], 'outside the web root'), 'No recommendation for a data directory inside the web root');
removeTree($insideConfig['dataDirectory']);
// ---- Media: categories by extension, and refusal of executables and files that contain code ----
$mediaDir = sys_get_temp_dir() . '/neocms-media-' . bin2hex(random_bytes(4));
mkdir($mediaDir, 0755, true);
$zip = function (array $entries): string {
$out = '';
$directory = '';
foreach ($entries as $name => $data) {
$offset = strlen($out);
$crc = crc32($data);
$out .= "PK\x03\x04" . pack('vvvvvVVVvv', 20, 0, 0, 0, 0, $crc, strlen($data), strlen($data), strlen($name), 0) . $name . $data;
$directory .= "PK\x01\x02" . pack('vvvvvvVVVvvvvvVV', 20, 20, 0, 0, 0, 0, $crc, strlen($data), strlen($data), strlen($name), 0, 0, 0, 0, 0, $offset) . $name;
}
return $out . $directory . "PK\x05\x06" . pack('vvvvVVv', 0, 0, count($entries), count($entries), strlen($directory), strlen($out), 0);
};
$write = function (string $name, string $data) use ($mediaDir): string {
$path = $mediaDir . '/' . bin2hex(random_bytes(3));
file_put_contents($path, $data);
return $path;
};
$img = imagecreatetruecolor(8, 8);
ob_start(); imagepng($img); $png = (string) ob_get_clean();
ob_start(); imagejpeg($img); $jpg = (string) ob_get_clean();
ob_start(); imagegif($img); $gif = (string) ob_get_clean();
$wav = 'RIFF' . pack('V', 36 + 8) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 8000, 1, 8) . 'data' . pack('V', 8) . str_repeat("\x80", 8);
$mp4 = "\0\0\0\x18ftypisom\0\0\2\0isomiso2" . "\0\0\0\x10mdat" . str_repeat("\0", 8);
$mp3 = "ID3\x03\0\0\0\0\0\0" . str_repeat("\xFF\xFB\x90\x00" . str_repeat("\0", 413), 4);
$pdf = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
$docx = $zip(['[Content_Types].xml' => '<Types/>', 'word/document.xml' => '<w:document/>']);
$odt = $zip(['mimetype' => 'application/vnd.oasis.opendocument.text', 'content.xml' => '<office:document-content/>']);
// Accepted, and categorised by extension.
foreach ([
['photo.png', $png, 'imagery', 'png'], ['photo.JPG', $jpg, 'imagery', 'jpg'], ['photo.jpeg', $jpg, 'imagery', 'jpg'], ['anim.gif', $gif, 'imagery', 'gif'],
['guide.pdf', $pdf, 'documents', 'pdf'], ['letter.docx', $docx, 'documents', 'docx'], ['letter.odt', $odt, 'documents', 'odt'],
['notes.txt', "Plain notes\nline two\n", 'documents', 'txt'], ['table.csv', "a,b\n1,2\n", 'documents', 'csv'],
['clip.mp4', $mp4, 'video', 'mp4'], ['tone.wav', $wav, 'audio', 'wav'], ['song.mp3', $mp3, 'audio', 'mp3'],
] as [$name, $bytes, $category, $extension]) {
$result = NeoCMS\MediaTypes::inspect($write($name, $bytes), $name);
assertTrue($result['category'] === $category && $result['ext'] === $extension, "{$name} was not accepted as {$category}");
}
// Compressed media contains arbitrary bytes: short sequences such as <%= or <?= must not cause a false refusal.
$noise = $mp4 . str_repeat('<%=<?=<%@', 50000) . random_bytes(200000);
assertTrue(NeoCMS\MediaTypes::inspect($write('noise.mp4', $noise), 'noise.mp4')['category'] === 'video', 'Video containing chance byte sequences was refused');
assertTrue(NeoCMS\MediaTypes::inspect($write('noise.mp3', $mp3 . '<%=<?=' . random_bytes(100000)), 'noise.mp3')['category'] === 'audio', 'Audio containing chance byte sequences was refused');
// Refused: executables in any disguise, code inside otherwise valid files, macros, scripts, and unlisted types.
$exe = "MZ\x90\x00\x03\x00\x00\x00" . str_repeat("\0", 200);
foreach ([
'setup.exe' => $exe, 'notes.pdf' => $exe, 'photo.jpg' => $exe, 'a.php.jpg' => $png, 'report.exe.pdf' => $pdf, 'shell.php' => '<?php echo 1;', 'run.sh' => "#!/bin/sh\necho hi\n",
'evil.jpg' => $jpg . '<?php system($_GET["c"]); ?>', 'clip.mp4' => $mp4 . '<?php echo 1; ?>', 'tone.mp3' => $mp3 . '<script>alert(1)</script>',
'page.txt' => '<script>alert(1)</script>', 'data.csv' => "a,b\n<?= 1 ?>,2\n", 'nul.txt' => "abc\0def",
'js.pdf' => $pdf . "\n/JavaScript (app.alert(1))", 'launch.pdf' => $pdf . "\n/Launch /Win", 'embedded.pdf' => $pdf . "\n/EmbeddedFile",
'macro.docx' => $zip(['[Content_Types].xml' => '<Types/>', 'word/document.xml' => '<w:document/>', 'word/vbaProject.bin' => 'x']),
'ole.docx' => $zip(['[Content_Types].xml' => '<Types/>', 'word/document.xml' => '<w:document/>', 'word/embeddings/oleObject1.bin' => 'x']),
'fake.docx' => $zip(['readme.txt' => 'hello']), 'basic.odt' => $zip(['mimetype' => 'application/vnd.oasis.opendocument.text', 'Basic/Module1.xba' => 'Sub Main']),
'exec.odt' => $zip(['mimetype' => 'application/vnd.oasis.opendocument.text', 'content.xml' => '<x/>', 'tool.exe' => 'MZ']),
'macro.docm' => $docx, 'vector.svg' => '<svg xmlns="http://www.w3.org/2000/svg"/>', 'page.html' => '<html></html>', 'old.doc' => $pdf, 'old.xls' => $pdf,
'archive.zip' => $docx, 'noext' => $png, 'empty.png' => '', 'wrong.jpg' => $png,
] as $name => $bytes) {
assertRejected(fn() => NeoCMS\MediaTypes::inspect($write($name, $bytes), $name), "{$name} was accepted");
}
assertRejected(fn() => NeoCMS\MediaTypes::inspect($write('big.png', $png), 'big.png', ['maxBytes' => 10]), 'An oversize file was accepted');
assertRejected(fn() => NeoCMS\MediaTypes::inspect($write('wide.png', $png), 'wide.png', ['maxWidth' => 4]), 'An oversize image was accepted');
// Stored names carry one dot, are safe, and are recognised as managed media; extensions map to the four categories.
$stored = NeoCMS\MediaTypes::storedName('My Report (Final v2).PDF', 'pdf');
assertTrue(preg_match('/^my-report-final-v2-[a-f0-9]{8}\.pdf$/', $stored) === 1 && substr_count($stored, '.') === 1 && NeoCMS\MediaTypes::isManagedName($stored), 'Stored media name is wrong: ' . $stored);
assertTrue(NeoCMS\MediaTypes::storedName('../../etc/passwd.jpg', 'jpg') !== '' && !str_contains(NeoCMS\MediaTypes::storedName('../../a b.jpg', 'jpg'), '/'), 'A path leaked into a stored name');
assertTrue(NeoCMS\MediaTypes::isManagedName(str_repeat('a', 32) . '.png') && !NeoCMS\MediaTypes::isManagedName('evil.php') && !NeoCMS\MediaTypes::isManagedName('x-12345678.exe') && !NeoCMS\MediaTypes::isManagedName('../x-12345678.pdf'), 'Managed-name check is too permissive');
assertTrue(!in_array('exe', NeoCMS\MediaTypes::extensions(), true) && array_unique(array_map([NeoCMS\MediaTypes::class, 'categoryForExtension'], NeoCMS\MediaTypes::extensions())) === ['imagery', 'documents', 'video', 'audio'] || count(array_unique(array_map([NeoCMS\MediaTypes::class, 'categoryForExtension'], NeoCMS\MediaTypes::extensions()))) === 4, 'Extension map is wrong');
removeTree($mediaDir);
if (DIRECTORY_SEPARATOR === '/') { // chmod is a no-op on Windows
assertTrue((fileperms($data) & 0777) === 0700, 'CMS data directory permissions are too broad');
assertTrue((fileperms($schedulePath) & 0777) === 0600, 'CMS metadata file permissions are too broad');
}
echo "All NeoCMS integration tests passed.\n";
} finally {
+7 -3
View File
@@ -1,7 +1,7 @@
# Uploaded files are images only; deny executable extensions even if one appears by other means.
Options -Indexes
# Uploaded media: images, documents, video and audio only. Deny executable and markup extensions even if one appears by other means.
Options -Indexes -ExecCGI
<FilesMatch "\.(?:php[0-9]?|phtml|phar|cgi|pl|py|sh)$">
<FilesMatch "\.(?:php[0-9]?|phtml|phar|cgi|pl|py|sh|exe|dll|bat|cmd|com|msi|asp|aspx|jsp|jspx|html?|xhtml|shtml|svg|xml|js|jar|swf)$">
Require all denied
</FilesMatch>
@@ -9,4 +9,8 @@ Options -Indexes
Header always set X-Content-Type-Options "nosniff"
Header always set Content-Security-Policy "default-src 'none'; sandbox"
Header always set Cross-Origin-Resource-Policy "same-origin"
# Documents download instead of rendering in the browser.
<FilesMatch "\.(?:pdf|docx|xlsx|pptx|odt|ods|odp|txt|csv)$">
Header always set Content-Disposition "attachment"
</FilesMatch>
</IfModule>