57 Commits
Author SHA1 Message Date
Andy Dixon 1ac1bdcfd5 Merge pull request #42 from GeoffMoore1979/feature/media-users-pages
Lock pages against editor changes
2026-10-02 18:12:33 +01:00
Geoff MooreandClaude Sonnet 5 bc4730eb30 docs: document page locking and prior security fixes
README, the changelog and the built-in welcome page were missing the
page-lock feature and the bootstrap/LoginRateLimiter/Logger hardening
from the earlier security audit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 12:11:09 +01:00
Geoff MooreandClaude Sonnet 5 83ae3a08ff feat: lock pages against editor changes
Administrators can lock a page from the Pages list to stop editors
publishing, saving a draft, or scheduling a change to it. Administrators
are never restricted by a lock. State is stored per-page URI in the new
cms/data/pagelocks.json, following the existing pagetemplates.json
pattern: it moves with the page on rename, is cleared on delete, and is
never copied onto a duplicate.

- New lockPageAction and assertNotLockedForEditor() guard, called from
  save/saveDraft/schedule.
- getPages responses gain a locked flag; the Pages list gets a Lock/
  Unlock row action and a Locked badge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 12:11:06 +01:00
Andy Dixon 7bbc47616c Merge pull request #41 from GeoffMoore1979/feature/media-users-pages
fix: suppress verbose PHP errors and bound login/audit housekeeping cost
2026-10-02 11:28:28 +01:00
Geoff MooreandClaude Sonnet 5 43200d7dfe fix: suppress verbose PHP errors and bound login/audit housekeeping cost
- bootstrap.php: disable display_errors/display_startup_errors in favour of
  PHP's own error log, so an uncaught engine error can never leak a stack
  trace or filesystem path to a client.
- LoginRateLimiter: cap tracked address/identity buckets at 20000, evicting
  the least recently active first, so a distributed flood of distinct
  addresses or login names cannot grow login-attempts.json without bound.
- Logger: prune expired audit logs probabilistically (1-in-100) instead of
  on every request, since every request already builds a Logger and a full
  directory scan on each one scales with total rotated log files.

Found during a self-review security audit; full findings available on
request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 11:24:00 +01:00
Andy Dixon cef826e46e Merge pull request #40 from GeoffMoore1979/feature/media-users-pages
Add Users, media library, menus from the site scan, new-page wizard and deleted-page recovery
2026-09-25 14:27:39 +01:00
Geoff MooreandClaude Sonnet 5 8946915bfe 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>
2026-09-24 17:47:34 +01:00
Andy Dixon 89d64418de Merge pull request #39 from GeoffMoore1979/feature/site-scan-and-editor-workflow
Add site scan, editor-based publishing workflow and subfolder support
2026-09-24 14:09:15 +01:00
Geoff Moore 2ae3e3c0e6 Add site scan, editor-based publishing workflow and subfolder support
Adds a whole-site "Site scan" that analyses pages, images and SEO tags and
makes pages editable in one step, moves Save Draft / Schedule Publish /
Publish Now into the content editor, supports installs in a subfolder, and
hardens metadata handling. Only the cms/ directory is changed.

Added
- Site scan (Tools > Site scan, administrators): reports pages, folders,
  editable/untagged pages, images without alt text, broken local
  references, unused uploads and missing title/description/Open Graph
  tags; then tags <main> or top-level article/section content areas,
  images outside editable regions and photo placeholders, and inserts only
  missing SEO tags. Page source is edited byte-for-byte (no DOM
  re-serialisation), a revision is kept for every changed page, later scans
  list only pages that still need changes, and work runs in small batches
  with a progress bar and "N of M pages" count. New administrator-only
  actions: listSitePages, analyseSitePages, applySiteTagging.
- Click-to-edit images and photo placeholders (role="img" boxes) with a
  Media Library picker and alt text.
- Subfolder installs (basePath / siteRoot auto-detected); root installs
  behave as before.
- Audit/activity entries for cancelled schedules, image uploads, alt-text
  changes and failed scheduled publications (shared Activity class).
- Snackbar (success/error) and table-style dialogs for the page picker,
  revision history, dashboard, shared content, menus and site scan.

Changed
- Editor actions are now Save Draft | Schedule Publish | Publish Now |
  Cancel; saving in the editor and applying image/SEO changes stores a
  private draft. The toolbar no longer has Save Draft, Schedule or Publish.
- CMSController split into PagePaths, ContentDom and Activity; entry points
  share cms/bootstrap.php. Restoring a deleted page uses
  PageNotFoundException instead of matching an error message.

Fixed
- Lost updates on JSON metadata (locked FileStore::update).
- Non-atomic site-wide shared-content/menu writes; non-ASCII text handling.
- Scheduled publishing contending for a lock on every request.
- Revision pruning dropping the newest revision (revisions gain a "ts").
- Stale cached pages in the editor preview after a site is replaced.
- Saved drafts reloading without stylesheets or the correct page path, and
  Publish discarding a draft that was not re-offered in the same session.
- HTML entities in page titles; backslashes in folder names on Windows.

Migration: no manual steps. basePath/siteRoot are optional and detected
automatically. The analyseSite action was replaced by listSitePages and
analyseSitePages.
2026-09-24 13:17:09 +01:00
Andy Dixon a636ecd991 fix: harden CMS security controls 2026-06-08 17:12:42 +01:00
Andy Dixon 42fe5b9304 UI fixes, code tightening, security fixes 2026-03-17 10:19:21 +00:00
Andy Dixon 40cc6b8c66 Fix issue with namespaces for DOM 2025-10-09 17:19:55 +01:00
Andy Dixon d39d344be2 Pages list will only list CMS editable files 2024-12-19 21:04:47 +00:00
Andy Dixon 7f4cf860e2 Update README.md 2024-12-02 19:40:22 +00:00
Andy Dixon 9e0f7cb773 Merge pull request #38 from andydixon/development
Beta 2 Candidate
2024-11-18 20:36:04 +00:00
Andy Dixon 1245ed9f5f Fixed regression of the 'Edit website' button on the welcome page. Major design tweaks to login and header bar 2024-11-18 20:29:46 +00:00
Andy Dixon 8e64fa77b7 Merge pull request #37 from andydixon/development
Fix hiding clone buttons. Fixes #18
2024-11-13 12:34:54 +00:00
Andy Dixon b68c69ebe8 Fix hiding clone buttons. Fixes #18 2024-11-13 12:34:27 +00:00
Andy Dixon 971f4a92c1 Merge pull request #35 from andydixon/development
Massive refactor of code
2024-11-12 21:22:06 +00:00
Andy Dixon d0c38378b6 Massive refactor of code into classes, improve security on the image/file upload handler. Clean up the init.php into a config.php and refactor everything else to work around it.
Resolves issues #13 #14 and #16
2024-11-12 21:20:46 +00:00
Andy Dixon 6d15ad31f2 Merge pull request #34 from andydixon/development
Keep button styles the same and option to show full URL
2024-11-12 19:34:25 +00:00
Andy Dixon 94f1634fc2 Force button text styles 2024-11-12 19:32:56 +00:00
Andy Dixon f8c1892e99 Added option to show full URL in the control bar. Probably could do with styling a bit better though. 2024-11-12 18:48:00 +00:00
Andy Dixon bfa237396c Update welcome.html
Because I am stooopid
2024-11-12 17:14:15 +00:00
Andy Dixon 0b9338fcd6 Merge pull request #33 from andydixon/development
UI refinements
2024-11-11 20:11:40 +00:00
Andy Dixon 2d5ed4cc5d Refined control bar a bit so it's tidier and not on a wonk, refreshed welcome page, and added option to skip welcome page and go straight to the web page 2024-11-11 20:09:48 +00:00
Andy Dixon a6fc206560 Remove icky underline on buttons 2024-11-11 19:50:27 +00:00
Andy Dixon 69bec6f9b7 Merge pull request #32 from andydixon/development
(Not a proper) Fix for issue #31
2024-11-11 19:46:10 +00:00
Andy Dixon 6ac8ad74a0 (Not a proper) Fix for issue 31 2024-11-11 19:44:38 +00:00
Andy Dixon 29e0a969f0 Update index.html
Added example of cloning elements
2024-11-11 19:21:05 +00:00
Andy Dixon 1deaa1cffc Merge pull request #30 from andydixon/development
development
2024-11-11 19:07:58 +00:00
Andy Dixon acdce9d569 Code cleanup 2024-11-11 19:06:59 +00:00
Andy Dixon 9993357728 Added element and child cloning with .neo-dupe - resolves issue #29 2024-11-11 19:06:12 +00:00
Andy Dixon e0bba3c4bf Merge pull request #28 from andydixon/development
Development Sync
2024-10-09 21:14:11 +01:00
Andy Dixon a705f6e3e0 Fix styling of log folder alert 2024-10-09 21:09:37 +01:00
Andy Dixon 03161dbeaa Add check to make sure audit log location is writeable
Some cleanups
2024-10-09 21:06:37 +01:00
Andy Dixon 8b12366183 Merge pull request #27 from andydixon/development
Development Sync
2024-10-09 20:56:19 +01:00
Andy Dixon e5ff977ca4 Clean up CSS 2024-10-09 20:54:17 +01:00
Andy Dixon d5febe1cbe Fix multiple code issues 2024-10-09 20:50:11 +01:00
Andy Dixon 03dd469c27 Fix issue #22
Fix issue with ugly buttons :hover css
2024-10-09 20:44:46 +01:00
Andy Dixon 4067e0f0ce Merge pull request #26 from andydixon/development
Revert accidental commit and apply correct code
2024-10-09 20:36:48 +01:00
Andy Dixon 71a07e0697 ..and more code 2024-10-09 20:35:17 +01:00
Andy Dixon 71386055b0 Merge pull request #25 from andydixon/development
Development sync into master
2024-10-09 20:31:07 +01:00
Andy Dixon 87226b1f36 Fixed issue #24
Fixed issue with audit logs going into wrong location
2024-10-09 20:28:40 +01:00
Andy Dixon 76f96f9200 Fixed issue #24
Fixed issue with audit logs going into wrong location
2024-10-09 20:27:23 +01:00
Andy Dixon 081401b9ef Update README.md
Typo
2024-10-03 08:54:39 +01:00
Andy Dixon b2b7f85aba Update README.md
Rewrote to be more public, added LICENSE information
2024-10-03 08:54:13 +01:00
Andy Dixon af7650c4d4 Create LICENSE 2024-10-03 08:27:27 +01:00
Andy Dixon b9cf812f8e Refined feedback 2024-10-03 08:07:54 +01:00
Andy Dixon 1e5b8fea62 Added feedback when saving page content 2024-10-03 08:07:54 +01:00
Andy Dixon b47b0917ed Hopefully fixed issue #17 - stop adding unwanted tags into html 2024-10-03 08:07:54 +01:00
Andy Dixon d4c6e49b64 Update README.md 2024-10-03 08:07:54 +01:00
Andy Dixon ccc0ebf795 Add new page system implemented, and Select Page system implemented, Code tidied 2024-10-03 08:07:54 +01:00
Andy Dixon e4246eb60d Initial code for New Page templating system 2024-10-03 08:07:54 +01:00
Andy Dixon 7cf45e8243 Added README 2024-10-03 08:07:54 +01:00
Andy Dixon cc2a29d683 Add upload redirect 2024-10-03 08:07:53 +01:00
Andy Dixon c103a8e6dc Code Cleanup 2024-10-03 08:07:53 +01:00
30 changed files with 4759 additions and 793 deletions
+176 -41
View File
@@ -23,19 +23,30 @@ 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. Administrators can Lock a page to stop editors publishing, saving a draft, or
scheduling changes to it; administrators are never restricted by a lock.
* 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 +57,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 +75,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 +117,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, delete and lock 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 +203,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 +216,89 @@ 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).
### Locking Pages
Administrators can **Lock** a page from the Pages list to stop editors publishing, saving a draft, or scheduling a change to
it; a **Locked** badge shows next to the page name for everyone. Administrators are never restricted by a lock, and locking
is independent of Create Template, Duplicate, Rename, and Delete, which were already administrator-only. A lock follows a
page when it is renamed, is cleared when the page is deleted, and is never copied onto a duplicate.
### 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 +318,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 +342,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 +359,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 +388,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
+22
View File
@@ -0,0 +1,22 @@
<?php
/** Shared entry-point setup: loads configuration into $config and registers the NeoCMS class autoloader. */
// Keep stack traces and filesystem paths out of responses; every engine warning still reaches PHP's own error log.
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
ini_set('log_errors', '1');
require_once __DIR__ . '/config.php';
// URL prefix of the site root: "/neocms" for http://localhost/neocms/, "" for a site at the domain root.
// Derived from this script's URL unless config.local.php sets 'basePath' (e.g. behind a path-rewriting proxy).
$config['basePath'] = rtrim((string) ($config['basePath'] ?? (preg_match('#^(.*)/cms/#', $_SERVER['SCRIPT_NAME'] ?? '', $neoBase) ? $neoBase[1] : '')), '/');
// The site root on disk is always the folder containing cms/, whatever DOCUMENT_ROOT says.
$config['siteRoot'] ??= dirname(__DIR__);
$config['security']['cookiePath'] = $config['basePath'] . '/cms';
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once __DIR__ . "/src/{$classPath}.php";
});
+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',
],
+1 -7
View File
@@ -7,13 +7,7 @@
*/
// Load the site-specific credentials, roles, and content settings.
require_once "../config.php";
// Resolve NeoCMS namespaces to the matching class beneath cms/src/.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "../src/{$classPath}.php";
});
require_once __DIR__ . '/../bootstrap.php';
use NeoCMS\CMSController;
+230 -79
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;
}
@@ -109,19 +106,18 @@ body {
outline: 0;
}
.headerButton.dangerButton {
background-color: #6E4D50;
}
.headerButton:hover {
filter: brightness(1.08);
}
/* 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;
@@ -139,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;
@@ -208,26 +208,6 @@ body {
display: none;
}
.filelist-content {
padding: 20px;
background-color: #fefefe;
border: 1px solid #888;
width: 400px;
margin: auto;
}
#fileList {
list-style-type: none;
padding: 0;
}
#fileList li {
padding: 10px;
cursor: pointer;
background-color: #f0f0f0;
margin-bottom: 5px;
}
#pageSearch {
width: 100%;
padding: 9px;
@@ -235,17 +215,6 @@ body {
box-sizing: border-box;
}
#fileList li {
display: flex;
gap: 6px;
align-items: center;
}
#fileList .page-open {
flex: 1;
text-align: left;
}
.cms-dialog label {
display: block;
margin: 12px 0;
@@ -254,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 {
@@ -268,11 +240,47 @@ body {
/* Content-editor actions sit beneath TinyMCE now that no Bootstrap modal is required. */
.editor-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
/* Light, regular-weight buttons; Publish Now is the only filled one, in green. */
#editModal .editor-actions button {
padding: 6px 14px;
border: 1px solid #c9cfd8;
border-radius: 4px;
background: #fff;
color: #374151;
font-size: 13px;
font-weight: 400;
cursor: pointer;
}
#editModal .editor-actions button:hover {
background: #f3f5f8;
}
#editModal .editor-actions #saveBtn {
border-color: #9db8d8;
color: #2f5a8a;
}
#editModal .editor-actions #saveBtn:hover {
background: #eaf1fb;
}
#editModal .editor-actions .publish-now {
background: #2e7d4f;
border-color: #2e7d4f;
color: #fff;
}
#editModal .editor-actions .publish-now:hover {
background: #256a42;
}
/* Tool launcher expands to available width without requiring a row of tiny buttons. */
.tool-grid {
display: grid;
@@ -342,52 +350,38 @@ body {
color: #a51d2d;
}
#fileList li:hover {
background-color: #e0e0e0;
}
.redText {
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;
}
@@ -403,3 +397,160 @@ body {
margin-top: 4px;
}
}
/* Site scan dialogue: selectable page rows and the option group. */
#siteScanOptions { border: 1px solid #ccc; margin: 12px 0; padding: 8px 12px; }
#siteScanOptions label { display: block; margin: 4px 0; }
#siteScanPages { max-height: 40vh; overflow: auto; }
.scan-row { display: block; cursor: pointer; }
.scan-note { color: #555; font-size: 0.9em; }
/* Image dialogue: thumbnail picker and address fields. */
#imagePicker img { cursor: pointer; }
#imageForm label { display: block; margin: 8px 0; }
#imageForm input[type="text"] { display: block; width: 100%; box-sizing: border-box; }
/* Page picker: full-width table with a document icon, draft badge, and per-row actions. */
.filelist-content { padding: 4px 0 0; }
.table-wrap { max-height: 60vh; overflow: auto; border: 1px solid #dfe3ea; border-radius: 8px; }
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.data-table th {
position: sticky; top: 0; z-index: 1; text-align: left; padding: 10px 14px;
background: #f3f5f8; color: #4b5563; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em;
border-bottom: 1px solid #dfe3ea;
}
.data-table td { padding: 10px 14px; border-bottom: 1px solid #edf0f4; vertical-align: middle; }
.data-table tbody tr:last-child td { border-bottom: 0; }
.data-table tbody tr:nth-child(even) { background: #fafbfc; }
.page-row { cursor: pointer; }
.page-row:hover { background: #eaf1fb !important; }
.page-cell { white-space: nowrap; }
.doc-icon { vertical-align: middle; margin-right: 8px; color: #4a7bb7; flex: none; }
.data-table .page-open { border: 0; background: none; padding: 0; font: inherit; font-weight: 600; color: #1f2937; cursor: pointer; text-align: left; }
.data-table .page-open:hover, .data-table .page-open:focus { color: #2563a8; text-decoration: underline; }
.page-title { color: #4b5563; }
.page-date { color: #6b7280; white-space: nowrap; }
.row-actions { white-space: nowrap; text-align: right; }
.row-actions button, .row-button { margin-left: 4px; padding: 4px 9px; font-size: 12px; cursor: pointer; }
.badge-draft {
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
background: #fff3d6; color: #8a5a00; border: 1px solid #f0d58a;
}
.badge-locked { background: #fde2e1; color: #8a1f1f; border-color: #f3b4b2; }
@media (max-width: 700px) { #fileList .page-title, #fileList th:nth-child(2), #fileList .page-date, #fileList th:nth-child(3) { display: none; } }
/* Site scan progress bar and file count. */
#siteScanProgress { margin: 0 0 12px; }
#scanProgressBar { width: 100%; height: 16px; accent-color: #4a7bb7; }
#scanProgressText { margin-top: 4px; font-size: 13px; color: #4b5563; }
/* Shared polish for dialogue content: stat tiles, section headings, empty states, badges. */
.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 14px; }
.stat { padding: 10px 14px; background: #f3f5f8; border: 1px solid #dfe3ea; border-radius: 8px; font-size: 13px; color: #4b5563; }
.stat strong { font-size: 18px; color: #1f2937; margin-right: 2px; }
.dialog-section { margin: 18px 0 8px; font-size: 14px; color: #374151; }
.empty-state { padding: 18px; text-align: center; color: #6b7280; background: #fafbfc; border: 1px dashed #dfe3ea; border-radius: 8px; }
.page-sub { display: block; margin: 2px 0 0 26px; font-size: 12px; font-weight: 400; color: #6b7280; white-space: normal; }
.check-cell { width: 34px; text-align: center; }
.data-table .page-cell strong { font-weight: 600; color: #1f2937; }
.badge-pending, .badge-failed { padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.badge-pending { background: #e7f0fb; color: #1e5a99; border: 1px solid #bcd3ee; }
.badge-failed { background: #fdeaec; color: #a51d2d; border: 1px solid #f0b8bf; }
.issue { border-radius: 6px; }
/* New-page templates as selectable rows. */
#radioList { display: grid; gap: 8px; margin-bottom: 14px; }
.template-option { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid #dfe3ea; border-radius: 8px; cursor: pointer; }
.template-option:hover { background: #f3f7fc; }
.template-option:has(input:checked) { border-color: #4a7bb7; background: #eaf1fb; }
.template-option .doc-icon { margin: 0; }
/* Media library cards. */
.media-card { border-radius: 10px; border-color: #dfe3ea; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); transition: box-shadow 0.15s; }
.media-card:hover { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); }
.media-card img { border-radius: 6px; }
.media-card strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.media-card small { color: #6b7280; }
/* Snackbar: bottom-centre toast for success and error messages. */
#message-bar {
position: fixed; left: 50%; bottom: 28px; z-index: 100000;
display: flex; align-items: center; gap: 12px;
min-width: 280px; max-width: min(560px, calc(100vw - 32px)); box-sizing: border-box;
padding: 12px 10px 12px 14px; background: #fff; color: #1f2937;
border-left: 5px solid #18733c; border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
font: 14px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
opacity: 0; transform: translate(-50%, 24px); pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
#message-bar[hidden] { display: none; }
#message-bar.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
#message-bar.is-error { border-left-color: #a51d2d; }
.snack-icon { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #18733c; color: #fff; font-size: 14px; font-weight: 700; }
#message-bar.is-error .snack-icon { background: #a51d2d; }
#message-bar.is-success .snack-icon::before { content: "\2713"; }
#message-bar.is-error .snack-icon::before { content: "!"; }
.snack-text { flex: 1; word-break: break-word; }
.snack-close { flex: none; border: 0; background: none; padding: 0 6px; font-size: 22px; line-height: 1; color: #6b7280; cursor: pointer; }
.snack-close:hover { color: #111827; }
@media (prefers-reduced-motion: reduce) { #message-bar { transition: none; } }
/* New-page dialogue: fill the dialogue instead of sitting in a bordered box inside it. */
.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; }
/* 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; }
+37 -47
View File
@@ -1,28 +1,25 @@
<?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.
require_once "config.php";
// Resolve project classes without requiring Composer for this deliberately lightweight CMS.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "./src/{$classPath}.php";
});
require_once __DIR__ . '/bootstrap.php';
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']);
@@ -52,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));
@@ -70,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;
}
@@ -81,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.
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
// 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);
$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,11 +147,11 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
chmod($target_file, 0644);
// Seed media metadata from the original basename; editors can improve the alt text later.
$location = '/uploads/' . $filename;
$location = $config['basePath'] . '/uploads/' . $filename;
$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) {
@@ -181,7 +164,14 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
}
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
echo json_encode(['location' => $location]);
// 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 ' . (['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, 'name' => $filename, 'category' => $inspection['category']]);
} else {
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
+161 -51
View File
@@ -2,19 +2,13 @@
/** Server-rendered shell for the authenticated NeoCMS administration application. */
// Load configuration before initialising sessions and role-aware controls.
require_once "config.php";
// Resolve project classes without requiring Composer.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "./src/{$classPath}.php";
});
require_once __DIR__ . '/bootstrap.php';
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';
@@ -24,7 +18,7 @@ if (!is_string($editableClass) || !preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $ed
// Keep the administration shell private by redirecting anonymous requests to login.
if (!$authentication->isLoggedIn()) {
header("Location: /cms/login/");
header("Location: " . $config['basePath'] . "/cms/login/");
exit;
}
SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
@@ -35,16 +29,21 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<head>
<title>NeoCMS</title>
<meta name="csrf-token" content="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">
<meta name="neo-base-path" content="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>">
<meta name="neo-editable-class" content="<?php echo htmlspecialchars($editableClass, ENT_QUOTES, 'UTF-8'); ?>">
<script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha384-fgGyf7Mo7DURSOMnOy7ed+dkq5Job205Gnzu6QIg0BOHKaqt4D76Dt8VlDCzcMHV" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/ui/1.14.2/jquery-ui.min.js" integrity="sha384-tBcEcHGtNy7/Mx08+YxuvQ6v6s0N2jgehtFiT+bLtGwTj/txXtB/L5GqXfggm5sS" crossorigin="anonymous"></script>
<script src="/cms/tinymce/tinymce.min.js"></script>
<script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/tinymce/tinymce.min.js"></script>
<link rel="stylesheet" href="https://code.jquery.com/ui/1.14.2/themes/base/jquery-ui.css" integrity="sha384-pUvA/6DQjteMxpaV6uGxZ1QuYrFLJgrLMvBWf06VcJIg6ky/Y5m3UZJlrv11V1I+" crossorigin="anonymous">
<link rel="stylesheet" href="/cms/css/editor.css">
<link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/editor.css">
</head>
<body data-role="<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>">
<!-- Status messages sit outside the flex layout so they may overlay the full viewport. -->
<div id="message-bar" style="display:none;"></div>
<div id="message-bar" role="status" aria-live="polite" hidden>
<span class="snack-icon" aria-hidden="true"></span>
<span class="snack-text"></span>
<button type="button" class="snack-close" aria-label="Dismiss message">&times;</button>
</div>
<div class="pageContainer">
<div class="controls">
<div class="logo"></div>
@@ -55,23 +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" id="saveDraft">Save Draft</button>
<button class="headerButton publish-only" id="scheduleButton">Schedule</button>
<button class="headerButton dangerButton publish-only" id="savePage">Publish</button>
<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
@@ -81,40 +77,66 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
?>
</div>
</div>
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? "/" : "welcome.html"; ?>"
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8') . "/" : "welcome.html"; ?>"
class="frame" sandbox="allow-same-origin"></iframe>
</div>
<!-- TinyMCE content editor. Saving here changes the preview, not the public page. -->
<!-- TinyMCE content editor. Save Draft keeps changes private; Schedule Publish and Publish Now act on the whole page. -->
<div id="editModal" class="cms-dialog" title="Edit Content">
<textarea id="editor"></textarea>
<div class="editor-actions">
<button id="saveBtn" type="button">Save</button>
<button id="closeEditorBtn" type="button">Close</button>
</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">
<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>
<button id="saveBtn" type="button" title="Applies your changes and saves them as a private draft">Save Draft</button>
<button id="scheduleBtn" type="button" class="publish-only" title="Publishes this page automatically at a time you choose">Schedule Publish</button>
<button id="publishBtn" type="button" class="publish-only publish-now" title="Replaces the live page now; a revision of the old page is kept">Publish Now</button>
<button id="closeEditorBtn" type="button">Cancel</button>
</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">
<ul id="fileList">
<!-- JavaScript populates discovered editable pages here. -->
</ul>
<input id="pageSearch" type="search" placeholder="Search pages" aria-label="Search pages">
<div class="table-wrap">
<table id="fileList" class="data-table">
<thead>
<tr><th>Page</th><th>Title</th><th>Last modified</th><th class="manage-col">Actions</th></tr>
</thead>
<tbody>
<!-- JavaScript populates discovered editable pages here. -->
</tbody>
</table>
<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>
@@ -127,13 +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>
@@ -165,25 +194,106 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<!-- Site-wide shared-region registry and propagation form. -->
<div id="sharedDialog" class="cms-dialog" title="Shared Content">
<div id="sharedList"></div>
<form id="sharedForm">
<label>Block name<input id="sharedKey" type="text" pattern="[A-Za-z0-9_-]+" required></label>
<label>HTML content<textarea id="sharedContent" rows="8" required></textarea></label>
<button type="submit">Save and update every page</button>
</form>
<div id="sharedList"></div>
</div>
<!-- Line-oriented menu editor supporting an optional parent label for nesting. -->
<div id="menusDialog" class="cms-dialog" title="Navigation Menus">
<form id="menuForm">
<label>Menu name<input id="menuName" type="text" pattern="[A-Za-z0-9_-]+" required></label>
<div id="menuList"></div>
<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 id="menuList"></div>
</div>
<script src="/cms/js/cms.js"></script>
<!-- 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>
<progress id="scanProgressBar" max="100" value="0"></progress>
<div id="scanProgressText" role="status" aria-live="polite"></div>
</div>
<div id="siteScanSummary"></div>
<fieldset id="siteScanOptions">
<legend>Changes to make</legend>
<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>
<button id="siteScanApply" type="button">Apply to selected pages</button>
</div>
<!-- Replace or describe an image that sits outside an editable region. -->
<div id="imageDialog" class="cms-dialog" title="Edit Image">
<div id="imagePicker" class="media-grid"></div>
<form id="imageForm">
<label>Image address<input id="imageSrc" type="text" required></label>
<label>Alternative text<input id="imageAlt" type="text"></label>
<button type="submit">Apply image</button>
</form>
</div>
<script src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/js/cms.js"></script>
</body>
</html>
+956 -113
View File
File diff suppressed because it is too large Load Diff
+54 -17
View File
@@ -2,29 +2,29 @@
/** Login form and authentication request handler. */
// Load account configuration before constructing authentication services.
require_once "../config.php";
// Resolve NeoCMS classes without introducing a package-manager requirement.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once "../src/{$classPath}.php";
});
require_once __DIR__ . '/../bootstrap.php';
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) {
@@ -39,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'] : '';
@@ -46,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);
@@ -54,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(
@@ -62,7 +88,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
);
// Redirect after POST so browser refreshes do not resubmit credentials.
header("Location: /cms/");
header("Location: " . $config['basePath'] . "/cms/");
exit;
} else {
$rateLimiter->recordFailure($address, $username);
@@ -72,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
$username
);
$error = "Invalid username or password";
$error = "Invalid login name or password";
}
}
}
@@ -81,27 +107,38 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
<html lang="en-gb">
<head>
<title>NeoCMS Login</title>
<link rel="stylesheet" href="/cms/css/login.css"/>
<link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/login.css"/>
</head>
<body>
<!-- The compact login card is intentionally independent of the heavier administration UI. -->
<div class="login-container">
<img class="logo" src="/cms/img/loginlogo.png" alt="NeoCMS logo"/>
<h2>NeoCMS Login</h2>
<img class="logo" src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/img/loginlogo.png" alt="NeoCMS logo"/>
<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>
+1 -1
View File
@@ -1,4 +1,4 @@
<?php
// Audit logs are not a public attraction; return to the authenticated CMS instead.
http_response_code(404);
header("Location: /cms/");
header("Location: ../");
+2 -8
View File
@@ -6,14 +6,8 @@
* publishing is useful, while a public button marked "publish everything due" is rather less so.
*/
// Load the same configuration used by the interactive CMS.
require_once __DIR__ . '/config.php';
// Register the small project autoloader because NeoCMS deliberately has no Composer dependency.
spl_autoload_register(function ($class) {
$classPath = str_replace('\\', DIRECTORY_SEPARATOR, $class);
require_once __DIR__ . "/src/{$classPath}.php";
});
// Load the same configuration and autoloader used by the interactive CMS.
require_once __DIR__ . '/bootstrap.php';
// Refuse web requests even if the web server accidentally exposes this script.
if (PHP_SAPI !== 'cli') {
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace NeoCMS;
/** Records one operational event in both the Dashboard activity feed and the daily audit log. */
final class Activity
{
public function __construct(private FileStore $store, private Logger $logger)
{
}
public function record(string $user, string $action, string $target): void
{
$entry = ['created' => date(DATE_ATOM), 'user' => $user, 'action' => $action, 'target' => $target];
// Retaining the latest 250 entries keeps the dashboard useful without growing forever.
$this->store->update('activity', function (array $entries) use ($entry) {
$entries[] = $entry;
return array_slice($entries, -250);
});
$this->logger->write($action . ': ' . $target, $user);
}
}
+25 -12
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. */
@@ -25,6 +25,9 @@ class Authentication
/** Maximum total lifetime of an authenticated session. */
private int $absoluteTimeout;
/** URL path the session cookie is scoped to; the CMS directory, which moves when the site is in a subfolder. */
private string $cookiePath;
/** Optional explicit Secure-cookie setting for TLS-terminating reverse proxies. */
private ?bool $cookieSecure;
@@ -36,6 +39,7 @@ class Authentication
$this->idleTimeout = max(300, (int) ($sessionOptions['idleTimeout'] ?? 1800));
$this->absoluteTimeout = max($this->idleTimeout, (int) ($sessionOptions['absoluteTimeout'] ?? 43200));
$this->cookieSecure = isset($sessionOptions['cookieSecure']) ? (bool) $sessionOptions['cookieSecure'] : null;
$this->cookiePath = (string) ($sessionOptions['cookiePath'] ?? '/cms');
$this->startSession();
$this->enforceSessionLifetime();
}
@@ -65,7 +69,7 @@ class Authentication
session_set_cookie_params([
'httponly' => true,
'path' => '/cms',
'path' => $this->cookiePath,
'samesite' => 'Lax',
'secure' => $isHttps,
]);
@@ -87,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();
@@ -127,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;
}
@@ -143,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
{
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
<?php
namespace NeoCMS;
/** Pure HTML transformations for shared regions, generated menus, and editable-region detection. */
final class ContentDom
{
/** Return each data-neo-shared region's inner HTML, keyed by its shared name. */
public static function sharedBlocks(string $html): array
{
$found = [];
foreach ((new \DOMXPath(self::load($html)))->query('//*[@data-neo-shared]') as $node) {
$key = $node->getAttribute('data-neo-shared');
if ($key !== '') {
$found[$key] = self::innerHtml($node);
}
}
return $found;
}
/** Replace every region marked data-neo-shared="$key"; null when the page has none. */
public static function withShared(string $html, string $key, string $content): ?string
{
if (!str_contains($html, 'data-neo-shared')) {
return null;
}
$dom = self::load($html);
// $key is restricted to [a-zA-Z0-9_-] by the caller, so it cannot break out of the XPath string.
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-shared="' . $key . '"]');
if ($nodes->length === 0) {
return null;
}
foreach ($nodes as $node) {
self::replaceInnerHtml($dom, $node, $content);
}
return $dom->saveHTML();
}
/**
* 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;
}
$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;
}
if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) {
$edits[] = [$nav['end'], $nav['close'] - $nav['end']];
}
}
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 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 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, $uri, $basePath): string {
if (empty($children[$parent])) {
return '';
}
$html = '<ul>';
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 . '"' . $current . '>' . $label . '</a>' . $nested . '</li>';
}
return $html . '</ul>';
};
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
{
$dom = new \DOMDocument();
@$dom->loadHTML($html);
// Class-token matching avoids treating "editable-extra" as though it were "editable".
$query = '//*[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]';
return (new \DOMXPath($dom))->query($query)->length > 0;
}
/** Parse HTML as UTF-8 (libxml assumes Latin-1 otherwise) without keeping the encoding hint in the output. */
private static function load(string $html): \DOMDocument
{
$dom = new \DOMDocument();
@$dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
foreach (iterator_to_array($dom->childNodes) as $node) {
if ($node->nodeType === XML_PI_NODE) {
$dom->removeChild($node);
}
}
return $dom;
}
/**
* Replace a DOM node's children with an HTML fragment.
*
* DOMDocument parses forgiving HTML here rather than strict XML, allowing ordinary authoring
* fragments such as <br> without demanding that editors suddenly become XML librarians.
*/
private static function replaceInnerHtml(\DOMDocument $dom, \DOMNode $node, string $html): void
{
while ($node->firstChild) {
$node->removeChild($node->firstChild);
}
$wrapper = self::load('<div id="neo-fragment">' . $html . '</div>')->getElementById('neo-fragment');
if (!$wrapper) {
$node->appendChild($dom->createTextNode($html));
return;
}
foreach (iterator_to_array($wrapper->childNodes) as $child) {
$node->appendChild($dom->importNode($child, true));
}
}
/** Serialise only the children of a DOM node, excluding the node's own wrapper tag. */
private static function innerHtml(\DOMNode $node): string
{
$html = '';
foreach ($node->childNodes as $child) {
$html .= $node->ownerDocument->saveHTML($child);
}
return $html;
}
}
+27 -1
View File
@@ -68,6 +68,32 @@ final class FileStore
@chmod($path, 0600);
}
/**
* Read, modify, and write a JSON document under an exclusive lock so concurrent requests cannot lose updates.
*
* @param callable $fn Receives the current array and returns the array to store.
* @return array The stored document.
*/
public function update(string $name, callable $fn, array $default = []): array
{
$lockPath = $this->directory('locks') . preg_replace('/[^a-zA-Z0-9_.-]/', '', $name) . '.lock';
$lock = fopen($lockPath, 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
if (is_resource($lock)) {
fclose($lock);
}
throw new \RuntimeException('Unable to lock CMS data');
}
try {
$value = $fn($this->read($name, $default));
$this->write($name, $value);
return $value;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Atomically write a private content file beneath a managed subdirectory. */
public function writePrivateFile(string $directory, string $filename, string $content): string
{
@@ -106,7 +132,7 @@ final class FileStore
/** Create a directory recursively or fail with a useful application-level exception. */
private function ensureDirectory(string $path): void
{
if (!is_dir($path) && !mkdir($path, 0700, true) && !is_dir($path)) {
if (!is_dir($path) && !@mkdir($path, 0700, true) && !is_dir($path)) {
throw new \RuntimeException('Unable to create CMS data directory');
}
@chmod($path, 0700);
+3 -1
View File
@@ -42,7 +42,9 @@ class Logger
}
@chmod($this->logsDir, 0700);
if ($this->auditEnabled) {
// Pruning scans every rotated log file; a Logger is built on every request, so do this
// occasionally rather than on each one (the same probabilistic approach as PHP's own session GC).
if ($this->auditEnabled && random_int(1, 100) === 1) {
$this->pruneExpiredLogs();
}
}
+13
View File
@@ -16,6 +16,9 @@ final class LoginRateLimiter
private int $maxIdentityAttempts;
private int $maxAddressAttempts;
/** Hard ceiling on tracked buckets, so a distributed flood of distinct addresses or login names cannot grow the state file without bound. */
private const MAX_TRACKED_KEYS = 20000;
/** Configure the protected state file and throttling thresholds. */
public function __construct(string $dataDirectory, array $options = [])
{
@@ -93,6 +96,16 @@ final class LoginRateLimiter
}
}
unset($record);
if (count($state) > self::MAX_TRACKED_KEYS) {
// Evict the least recently active buckets first; an evicted bucket simply restarts its count.
uasort($state, static function (array $a, array $b): int {
$lastA = max((int) ($a['locked_until'] ?? 0), $a['attempts'] ? (int) end($a['attempts']) : 0);
$lastB = max((int) ($b['locked_until'] ?? 0), $b['attempts'] ? (int) end($b['attempts']) : 0);
return $lastA <=> $lastB;
});
$state = array_slice($state, -self::MAX_TRACKED_KEYS, null, true);
}
}
/** Serialise state updates beneath an exclusive lock to avoid concurrent lost updates. */
+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']);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace NeoCMS;
/** Raised when a URI does not resolve to an existing managed page. */
final class PageNotFoundException extends \RuntimeException
{
}
+163
View File
@@ -0,0 +1,163 @@
<?php
namespace NeoCMS;
/** Resolves public page URIs to filesystem paths inside the document root, rejecting anything that escapes it. */
final class PagePaths
{
public function __construct(private string $documentRoot, private int $maxManagedPages, private int $maxScannedEntries)
{
}
/** Resolve an existing public URI to a canonical, in-root HTML file path. */
public function existing(string $uri): string
{
$uri = $this->normaliseUri($uri);
$candidate = $this->documentRoot . ltrim($uri, '/');
if (is_dir($candidate)) {
$candidate = rtrim($candidate, '/\\') . '/index.html';
} elseif (!preg_match('/\.html?$/i', $candidate)) {
$candidate .= '.html';
}
$real = realpath($candidate);
if (!$real || !str_starts_with($real, $this->documentRoot) || str_starts_with($real, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR) || !preg_match('/\.html?$/i', $real)) {
throw new PageNotFoundException('Invalid page path');
}
return $real;
}
/** Build a safe path for a page that may not exist yet. */
public function newPath(string $uri): string
{
$path = $this->documentRoot . ltrim($uri, '/');
if (!str_starts_with($path, $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
$this->assertNoSymlinks($path);
$parent = realpath(dirname($path));
if ($parent && !str_starts_with($parent . DIRECTORY_SEPARATOR, $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
throw new \RuntimeException('CMS files cannot be managed as pages');
}
return $path;
}
/** Reject page targets whose existing path components contain symbolic links. */
public function assertNoSymlinks(string $path): void
{
$relative = substr($path, strlen($this->documentRoot));
$current = rtrim($this->documentRoot, DIRECTORY_SEPARATOR);
foreach (explode('/', str_replace('\\', '/', $relative)) as $component) {
if ($component === '') {
continue;
}
$current .= DIRECTORY_SEPARATOR . $component;
if (is_link($current)) {
throw new \RuntimeException('Symbolic links are not permitted in managed page paths');
}
if (file_exists($current)) {
$real = realpath($current);
if ($real === false || !str_starts_with($real . (is_dir($real) ? DIRECTORY_SEPARATOR : ''), $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
}
}
}
/** Canonicalise a new page URI, append .html when absent, and enforce safe characters. */
public function normaliseNewUri(string $uri): string
{
$uri = $this->normaliseUri($uri);
if (!preg_match('/\.html?$/i', $uri)) {
$uri .= '.html';
}
if (!preg_match('#^/[a-zA-Z0-9_./-]+\.html?$#', $uri)) {
throw new \RuntimeException('Invalid page name');
}
return $uri;
}
/** Canonicalise a request URI and reject traversal or null-byte input. */
public function normaliseUri(string $uri): string
{
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
$path = '/' . ltrim(str_replace('\\', '/', $path), '/');
if (str_contains($path, '..') || str_contains($path, "\0")) {
throw new \RuntimeException('Invalid URI');
}
return $path;
}
/** Accept an absolute HTTP(S) link or normalise a local site path. */
public function normaliseLink(string $url): string
{
if (preg_match('#^https?://#i', $url)) {
return filter_var($url, FILTER_SANITIZE_URL);
}
return $this->normaliseUri($url);
}
/** Return every public .html or .htm file while excluding the CMS application itself. */
public function files(): array
{
$files = [];
$scanned = 0;
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->documentRoot, \FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
$scanned++;
if ($scanned > $this->maxScannedEntries) {
throw new \RuntimeException('Document root scan limit exceeded');
}
if (count($files) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit exceeded');
}
if ($file->isLink()) {
continue;
}
$path = $file->getRealPath();
if (!$file->isFile() || $path === false || !str_starts_with($path, $this->documentRoot) || !preg_match('/\.html?$/i', $path)) {
continue;
}
if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) {
continue;
}
$files[] = $path;
}
return $files;
}
/** Convert an absolute page path back into its public, slash-separated URI. */
public function uriFor(string $path): string
{
return '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot)));
}
/**
* Whether a page's local asset reference points at a real file inside the site.
*
* @return bool|null null for external or non-file references (http:, //, data:, #anchor), which are not checked.
*/
public function refExists(string $pagePath, string $ref, string $basePath = ''): ?bool
{
$ref = trim($ref);
if ($ref === '' || preg_match('#^([a-z][a-z0-9+.-]*:|//|\#)#i', $ref)) {
return null;
}
$ref = rawurldecode((string) preg_replace('/[?#].*$/s', '', $ref));
if ($ref === '' || str_contains($ref, "\0")) {
return $ref === '' ? null : false;
}
if ($ref[0] === '/') {
// The upload endpoint writes URLs with the site's URL prefix; strip it to get a path inside the site.
if ($basePath !== '' && str_starts_with($ref, $basePath . '/')) {
$ref = substr($ref, strlen($basePath));
}
$candidate = $this->documentRoot . ltrim($ref, '/');
} else {
$candidate = dirname($pagePath) . DIRECTORY_SEPARATOR . $ref;
}
$real = realpath($candidate);
return $real !== false && str_starts_with($real, $this->documentRoot) && is_file($real);
}
}
+428
View File
@@ -0,0 +1,428 @@
<?php
namespace NeoCMS;
/**
* Finds what NeoCMS could edit in a page (content containers, images, SEO tags) and adds the markers.
*
* Pages are never re-serialised through a DOM parser: a tokenizer locates tag offsets and apply() splices
* text in, so every byte outside the inserted markers is preserved exactly.
*/
final class SiteAnalyser
{
private const VOID = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
private const SKIPPED = ['header', 'nav', 'footer', 'aside', 'form'];
private const SKIPPED_ROLES = ['navigation', 'banner', 'contentinfo'];
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, bool $allNavs = false): array
{
$c = self::scan($html, $editableClass, true);
return [
'title' => $c['title'],
'existingRegions' => $c['existing'],
'proposed' => array_map(fn(int $i) => $c['els'][$i]['name'], $c['regions']),
'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, 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
{
$c = self::scan($html, $editableClass, !empty($options['content']));
$edits = [];
$changes = [];
if (!empty($options['content'])) {
foreach ($c['regions'] as $i) {
$edit = self::classEdit($html, $c['els'][$i], $editableClass);
if ($edit) {
$edits[] = $edit;
$changes[] = "Made <{$c['els'][$i]['name']}> editable";
}
}
}
if (!empty($options['images'])) {
foreach ($c['images'] as $img) {
if (!$img['inRegion'] && !$img['tagged'] && $img['taggable']) {
$edits[] = [$img['insertAt'], ' data-neo-image'];
$changes[] = !empty($img['placeholder'])
? 'Made image placeholder editable' . ($img['label'] !== '' ? ': ' . $img['label'] : '')
: 'Made image editable: ' . $img['src'];
}
}
}
if (!empty($options['seo']) && ($seo = self::seoEdit($html, $c))) {
$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;
}
usort($edits, fn(array $a, array $b) => $b[0] <=> $a[0]);
foreach ($edits as [$offset, $text]) {
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
}
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. */
private static function scan(string $html, string $class, bool $withRegions): array
{
$els = self::elements($html);
$existing = 0;
foreach ($els as $k => $e) {
$els[$k]['editable'] = self::hasClassToken($e['attrs'], $class) || ($e['parent'] >= 0 && $els[$e['parent']]['editable']);
$existing += self::hasClassToken($e['attrs'], $class) ? 1 : 0;
}
$regions = ($withRegions && $existing === 0) ? self::chooseRegions($html, $els) : [];
$inChosen = static function (int $k) use ($els, $regions): bool {
for ($i = $k; $i >= 0; $i = $els[$i]['parent']) {
if (in_array($i, $regions, true)) {
return true;
}
}
return false;
};
// Containers that already hold a real <img> are not placeholders.
$hasImg = [];
foreach ($els as $e) {
for ($p = $e['name'] === 'img' ? $e['parent'] : -1; $p >= 0; $p = $els[$p]['parent']) {
$hasImg[$p] = true;
}
}
$images = [];
$refs = [];
$meta = [];
$title = null;
$h1 = '';
$firstParagraph = '';
foreach ($els as $k => $e) {
$name = $e['name'];
if ($name === 'img') {
$src = self::attr($e['attrs'], 'src');
$images[] = [
'src' => (string) $src,
'hasAlt' => self::attr($e['attrs'], 'alt') !== null,
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
'inRegion' => $e['editable'] || $inChosen($k),
'taggable' => $src !== null && $src !== '' && !str_starts_with(strtolower(trim($src)), 'data:')
&& !(self::attr($e['attrs'], 'width') === '1' && self::attr($e['attrs'], 'height') === '1'),
'insertAt' => $e['start'] + 1 + strlen($name),
'skip' => $e['skip'],
];
if ($src) {
$refs[] = ['type' => 'img', 'url' => $src];
}
} elseif (strtolower((string) self::attr($e['attrs'], 'role')) === 'img' && !in_array($name, ['svg'], true)
&& self::attr($e['attrs'], 'aria-hidden') !== 'true' && empty($hasImg[$k])) {
// A styled box standing in for a photo (for example a "Your photo here" block).
$images[] = [
'src' => '', 'hasAlt' => true, 'placeholder' => true,
'label' => (string) self::attr($e['attrs'], 'aria-label'),
'tagged' => self::attr($e['attrs'], 'data-neo-image') !== null,
'inRegion' => $e['editable'] || $inChosen($k),
'taggable' => true,
'insertAt' => $e['start'] + 1 + strlen($name),
'skip' => $e['skip'],
];
} elseif ($name === 'link' && preg_match('/\bstylesheet\b/i', (string) self::attr($e['attrs'], 'rel')) && ($h = self::attr($e['attrs'], 'href'))) {
$refs[] = ['type' => 'link', 'url' => $h];
} elseif ($name === 'script' && ($s = self::attr($e['attrs'], 'src'))) {
$refs[] = ['type' => 'script', 'url' => $s];
} elseif ($name === 'meta') {
$key = self::attr($e['attrs'], 'name') ?? self::attr($e['attrs'], 'property');
if ($key !== null && trim((string) self::attr($e['attrs'], 'content')) !== '') {
$meta[strtolower($key)] = trim(html_entity_decode((string) self::attr($e['attrs'], 'content'), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
} elseif ($name === 'title' && $title === null) {
$title = self::text($html, $e);
} elseif ($name === 'h1' && $h1 === '') {
$h1 = self::text($html, $e);
} elseif ($name === 'p' && $firstParagraph === '' && !$e['skip'] && strlen($t = self::text($html, $e)) >= 20) {
$firstParagraph = $t;
}
}
$canonical = false;
foreach ($els as $e) {
if ($e['name'] === 'link' && preg_match('/\bcanonical\b/i', (string) self::attr($e['attrs'], 'rel'))) {
$canonical = true;
}
}
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' => [
'title' => trim((string) $title) !== '', 'description' => isset($meta['description']), 'canonical' => $canonical,
'ogTitle' => isset($meta['og:title']), 'ogDescription' => isset($meta['og:description']),
'ogImage' => isset($meta['og:image']), 'robots' => isset($meta['robots']),
],
];
}
/** Flat list of elements with source offsets, parent index, skipped-subtree and close-position data. */
private static function elements(string $html): array
{
$pattern = '#<!--.*?-->|<(script|style|textarea|template)\b[^>]*>.*?</\1\s*>|<(/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#is';
preg_match_all($pattern, $html, $tokens, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
$els = [];
$stack = [];
foreach ($tokens as $t) {
// Group 3 is the tag name; comments and raw script/style blocks never set it, but script src still matters.
if (!isset($t[3]) || $t[3][1] < 0) {
if (!empty($t[1][0]) && preg_match('#^<script\b([^>]*)>#i', $t[0][0], $m)) {
$els[] = ['name' => 'script', 'attrs' => $m[1], 'start' => $t[0][1], 'end' => $t[0][1] + strlen($m[0]), 'parent' => $stack ? $stack[count($stack) - 1] : -1, 'close' => $t[0][1] + strlen($t[0][0]), 'skip' => true, 'editable' => false];
}
continue;
}
$name = strtolower($t[3][0]);
if ($t[2][0] === '/') {
// Close the nearest open element of this name and anything implicitly left open inside it.
for ($j = count($stack) - 1; $j >= 0 && $els[$stack[$j]]['name'] !== $name; $j--);
if ($j >= 0) {
for ($x = count($stack) - 1; $x >= $j; $x--) {
$els[$stack[$x]]['close'] = $t[0][1];
}
$stack = array_slice($stack, 0, $j);
}
continue;
}
$parent = $stack ? $stack[count($stack) - 1] : -1;
$attrs = $t[4][0];
$skip = in_array($name, self::SKIPPED, true)
|| in_array(strtolower((string) self::attr($attrs, 'role')), self::SKIPPED_ROLES, true)
|| ($parent >= 0 && $els[$parent]['skip']);
$els[] = ['name' => $name, 'attrs' => $attrs, 'start' => $t[0][1], 'end' => $t[0][1] + strlen($t[0][0]), 'parent' => $parent, 'close' => null, 'skip' => $skip, 'editable' => false];
if (!in_array($name, self::VOID, true) && !str_ends_with(rtrim($attrs), '/')) {
$stack[] = count($els) - 1;
}
}
return $els;
}
/** Pick the containers to make editable: <main>, else top-level articles, else text-bearing sections. */
private static function chooseRegions(string $html, array $els): array
{
$isMain = fn(array $e) => $e['name'] === 'main' || strtolower((string) self::attr($e['attrs'], 'role')) === 'main';
foreach ([$isMain, fn(array $e) => $e['name'] === 'article', fn(array $e) => $e['name'] === 'section' && strlen(self::text($html, $e)) >= self::MIN_SECTION_TEXT] as $match) {
$chosen = [];
foreach ($els as $k => $e) {
if ($e['skip'] || !$match($e)) {
continue;
}
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
if (in_array($p, $chosen, true)) {
continue 2;
}
}
$chosen[] = $k;
}
if ($chosen) {
return $chosen;
}
}
return [];
}
/** Edit that adds the editable class to an element's opening tag, or null when it cannot be done safely. */
private static function classEdit(string $html, array $el, string $class): ?array
{
$tag = substr($html, $el['start'], $el['end'] - $el['start']);
if (preg_match('/\sclass\s*=\s*(["\'])(.*?)\1/is', $tag, $m, PREG_OFFSET_CAPTURE)) {
$closeQuote = $m[0][1] + strlen($m[0][0]) - 1;
return [$el['start'] + $closeQuote, trim($m[2][0]) === '' ? $class : ' ' . $class];
}
if (preg_match('/\sclass\s*=/i', $tag)) {
return null; // Unquoted class value: leave for manual tagging.
}
return [$el['start'] + 1 + strlen($el['name']), ' class="' . $class . '"'];
}
/** One combined insertion of every missing SEO tag just before </head>, with values derived from the page. */
private static function seoEdit(string $html, array $c): ?array
{
if (!preg_match('#^([ \t]*)</head\s*>#im', $html, $m, PREG_OFFSET_CAPTURE)
&& !preg_match('#</head\s*>#i', $html, $m, PREG_OFFSET_CAPTURE)) {
return null;
}
$seo = $c['seo'];
$esc = static fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$trim = static fn(string $s, int $n) => mb_strlen($s) > $n ? rtrim(mb_substr($s, 0, $n - 3)) . '...' : $s;
$valid = static fn(string $s) => $s !== '' && mb_check_encoding($s, 'UTF-8');
$title = trim($c['title']) !== '' ? trim($c['title']) : $c['h1'];
// Prefer the page's own description over one derived from its first paragraph.
$description = (string) ($c['metaText']['description'] ?? '') !== '' ? $c['metaText']['description'] : ($valid($c['paragraph']) ? $trim($c['paragraph'], 155) : '');
$image = '';
// A content image makes a better social preview than a header logo.
foreach ([false, true] as $allowSkipped) {
foreach ($c['images'] as $img) {
// Open Graph needs an absolute or root-relative address; a page-relative one would be wrong.
if ($img['taggable'] && preg_match('#^(/|https?://)#i', $img['src']) && ($allowSkipped || !$img['skip'])) {
$image = $img['src'];
break 2;
}
}
}
$tags = [];
$changes = [];
if (!$seo['title'] && $valid($c['h1'])) {
$tags[] = '<title>' . $esc($c['h1']) . '</title>';
$changes[] = 'Added page title';
}
if (!$seo['description'] && $description !== '') {
$tags[] = '<meta name="description" content="' . $esc($description) . '">';
$changes[] = 'Added meta description';
}
if (!$seo['ogTitle'] && $valid($title)) {
$tags[] = '<meta property="og:title" content="' . $esc($title) . '">';
$changes[] = 'Added og:title';
}
if (!$seo['ogDescription'] && $description !== '') {
$tags[] = '<meta property="og:description" content="' . $esc($description) . '">';
$changes[] = 'Added og:description';
}
if (!$seo['ogImage'] && $image !== '') {
$tags[] = '<meta property="og:image" content="' . $esc($image) . '">';
$changes[] = 'Added og:image';
}
if (!$tags) {
return null;
}
$atLineStart = isset($m[1]) && $m[1][0] !== null && $m[0][1] === $m[1][1];
$offset = $m[0][1];
// Match the page's own line endings so CRLF files do not end up with mixed endings.
$nl = str_contains($html, "\r\n") ? "\r\n" : "\n";
$body = ' ' . implode($nl . ' ', $tags) . $nl;
$text = $atLineStart ? $body : $nl . $body;
return ['edit' => [$offset, $text], 'changes' => $changes];
}
/** Visible text of an element (tags stripped, entities decoded, whitespace collapsed). */
private static function text(string $html, array $el): string
{
$end = $el['close'] ?? $el['end'];
$inner = substr($html, $el['end'], max(0, $end - $el['end']));
return trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($inner), ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '');
}
private static function hasClassToken(string $attrs, string $class): bool
{
return in_array($class, preg_split('/\s+/', (string) self::attr($attrs, 'class')) ?: [], true);
}
/** Return an attribute's value from a tag's attribute string, '' for a bare attribute, null when absent. */
private static function attr(string $attrs, string $name): ?string
{
if (!preg_match('/(?:^|\s)' . preg_quote($name, '/') . '(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?(?=\s|\/|$)/i', $attrs, $m)) {
return null;
}
return (string) ($m[1] ?? '') . (string) ($m[2] ?? '') . (string) ($m[3] ?? '');
}
}
+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');
}
}
+13 -8
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>
@@ -31,39 +33,42 @@
<section>
<h2>Edit and publish</h2>
<p>Open <strong>Pages</strong>, choose a page, and select any region carrying the configured editable class.</p>
<p><strong>Save Draft</strong> keeps work private. <strong>Publish</strong> creates a revision before replacing the public page.</p>
<p>The editor offers <strong>Save Draft</strong> (private), <strong>Schedule Publish</strong>, and <strong>Publish Now</strong>, which creates a revision before replacing the public page.</p>
</section>
<!-- 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>Administrators can also <strong>Lock</strong> a page to stop editors publishing, saving a draft, or scheduling changes to it; administrators are never restricted by a lock.</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>
+116 -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,19 @@ 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. |
| `pagelocks.json` | Pages an administrator has locked against editor changes. |
| `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 +76,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 +183,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.
+99
View File
@@ -0,0 +1,99 @@
# 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.
- **Lock pages**: administrators can lock a page from the Pages list to stop editors publishing, saving a draft, or scheduling a change to it, without restricting administrators themselves. A "Locked" badge shows on the page for everyone; the Lock/Unlock action sits beside Create Template, Duplicate, Rename and Delete. State is stored in `cms/data/pagelocks.json`, keyed by page URI, and follows a page through rename (cleared on delete, never copied by Duplicate). New action `lockPage`; `getPages` items gain `locked`.
### Fixed
- **Verbose PHP errors**: `display_errors` and `display_startup_errors` are now disabled in `bootstrap.php`, so an uncaught engine error can never leak a stack trace or filesystem path to a client; every error still reaches PHP's own error log.
- **Unbounded login-throttling state**: `LoginRateLimiter` now caps tracked address/identity buckets at 20,000, evicting the least recently active first, so a distributed flood of distinct addresses or login names cannot grow `login-attempts.json` without bound.
- **Audit-log pruning cost**: `Logger` now prunes expired audit logs probabilistically (1-in-100 constructions) instead of scanning the whole logs directory on every single request.
### 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`, `lockPage`; the dashboard response gains `deleted` and `notices`; `media` items gain `category`, `ext` and `original`; `getPages` items gain `locked`.
### 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, page locking (lock/unlock, rename moves the lock, delete clears
it, duplicate never copies it), 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.
- A self-review security audit found no critical or high-severity issues; the three fixes above address its medium/low findings.
## 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.
+428 -10
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);
@@ -225,24 +219,39 @@ try {
// Administrator page operations must support duplicate, rename, delete, and restoration.
$request('page', ['operation' => 'duplicate', 'uri' => '/index.html', 'target' => '/copy.html'], 'POST');
assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/copy.html']), 'Duplicating a page copied its lock');
$request('lockPage', ['uri' => '/copy.html', 'value' => '1'], 'POST');
$request('page', ['operation' => 'rename', 'uri' => '/copy.html', 'target' => '/renamed.html'], 'POST');
assertTrue(is_file($root . '/renamed.html'), 'Duplicate or rename failed');
$locksAfterRename = (new NeoCMS\FileStore($data))->read('pagelocks');
assertTrue(!isset($locksAfterRename['/copy.html']) && isset($locksAfterRename['/renamed.html']), 'Renaming a page did not move its lock entry');
$request('page', ['operation' => 'delete', 'uri' => '/renamed.html'], 'POST');
assertTrue(!is_file($root . '/renamed.html'), 'Page delete failed');
assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/renamed.html']), 'Deleting a page left a stale lock entry');
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 +264,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 +314,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 +323,403 @@ try {
assertTrue($indexRevisionCount <= 2, 'Per-page revision retention limit was exceeded');
// Private CMS state should not be readable by unrelated operating-system users.
assertTrue((fileperms($data) & 0777) === 0700, 'CMS data directory permissions are too broad');
assertTrue((fileperms($schedulePath) & 0777) === 0600, 'CMS metadata file permissions are too broad');
// ---- 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}");
}
// ---- Page locks: administrators toggle, only editors are blocked from editing ----
$ok($as('tester', 'lockPage', ['uri' => '/index.html', 'value' => '1']), 'Admin could not lock a page');
$pages = $as('tester', 'getPages', [], 'GET');
assertTrue((array_column($pages, 'locked', 'url'))['/index.html'] === true, 'Locked page was not reported as locked');
$err($as('ed1', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Editor saved a draft on a locked page');
$ok($as('tester', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Administrator was blocked by their own lock');
$ok($as('tester', 'lockPage', ['uri' => '/index.html', 'value' => '0']), 'Admin could not unlock a page');
$ok($as('ed1', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Editor still blocked after unlock');
// 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>