Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c34564a0a9 | ||
|
|
d946aaddc5 | ||
|
|
8c221399d9 | ||
|
|
fb2cd30801 | ||
|
|
0795eaf3a5 | ||
|
|
d5f8c3487b | ||
|
|
38563714dd | ||
|
|
012a688160 | ||
|
|
ab54b1415b | ||
|
|
4b8650c4c1 | ||
|
|
e7373477c5 | ||
|
|
cdf15d94fc | ||
|
|
5a02c25074 | ||
|
|
09de8ca167 | ||
|
|
85874dcd0c | ||
|
|
adb33af82a | ||
|
|
359b4e5b6e | ||
|
|
f766f4a6f5 | ||
|
|
7289c12e8d | ||
|
|
18547d7a17 | ||
|
|
77c6d88f80 | ||
|
|
a7f699a77b | ||
|
|
290a74cb29 | ||
|
|
af9276de95 | ||
|
|
8ee09b881c | ||
|
|
5f49677bb4 | ||
|
|
9700ee807a | ||
|
|
3878fd4c3d | ||
|
|
65331f905d | ||
|
|
ea123055d7 | ||
|
|
c848926cd9 | ||
|
|
18853fee87 | ||
|
|
3a87bf104a | ||
|
|
c1b3580d8b | ||
|
|
abfb0225cd | ||
|
|
7a544dad21 | ||
|
|
870843a8d3 | ||
|
|
70c46e3fc9 | ||
|
|
1def39102e | ||
|
|
cb0a8a3a62 | ||
|
|
b9db592be2 | ||
|
|
5381cd8145 | ||
|
|
e55c6384ce | ||
|
|
e35d2834bd | ||
|
|
e5ed2d3f32 | ||
|
|
a5bad61832 | ||
|
|
6e75313cab | ||
|
|
a1f2d18cf0 |
@@ -23,30 +23,19 @@ 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: 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.
|
||||
* 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.
|
||||
* 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: 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.
|
||||
* 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.
|
||||
* Accessibility Checks: Check common authoring problems such as missing alt text, empty links, heading jumps, missing labels,
|
||||
and absent page metadata before publishing.
|
||||
* 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.
|
||||
* Roles and Activity: Use editor, publisher, and administrator roles. The dashboard displays drafts, schedules, system
|
||||
problems, and recent activity.
|
||||
|
||||
## Installation Guide
|
||||
|
||||
@@ -57,15 +46,8 @@ 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
|
||||
@@ -75,21 +57,17 @@ Tracked defaults live in `cms/config.php`. Deployment credentials and overrides
|
||||
|
||||
| Setting | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `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`). |
|
||||
| `authentication` | array | Maps usernames to `password_hash()` values; plaintext credentials are rejected. |
|
||||
| `roles` | array | Maps usernames to `editor`, `publisher`, or `administrator`. |
|
||||
| `security` | array | Configures session expiry, login throttling, request limits, storage quotas, and audit-log retention. |
|
||||
| `uploads` | array | Configures the per-file size limit (all media types), image dimension, file-count, and aggregate-storage limits. |
|
||||
| `uploads` | array | Configures image size, 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 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.
|
||||
`dataDirectory` is an internal override used by the integration suite and specialised deployments. Normal installations
|
||||
should allow NeoCMS to use `cms/data/`.
|
||||
|
||||
### Editable Class
|
||||
|
||||
@@ -117,52 +95,27 @@ created pages are immediately discoverable by the page picker.
|
||||
|
||||
### Users and 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`.
|
||||
Passwords must be generated with `password_hash()` and stored under `authentication` in `cms/config.local.php`. Assign the
|
||||
same username a role under `roles`:
|
||||
|
||||
```php
|
||||
'authentication' => [
|
||||
'admin' => '$2y$10$...',
|
||||
'author' => '$2y$10$...',
|
||||
'publisher' => '$2y$10$...',
|
||||
],
|
||||
'roles' => [
|
||||
'admin' => 'administrator',
|
||||
'author' => 'editor',
|
||||
'publisher' => 'publisher',
|
||||
],
|
||||
```
|
||||
|
||||
There are two roles:
|
||||
* `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.
|
||||
|
||||
| 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -203,12 +156,7 @@ of its shared regions.
|
||||
|
||||
### Navigation Menus
|
||||
|
||||
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:
|
||||
Menu items use one line each in this format:
|
||||
|
||||
```text
|
||||
Home | /
|
||||
@@ -216,89 +164,13 @@ About | /about.html
|
||||
Team | /team.html | About
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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, 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.
|
||||
revision of the previous public file first. Deleting a page also retains its last revision.
|
||||
|
||||
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
|
||||
@@ -318,20 +190,14 @@ The server timezone controls PHP date handling; the browser converts the selecte
|
||||
|
||||
## Administration Workflow
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Page Markup Recommendations
|
||||
|
||||
@@ -342,7 +208,7 @@ common authoring issues but is not a complete accessibility audit.
|
||||
## Requirements
|
||||
|
||||
* PHP 8 running on Linux
|
||||
* PHP DOM and fileinfo extensions (the fileinfo extension is used to check uploads; the zip extension is not needed)
|
||||
* PHP DOM and fileinfo extensions
|
||||
* PHP sessions enabled
|
||||
* Write access to `cms/data/`, `cms/logs/`, `uploads/`, and managed HTML pages
|
||||
* A modern browser with JavaScript enabled
|
||||
@@ -359,7 +225,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|exe|dll|bat|cmd|com|msi|asp|aspx|jsp|jspx|html?|xhtml|shtml|svg|xml|js|jar|swf)$ { deny all; }
|
||||
location ~ ^/uploads/.*\.(php[0-9]?|phtml|phar|cgi|pl|py|sh)$ { deny all; }
|
||||
```
|
||||
|
||||
Also disable directory listings, prevent PHP source download through server misconfiguration, serve the CMS only over
|
||||
@@ -388,9 +254,8 @@ 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, 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
|
||||
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
|
||||
list used by this repository.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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";
|
||||
});
|
||||
@@ -11,9 +11,6 @@ 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,
|
||||
|
||||
+1
-2
@@ -10,8 +10,7 @@ $config = [
|
||||
'authentication' => [
|
||||
// 'admin' => '$2y$10$replace-with-a-real-password-hash',
|
||||
],
|
||||
// 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.
|
||||
// Grant each user the least powerful role they need: editor, publisher, or administrator.
|
||||
'roles' => [
|
||||
// 'admin' => 'administrator',
|
||||
],
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
*/
|
||||
|
||||
// Load the site-specific credentials, roles, and content settings.
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
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";
|
||||
});
|
||||
|
||||
use NeoCMS\CMSController;
|
||||
|
||||
|
||||
+79
-230
@@ -27,15 +27,14 @@ 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: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: "logo url user" "logo url buttons";
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px 16px;
|
||||
gap: 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. */
|
||||
@@ -51,29 +50,33 @@ body {
|
||||
|
||||
/* Product mark remains a fixed size while neighbouring controls wrap around it. */
|
||||
.controls .logo {
|
||||
background-image: url("../img/logo.png");
|
||||
background-image: url("/cms/img/logo.png");
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
grid-area: logo;
|
||||
flex: 0 0 60px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Top right: user and Log out; the menu buttons sit directly beneath them. */
|
||||
/* The two toolbar rows stay together and yield horizontal space when necessary. */
|
||||
.controls .buttonContainer {
|
||||
grid-area: buttons;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 7px 12px;
|
||||
margin-left: auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
justify-content: flex-end;
|
||||
margin: 3px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.publish-controls {
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,18 +109,19 @@ body {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.headerButton.dangerButton {
|
||||
background-color: #6E4D50;
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Current page path and unpublished-state label. */
|
||||
.controls #urlbox {
|
||||
grid-area: url;
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
color: #dee2e6;
|
||||
@@ -135,18 +139,14 @@ body {
|
||||
|
||||
/* Compact account, role, logout, and operational-warning area. */
|
||||
.controls .loggedInDetails {
|
||||
grid-area: user;
|
||||
justify-self: end;
|
||||
flex: 0 1 auto;
|
||||
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,6 +208,26 @@ 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;
|
||||
@@ -215,6 +235,17 @@ 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;
|
||||
@@ -223,9 +254,6 @@ 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 {
|
||||
@@ -240,47 +268,11 @@ 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;
|
||||
@@ -350,38 +342,52 @@ body {
|
||||
color: #a51d2d;
|
||||
}
|
||||
|
||||
#fileList li:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.redText {
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
/* At medium widths the buttons take their own row under the user block; the path stays beside the logo. */
|
||||
/* At medium widths the page path receives its own row. */
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer .controls {
|
||||
grid-template-areas: "logo url user" "buttons buttons buttons";
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls #urlbox {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.controls .buttonContainer {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* On small screens controls stack into touch-friendly full-width rows; the user block stays top right. */
|
||||
/* On small screens controls stack into touch-friendly full-width rows. */
|
||||
@media (max-width: 760px) {
|
||||
.pageContainer .controls {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas: "logo user" "url url" "buttons buttons";
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.controls .logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-basis: 48px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls .buttonContainer,
|
||||
.controls .loggedInDetails,
|
||||
.controls #urlbox {
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.controls .buttonContainer ul {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -397,160 +403,3 @@ 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; }
|
||||
|
||||
+47
-37
@@ -1,25 +1,28 @@
|
||||
<?php
|
||||
/**
|
||||
* Authenticated media-upload endpoint (images, documents, video, audio), also used by TinyMCE for pasted images.
|
||||
* Authenticated TinyMCE image-upload endpoint.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Load credentials, role assignments, and shared application settings.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
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";
|
||||
});
|
||||
|
||||
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(...\NeoCMS\UserStore::authArgs($config));
|
||||
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
|
||||
if (!$authentication->isLoggedIn()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorised']);
|
||||
@@ -49,6 +52,14 @@ 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));
|
||||
@@ -59,7 +70,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' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
echo json_encode(['error' => 'Upload request is too large.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -70,32 +81,38 @@ 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' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
echo json_encode(['error' => 'File size exceeds the maximum limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// 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] ?? '');
|
||||
}
|
||||
// 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);
|
||||
|
||||
// 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) {
|
||||
if (!array_key_exists($mime_type, $allowed_types)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => $exception->getMessage()]);
|
||||
echo json_encode(['error' => 'Invalid file type.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Generated names keep the original filename out of the URL namespace and allow only one extension.
|
||||
$filename = MediaTypes::storedName($originalName, $inspection['ext']);
|
||||
// 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;
|
||||
|
||||
// Create the public upload directory on a fresh installation.
|
||||
$uploadRoot = __DIR__ . '/../uploads';
|
||||
@@ -147,11 +164,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 = $config['basePath'] . '/uploads/' . $filename;
|
||||
$location = '/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, 'original' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', basename($originalName)) ?? '', 0, 200), 'by' => $authentication->getLoggedInUser()];
|
||||
$metadata[$filename] = ['alt' => $altText];
|
||||
try {
|
||||
$store->write('media', $metadata);
|
||||
} catch (\Throwable $exception) {
|
||||
@@ -164,14 +181,7 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
}
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
// 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']]);
|
||||
echo json_encode(['location' => $location]);
|
||||
} else {
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
|
||||
+51
-161
@@ -2,13 +2,19 @@
|
||||
/** Server-rendered shell for the authenticated NeoCMS administration application. */
|
||||
|
||||
// Load configuration before initialising sessions and role-aware controls.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
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";
|
||||
});
|
||||
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
|
||||
// Prepare the session values required by both the rendered shell and JavaScript client.
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
|
||||
$csrfToken = $authentication->getCsrfToken();
|
||||
// Only a single valid CSS class is allowed; malformed values fall back safely.
|
||||
$editableClass = $config['editableClass'] ?? 'editable';
|
||||
@@ -18,7 +24,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: " . $config['basePath'] . "/cms/login/");
|
||||
header("Location: /cms/login/");
|
||||
exit;
|
||||
}
|
||||
SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
@@ -29,21 +35,16 @@ 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="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/tinymce/tinymce.min.js"></script>
|
||||
<script src="/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="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/editor.css">
|
||||
<link rel="stylesheet" href="/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" 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">×</button>
|
||||
</div>
|
||||
<div id="message-bar" style="display:none;"></div>
|
||||
<div class="pageContainer">
|
||||
<div class="controls">
|
||||
<div class="logo"></div>
|
||||
@@ -54,20 +55,23 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
<div class="buttonContainer">
|
||||
<div class="toolbar-row">
|
||||
<button class="headerButton" id="dashboardButton">Dashboard</button>
|
||||
<button class="headerButton manage-only" id="newPage">New Page</button>
|
||||
<button class="headerButton" id="selectPage">Pages</button>
|
||||
<button class="headerButton" id="mediaButton">Media</button>
|
||||
<button class="headerButton" id="seoButton">SEO</button>
|
||||
<button class="headerButton manage-only" id="menusButton">Navigation Menus</button>
|
||||
<button class="headerButton" id="moreButton">Tools</button>
|
||||
</div>
|
||||
<div class="toolbar-row publish-controls">
|
||||
<button class="headerButton viewport-button 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loggedInDetails">
|
||||
<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->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?>
|
||||
(<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>)<br/>
|
||||
<button id="logoutButton" class="link-button">Log out</button><br/>
|
||||
<?php
|
||||
@@ -77,66 +81,40 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8') . "/" : "welcome.html"; ?>"
|
||||
<iframe id="frameContainer" src="<?php echo $config['skipWelcomePage'] ? "/" : "welcome.html"; ?>"
|
||||
class="frame" sandbox="allow-same-origin"></iframe>
|
||||
</div>
|
||||
<!-- TinyMCE content editor. Save Draft keeps changes private; Schedule Publish and Publish Now act on the whole page. -->
|
||||
<!-- TinyMCE content editor. Saving here changes the preview, not the public page. -->
|
||||
<div id="editModal" class="cms-dialog" title="Edit Content">
|
||||
<textarea id="editor"></textarea>
|
||||
<div class="editor-actions">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Searchable page picker with role-dependent management actions. -->
|
||||
<div id="fileListDialog" title="Pages">
|
||||
<div id="fileListDialog" title="Select an Existing Page">
|
||||
<div class="filelist-content">
|
||||
<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>
|
||||
<input id="pageSearch" type="search" placeholder="Search pages">
|
||||
<ul id="fileList">
|
||||
<!-- JavaScript populates discovered editable pages here. -->
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,20 +127,13 @@ 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="siteScanButton" class="manage-only">Site scan</button>
|
||||
<button id="usersButton">Users</button>
|
||||
<button id="menusButton" class="manage-only">Navigation menus</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reusable uploaded-image browser and metadata editor. -->
|
||||
<div id="mediaDialog" class="cms-dialog" title="Media Library">
|
||||
<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>
|
||||
<p>Select an image to insert it into the open editable region.</p>
|
||||
<div id="mediaList" class="media-grid"></div>
|
||||
</div>
|
||||
|
||||
@@ -194,106 +165,25 @@ 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">
|
||||
<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>
|
||||
<form id="menuForm">
|
||||
<label>Menu name<input id="menuName" type="text" pattern="[A-Za-z0-9_-]+" required></label>
|
||||
<label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | / About | /about.html Team | /team.html | About" required></textarea></label>
|
||||
<button type="submit">Save menu</button>
|
||||
</form>
|
||||
<div id="menuList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Own account (everyone), role descriptions, and account management (administrators only). -->
|
||||
<div id="usersDialog" class="cms-dialog" title="Users">
|
||||
<h3 class="dialog-section">My account</h3>
|
||||
<form id="profileForm" autocomplete="off">
|
||||
<label>Login name <span class="scan-note">(used to sign in; set by an administrator)</span><input id="profileLogin" type="text" readonly></label>
|
||||
<label>Display name<input id="profileName" type="text" maxlength="80" required autocomplete="name"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="profileEmail" type="email" maxlength="254" autocomplete="email"></label>
|
||||
<label>Current password <span class="scan-note">(only needed to change the email address)</span><input id="profileCurrent" type="password" maxlength="4096" autocomplete="current-password"></label>
|
||||
<button type="submit">Save details</button>
|
||||
</form>
|
||||
<form id="passwordForm" autocomplete="off">
|
||||
<h3 class="dialog-section">Change password</h3>
|
||||
<label>Current password<input id="pwCurrent" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<label>New password <span class="scan-note">(12 to 72 characters)</span><input id="pwNew" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<label>Confirm new password<input id="pwConfirm" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
<p id="passwordManaged" class="scan-note" hidden>Your password is managed in the site configuration (config.local.php) and cannot be changed here.</p>
|
||||
|
||||
<h3 class="dialog-section">Roles</h3>
|
||||
<div id="rolesList"></div>
|
||||
|
||||
<div id="userAdmin" class="manage-only">
|
||||
<h3 class="dialog-section">User accounts</h3>
|
||||
<div id="userList"></div>
|
||||
<p class="scan-note"><button id="userAddButton" type="button">Add user</button> <button id="userInviteButton" type="button">Invite user</button> Accounts marked Config are managed in config.local.php and cannot be changed here.</p>
|
||||
<form id="userForm" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="userFormTitle"></h3>
|
||||
<input id="userExisting" type="hidden">
|
||||
<label id="userUsernameRow">Login name <span class="scan-note">(3 to 32 letters, numbers, . - _)</span><input id="userUsername" type="text" maxlength="32" autocomplete="off"></label>
|
||||
<label>Display name<input id="userName" type="text" maxlength="80" required autocomplete="off"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="userEmail" type="email" maxlength="254" autocomplete="off"></label>
|
||||
<label>Role<select id="userRole"><option value="editor">Editor</option><option value="administrator">Administrator</option></select></label>
|
||||
<label id="userPasswordRow">Password <span class="scan-note" id="userPasswordNote"></span><input id="userPassword" type="password" maxlength="72" autocomplete="new-password"></label>
|
||||
<label>Your password <span class="scan-note">(to confirm this change)</span><input id="userConfirm" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit" id="userSubmit">Save user</button> <button type="button" id="userCancel">Cancel</button>
|
||||
</form>
|
||||
<form id="confirmBox" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="confirmTitle"></h3>
|
||||
<label>Your password<input id="confirmPassword" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit">Confirm</button> <button type="button" id="confirmCancel">Cancel</button>
|
||||
</form>
|
||||
<div id="inviteResult" hidden>
|
||||
<h3 class="dialog-section">Invitation link</h3>
|
||||
<p class="scan-note">Send this link to the person. It is shown only once, works once, and expires in 7 days. Their login name is <strong id="inviteLogin"></strong>.</p>
|
||||
<input id="inviteLink" type="text" readonly> <button type="button" id="inviteCopy">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Whole-site analysis with one-click tagging of content regions, images, and SEO tags. Administrators only. -->
|
||||
<div id="siteScanDialog" class="cms-dialog" title="Site Scan">
|
||||
<div id="siteScanProgress" hidden>
|
||||
<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>
|
||||
<script src="/cms/js/cms.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+114
-957
File diff suppressed because it is too large
Load Diff
+17
-54
@@ -2,29 +2,29 @@
|
||||
/** Login form and authentication request handler. */
|
||||
|
||||
// Load account configuration before constructing authentication services.
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
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";
|
||||
});
|
||||
|
||||
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(...\NeoCMS\UserStore::authArgs($config));
|
||||
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
|
||||
$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,31 +39,6 @@ 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'] : '';
|
||||
@@ -71,7 +46,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
|
||||
if (strlen($username) > 128 || strlen($password) > 4096) {
|
||||
http_response_code(400);
|
||||
$error = 'Invalid login name or password';
|
||||
$error = 'Invalid username or password';
|
||||
} elseif (($retryAfter = $rateLimiter->retryAfter($address, $username)) > 0) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
@@ -79,7 +54,6 @@ 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(
|
||||
@@ -88,7 +62,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
);
|
||||
|
||||
// Redirect after POST so browser refreshes do not resubmit credentials.
|
||||
header("Location: " . $config['basePath'] . "/cms/");
|
||||
header("Location: /cms/");
|
||||
exit;
|
||||
} else {
|
||||
$rateLimiter->recordFailure($address, $username);
|
||||
@@ -98,7 +72,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
$username
|
||||
);
|
||||
|
||||
$error = "Invalid login name or password";
|
||||
$error = "Invalid username or password";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,38 +81,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<title>NeoCMS Login</title>
|
||||
<link rel="stylesheet" href="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/css/login.css"/>
|
||||
<link rel="stylesheet" href="/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="<?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>
|
||||
<img class="logo" src="/cms/img/loginlogo.png" alt="NeoCMS logo"/>
|
||||
<h2>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'); ?>">
|
||||
<?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="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
<input type="password" id="password" name="password" required>
|
||||
|
||||
<button type="submit">Log In</button>
|
||||
<?php endif; ?>
|
||||
<p class="shamelessPlug">NeoCMS ©<?php echo date('Y'); ?> Andy Dixon</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
// Audit logs are not a public attraction; return to the authenticated CMS instead.
|
||||
http_response_code(404);
|
||||
header("Location: ../");
|
||||
header("Location: /cms/");
|
||||
|
||||
@@ -6,8 +6,14 @@
|
||||
* publishing is useful, while a public button marked "publish everything due" is rather less so.
|
||||
*/
|
||||
|
||||
// Load the same configuration and autoloader used by the interactive CMS.
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
// 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";
|
||||
});
|
||||
|
||||
// Refuse web requests even if the web server accidentally exposes this script.
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class Authentication
|
||||
/** Map of usernames to password hashes. */
|
||||
private array $credentials;
|
||||
|
||||
/** Map of usernames to editor or administrator roles (the legacy publisher role counts as editor). */
|
||||
/** Map of usernames to editor, publisher, or administrator roles. */
|
||||
private array $roles;
|
||||
|
||||
/** Maximum permitted inactivity for an authenticated session. */
|
||||
@@ -25,9 +25,6 @@ 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;
|
||||
|
||||
@@ -39,7 +36,6 @@ 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();
|
||||
}
|
||||
@@ -69,7 +65,7 @@ class Authentication
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'path' => $this->cookiePath,
|
||||
'path' => '/cms',
|
||||
'samesite' => 'Lax',
|
||||
'secure' => $isHttps,
|
||||
]);
|
||||
@@ -91,7 +87,10 @@ class Authentication
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['loggedIn'] = true;
|
||||
$_SESSION['loggedInUser'] = $username;
|
||||
$_SESSION['role'] = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$configuredRole = $this->roles[$username] ?? 'editor';
|
||||
$_SESSION['role'] = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
|
||||
? $configuredRole
|
||||
: 'editor';
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
|
||||
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
|
||||
$_SESSION['authenticatedAt'] = time();
|
||||
@@ -128,7 +127,10 @@ class Authentication
|
||||
public function getRole(): string
|
||||
{
|
||||
$username = $this->getLoggedInUser();
|
||||
$role = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$configuredRole = $this->roles[$username] ?? 'editor';
|
||||
$role = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
|
||||
? $configuredRole
|
||||
: 'editor';
|
||||
$_SESSION['role'] = $role;
|
||||
return $role;
|
||||
}
|
||||
@@ -141,29 +143,14 @@ class Authentication
|
||||
public function can(string $capability): bool
|
||||
{
|
||||
$permissions = [
|
||||
'editor' => ['draft', 'upload', 'publish', 'schedule'],
|
||||
'editor' => ['draft', 'upload'],
|
||||
'publisher' => ['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
|
||||
{
|
||||
|
||||
+383
-814
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -68,32 +68,6 @@ 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
|
||||
{
|
||||
@@ -132,7 +106,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);
|
||||
|
||||
@@ -42,9 +42,7 @@ class Logger
|
||||
}
|
||||
@chmod($this->logsDir, 0700);
|
||||
|
||||
// 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) {
|
||||
if ($this->auditEnabled) {
|
||||
$this->pruneExpiredLogs();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ 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 = [])
|
||||
{
|
||||
@@ -96,16 +93,6 @@ 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. */
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
<?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']);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/** Raised when a URI does not resolve to an existing managed page. */
|
||||
final class PageNotFoundException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
<?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] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
+8
-13
@@ -16,8 +16,6 @@
|
||||
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>
|
||||
@@ -33,42 +31,39 @@
|
||||
<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>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>
|
||||
<p><strong>Save Draft</strong> keeps work private. <strong>Publish</strong> 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. 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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Asset and metadata management. -->
|
||||
<section>
|
||||
<h2>Media and SEO</h2>
|
||||
<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 media library reuses uploaded images, records alternative text, and reports usage counts.</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. 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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Publication timing and preview controls. -->
|
||||
<section>
|
||||
<h2>Schedule and preview</h2>
|
||||
<p>Editors may queue a future release. The desktop, tablet, and mobile icon buttons resize the preview without altering page markup.</p>
|
||||
<p>Publishers may queue a future release. Desktop, tablet, and mobile controls resize the preview without altering page markup.</p>
|
||||
</section>
|
||||
|
||||
<!-- Quality and operational tools. -->
|
||||
<section>
|
||||
<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 > 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>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
+15
-116
@@ -17,30 +17,11 @@ 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.
|
||||
|
||||
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. |
|
||||
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.
|
||||
|
||||
## Storage Model
|
||||
|
||||
@@ -48,19 +29,15 @@ 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. New pages live only here until first published. |
|
||||
| `drafts/` | Complete unpublished HTML documents, named by a hash of their URI. |
|
||||
| `revisions/` | Immutable snapshots of earlier published documents. |
|
||||
| `scheduled/` | Complete HTML staged for future publication. |
|
||||
| `drafts.json` | Human-readable draft ownership and timestamp metadata. |
|
||||
| `revisions.json` | Revision URI, author, reason, and timestamp index. |
|
||||
| `schedules.json` | Publication times and staged-document identifiers. |
|
||||
| `shared.json` | Named global HTML fragments. |
|
||||
| `menus.json` | Named menus: `{key: {items: [{label, url, parent}], updated, title?}}`; seeded by the site scan, never created by the CMS. |
|
||||
| `newpages.json` | Pending (unpublished) pages: title, chosen menu, template, author, created. |
|
||||
| `pagetemplates.json` | Pages offered as templates in the New page section of Pages. |
|
||||
| `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. |
|
||||
| `menus.json` | Named flat menu structures with optional parent labels. |
|
||||
| `media.json` | Reusable image alternative text. |
|
||||
| `activity.json` | The latest 250 dashboard activity entries. |
|
||||
| `login-attempts.json` | Hashed address and identity buckets used for login throttling. |
|
||||
|
||||
@@ -76,103 +53,26 @@ 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 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.
|
||||
marks generated navigation that should be replaced whenever its named menu changes.
|
||||
|
||||
The bundled templates use the default `editable` class. Sites that override `editableClass` must update their templates
|
||||
to match; otherwise the resulting page is valid HTML but, quite reasonably, is not treated as editable by NeoCMS.
|
||||
|
||||
## Site Scan
|
||||
|
||||
`SiteAnalyser` tokenises each page (skipping comments, scripts, styles, and templates) to locate element offsets, then
|
||||
`apply()` splices markers into the original bytes. It deliberately avoids DOM re-serialisation so an automated pass over
|
||||
every page cannot rewrite unrelated markup. The browser drives the scan in small batches so it can show a progress bar and page count: `listSitePages` returns the
|
||||
page list, `analyseSitePages` reports a plan for up to 100 pages per request (the client totals the results), and
|
||||
`applySiteTagging` reuses `rewritePages()` to write only the pages it is given, after taking a revision of each,
|
||||
through the same atomic-replace path as publishing. All three need the administrator role. Local asset references are resolved and confined by
|
||||
`PagePaths::refExists()`.
|
||||
|
||||
## Media
|
||||
|
||||
`MediaTypes` is the single source of truth for uploads. Its allow-list maps each extension to one of four categories (imagery,
|
||||
documents, video, audio); the category is derived from the extension whenever the library is listed, and there is no separate
|
||||
category field to drift. `MediaTypes::inspect()` runs for every upload in `cms/image_upload.php` (the endpoint TinyMCE also uses):
|
||||
extension allow-list plus a deny list applied to every dot-separated part of the original name, content sniffing with `finfo`
|
||||
against the extension, refusal of executable signatures, a chunked scan of the whole file for code markers, and per-format
|
||||
checks (image decoding within the size limits, PDF actions, and a small built-in ZIP directory reader for OOXML and
|
||||
OpenDocument packages that refuses macros, embedded objects, scripts, and executables without needing the `zip` extension).
|
||||
Files are stored under `uploads/` as `slug-8hex.ext` (one dot only) or as legacy 32-hex names, both matched by
|
||||
`MediaTypes::nameRegex()`, which the usage counter, the site scan, and `deleteMedia` all share. `uploads/.htaccess` denies
|
||||
executable and markup extensions, sets `nosniff`, and serves documents with `Content-Disposition: attachment`. The editor's
|
||||
**Media library** button opens the same dialog in pick mode and inserts an `<img>`, a document link, or a `<video>`/`<audio>`
|
||||
element by category.
|
||||
|
||||
## New Pages and Templates
|
||||
|
||||
`newPageAction` reads the chosen template (a file in `cms/templates/` or `page:<uri>` for a page marked in `pagetemplates.json`),
|
||||
sets its title, and stores it only as a private draft plus a `newpages.json` record. No file is created, so the page cannot be
|
||||
served. `publishContent` (used by Publish Now and by scheduled publishing) creates the file on first publication, then
|
||||
`finishNewPage` adds the link to the chosen menu, propagates it, and removes the pending record. The preview writes the draft into
|
||||
an `about:blank` frame with an injected `<base>` element so relative assets resolve. Page filenames come from the name via
|
||||
`uniqueUri`, never from user input.
|
||||
|
||||
## Navigation Menus
|
||||
|
||||
`SiteAnalyser::navs()` classifies each `<nav>` by its position and label (header, banner and main navigation are `primary`;
|
||||
footer and legal are `secondary`). The scan tags it with `data-neo-menu` and seeds `menus.json` from the first page that has it
|
||||
(existing menus are never overwritten). `ContentDom::withMenu()` replaces only the inner list of a marked `<nav>` in the page's
|
||||
original bytes, sets `aria-current="page"` for the current page, and returns null when nothing changes so the page is not
|
||||
rewritten. The CMS cannot create a menu: `saveMenu` requires an existing key and may change its items and display `title`.
|
||||
|
||||
## Deleted Page Recovery
|
||||
|
||||
Deleting a page first stores its HTML as a revision with the reason "Before delete". The dashboard's `deleted` list (editors
|
||||
and administrators) contains pages whose newest revision has that reason and whose file no longer exists; **Recover** calls
|
||||
`restoreRevision`, which recreates the file from the snapshot.
|
||||
|
||||
## Subfolder Installs
|
||||
|
||||
`bootstrap.php` derives `basePath` from the script URL and `siteRoot` from the folder containing `cms/`. Stored menu links and
|
||||
page URLs carry the prefix, and the session cookie path is `basePath/cms`. Both values can be overridden in configuration.
|
||||
|
||||
## Roles
|
||||
|
||||
Capabilities are intentionally small and cumulative:
|
||||
|
||||
| Role | Capabilities |
|
||||
| --- | --- |
|
||||
| `editor` | Save drafts, upload images, publish, schedule, restore revisions, and recover deleted pages (the old `publisher` role is an alias). |
|
||||
| `administrator` | Editor capabilities plus page, media, shared-content, menu, template, and user management. |
|
||||
| `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. |
|
||||
|
||||
The interface hides controls the user cannot invoke, but server-side checks remain authoritative. Hidden buttons are a
|
||||
convenience, not a security boundary wearing a clever disguise.
|
||||
|
||||
Active sessions are bound to the account's current password hash. Removing, blocking, or re-passwording an account invalidates
|
||||
its sessions on the next request, while roles are re-read on every request so a demotion is immediate.
|
||||
|
||||
## Users and Accounts
|
||||
|
||||
`UserStore` merges two sources. Accounts in `config.local.php` are authoritative and locked: they are always active, cannot be
|
||||
edited, blocked, or deleted from the UI, and cannot be shadowed by a CMS account (only a display name and email overlay is
|
||||
stored for them). Accounts created in the UI live in `data/users.json` as `{hash, role, name, email, status, invite?}` with status
|
||||
`active`, `blocked`, or `invited`. `UserStore::authArgs()` supplies `Authentication` with the config accounts plus **active** CMS
|
||||
accounts only, which is why blocked or deleted users lose their sessions without any extra code. Sign-in uses the login name only; the
|
||||
email address is contact information (unique, but not a login identifier).
|
||||
|
||||
Actions: `users` (GET; administrators also receive the account list, never hashes or tokens), `saveProfile` and `changePassword`
|
||||
(any signed-in user, acting only on their own account taken from the session), and `saveUser`, `inviteUser`, `blockUser`, and
|
||||
`deleteUser` (administrators). Sensitive actions and email or password changes require the actor's current password, checked
|
||||
by `confirmPassword()` and throttled with `LoginRateLimiter`. Invitations are 32 random bytes; only the SHA-256 is stored, the link
|
||||
works once and expires after 7 days, and acceptance goes through `cms/login/?invite=TOKEN`. Passwords are 12 to 72 bytes
|
||||
(bcrypt truncates beyond 72), must differ from the username and email, and are stored with `password_hash`. Guard rails in the
|
||||
store and controller refuse self-block, self-delete, self-demotion, duplicate usernames and emails, and any change to a config
|
||||
account.
|
||||
|
||||
## Interface
|
||||
|
||||
The administration shell is a full-height grid: logo and page path on the left, the signed-in user and Log out at the top right,
|
||||
and the toolbar buttons plus the desktop/tablet/mobile icons beneath them (wrapping to their own row below 1100px). Dialogues are
|
||||
jQuery UI dialogues built from shared helpers (`dataTable`, `rowButton`), with a snackbar for results. Row buttons are small
|
||||
secondary buttons, action buttons (Save, Next, Apply, and the editor buttons) are a step larger, and the primary action is blue.
|
||||
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.
|
||||
|
||||
## Scheduled Publishing
|
||||
|
||||
@@ -183,10 +83,9 @@ 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# 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.
|
||||
+3
-42
@@ -5,29 +5,20 @@
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `cms/src/NeoCMS/Authentication.php` | Sessions, credentials, roles, capabilities, and CSRF tokens. |
|
||||
| `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/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/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 media upload endpoint (images, documents, video, audio); also used by TinyMCE for pasted images. |
|
||||
| `cms/image_upload.php` | Multipart image upload endpoint. |
|
||||
| `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
|
||||
@@ -83,33 +74,3 @@ 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.
|
||||
+10
-428
@@ -197,6 +197,12 @@ 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);
|
||||
@@ -219,39 +225,24 @@ 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 (é); 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'],
|
||||
]);
|
||||
(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');
|
||||
$request('saveMenu', ['name' => 'main', 'items' => $items], 'POST');
|
||||
$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');
|
||||
|
||||
@@ -264,20 +255,7 @@ try {
|
||||
$dashboard = $request('dashboard');
|
||||
assertTrue(isset($dashboard['schedules'][$schedule['id']]), 'Schedule was not visible on the dashboard');
|
||||
$request('cancelSchedule', ['id' => $schedule['id']], 'POST');
|
||||
$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);
|
||||
assertTrue(!isset($request('dashboard')['schedules'][$schedule['id']]), 'Schedule was not cancelled');
|
||||
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'
|
||||
@@ -314,7 +292,6 @@ 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);
|
||||
@@ -323,403 +300,8 @@ try {
|
||||
assertTrue($indexRevisionCount <= 2, 'Per-page revision retention limit was exceeded');
|
||||
|
||||
// Private CMS state should not be readable by unrelated operating-system users.
|
||||
// ---- Site scan: byte-exact tagging of content containers, images, and SEO tags ----
|
||||
$all = ['content' => true, 'images' => true, 'seo' => true];
|
||||
$scanPage = "<!DOCTYPE html>\r\n<html><head>\r\n <meta charset=\"utf-8\">\r\n</head>\r\n<body>\r\n"
|
||||
. "<header class='top'><nav><a href=\"/\">Home</a></nav></header>\r\n"
|
||||
. "<script>var s = '<main>';</script>\r\n"
|
||||
. "<main class=\"wide\">\r\n<h1>Café – Über uns</h1>\r\n<p>Wir sind ein kleines Team mit grossen Ideen.</p>\r\n<img src=\"/photo.jpg\" alt=\"\">\r\n</main>\r\n"
|
||||
. "<footer><p>Footer text that is long enough to count as text.</p></footer>\r\n"
|
||||
. "<img src=\"/logo.png\">\r\n<img src=\"x.gif\" width=\"1\" height=\"1\">\r\n</body></html>";
|
||||
$tagged = NeoCMS\SiteAnalyser::apply($scanPage, 'cms-content', $all);
|
||||
assertTrue($tagged !== null, 'Site scan found nothing to tag');
|
||||
$out = $tagged['html'];
|
||||
assertTrue(str_contains($out, '<main class="wide cms-content">'), 'Existing class attribute was not merged');
|
||||
assertTrue(!str_contains($out, "<header class='top cms-content'"), 'Header was tagged as content');
|
||||
assertTrue(str_contains($out, "var s = '<main>';"), 'Script content was altered');
|
||||
assertTrue(substr_count($out, 'data-neo-image') === 1 && str_contains($out, '<img data-neo-image src="/logo.png">'), 'Only the image outside the region should be tagged');
|
||||
assertTrue(str_contains($out, '<title>Café – Über uns</title>') && str_contains($out, 'name="description"'), 'Missing SEO tags were not inserted');
|
||||
assertTrue(substr_count($out, "\n") === substr_count($out, "\r\n"), 'Line endings became mixed');
|
||||
assertTrue(NeoCMS\SiteAnalyser::apply($out, 'cms-content', $all) === null, 'Second site scan was not idempotent');
|
||||
// Removing exactly what was inserted must give back the original bytes.
|
||||
$restored = str_replace([' cms-content"', ' data-neo-image'], ['"', ''], $out);
|
||||
$restored = preg_replace('#^ {4}<(title|meta (name="description"|property="og:[a-z]+")).*\r\n#m', '', $restored);
|
||||
assertTrue($restored === $scanPage, 'Site scan changed bytes other than the inserted markers');
|
||||
// Existing SEO tags are never overwritten; a page with no safe container is reported, not guessed.
|
||||
$seoPage = '<html><head><title>Mine</title><meta name="description" content="Keep me"></head><body><main><h1>H</h1></main></body></html>';
|
||||
$seoOut = NeoCMS\SiteAnalyser::apply($seoPage, 'cms-content', ['seo' => true]);
|
||||
assertTrue($seoOut === null || (str_contains($seoOut['html'], '<title>Mine</title>') && str_contains($seoOut['html'], 'content="Keep me"') && substr_count($seoOut['html'], 'name="description"') === 1), 'Existing SEO tags were overwritten');
|
||||
assertTrue(NeoCMS\SiteAnalyser::apply('<body><div>' . str_repeat('text ', 30) . '</div></body>', 'cms-content', ['content' => true]) === null, 'A page without a safe container was tagged');
|
||||
|
||||
// Photo placeholders (role="img" boxes) outside a region are marked; icons, real images, and in-region boxes are not.
|
||||
$placeholderPage = '<body><div class="portrait" role="img" aria-label="Photo placeholder"><svg role="img"></svg><span>Your photo here</span></div>'
|
||||
. '<div role="img" aria-label="has one"><img src="/a.jpg" alt=""></div><svg role="img"></svg><span role="img" aria-hidden="true"></span>'
|
||||
. '<main><div role="img" aria-label="inside"></div></main></body>';
|
||||
$placeholderOut = NeoCMS\SiteAnalyser::apply($placeholderPage, 'cms-content', ['images' => true]);
|
||||
assertTrue($placeholderOut !== null && substr_count($placeholderOut['html'], 'data-neo-image') === 3, 'Images-only pass should mark the portrait, the real image, and the placeholder in <main>');
|
||||
assertTrue(str_contains($placeholderOut['html'], '<div data-neo-image class="portrait" role="img"'), 'Photo placeholder was not marked');
|
||||
assertTrue(str_contains($placeholderOut['html'], '<img data-neo-image src="/a.jpg"'), 'Real image was not marked');
|
||||
$regionOut = NeoCMS\SiteAnalyser::apply($placeholderPage, 'cms-content', ['content' => true, 'images' => true]);
|
||||
assertTrue(substr_count($regionOut['html'], 'data-neo-image') === 2 && !str_contains($regionOut['html'], '<div data-neo-image role="img" aria-label="inside"'), 'A placeholder inside a newly editable region was marked');
|
||||
|
||||
// Controller: analyse reports pages; apply writes them, keeps a revision, and needs the manage capability.
|
||||
file_put_contents($root . '/scan-a.html', $scanPage);
|
||||
file_put_contents($root . '/scan-b.html', $scanPage);
|
||||
$listing = $request('listSitePages');
|
||||
assertTrue(in_array('/scan-a.html', $listing['pages'], true) && in_array('/scan-b.html', $listing['pages'], true), 'Page listing missed the sample pages');
|
||||
$analysis = $request('analyseSitePages', ['uris' => json_encode(['/scan-a.html', '/scan-b.html', '/no-such-page.html'])]);
|
||||
$byUri = array_column($analysis['pages'], null, 'uri');
|
||||
assertTrue(count($byUri) === 2, 'A page that does not exist should be skipped, not fail the batch');
|
||||
assertTrue(isset($byUri['/scan-a.html']) && $byUri['/scan-a.html']['proposed'] === ['main'], 'Analysis did not propose the main region');
|
||||
assertTrue(in_array('/logo.png', $byUri['/scan-a.html']['broken'], true), 'Missing local image was not reported as broken');
|
||||
assertRejected(fn() => $request('analyseSitePages', ['uris' => json_encode(array_map(fn($i) => "/p{$i}.html", range(1, 101)))]), 'An oversized batch was accepted');
|
||||
$asRole = function (string $role, string $action, array $parameters) use ($config): array {
|
||||
$cfg = $config;
|
||||
$cfg['roles']['tester'] = $role;
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = array_merge(['action' => $action, 'csrf_token' => 'test-token'], $parameters);
|
||||
$_GET = [];
|
||||
$_REQUEST = $_POST;
|
||||
ob_start();
|
||||
(new NeoCMS\CMSController($cfg))->handleRequest();
|
||||
return json_decode((string) ob_get_clean(), true) ?? [];
|
||||
};
|
||||
foreach (['listSitePages' => [], 'analyseSitePages' => ['uris' => json_encode(['/scan-a.html'])]] as $readAction => $readParams) {
|
||||
assertTrue(isset($asRole('publisher', $readAction, $readParams)['error']), "A non-administrator could run {$readAction}");
|
||||
}
|
||||
$before = file_get_contents($root . '/scan-b.html');
|
||||
$denied = $asRole('publisher', 'applySiteTagging', ['uris' => json_encode(['/scan-b.html']), 'options' => json_encode($all)]);
|
||||
assertTrue(isset($denied['error']) && file_get_contents($root . '/scan-b.html') === $before, 'A non-administrator could auto-tag pages');
|
||||
$traversal = $asRole('administrator', 'applySiteTagging', ['uris' => json_encode(['/scan-a.html', '../etc/passwd']), 'options' => json_encode($all)]);
|
||||
assertTrue(isset($traversal['error']) && !str_contains((string) file_get_contents($root . '/scan-a.html'), 'cms-content'), 'A traversal URI was not rejected before any page changed');
|
||||
$result = $request('applySiteTagging', ['uris' => json_encode(['/scan-a.html']), 'options' => json_encode($all)], 'POST');
|
||||
assertTrue($result['updated_pages'] === 1, 'Only the selected page should have been updated');
|
||||
assertTrue(str_contains((string) file_get_contents($root . '/scan-a.html'), 'cms-content') && file_get_contents($root . '/scan-b.html') === $before, 'Unselected page changed or selected page was not tagged');
|
||||
$revisions = $request('revisions', ['uri' => '/scan-a.html']);
|
||||
assertTrue(count($revisions) >= 1 && $revisions[0]['reason'] === 'Before auto-tagging', 'No revision was kept before auto-tagging: ' . json_encode($revisions));
|
||||
unlink($root . '/scan-a.html');
|
||||
unlink($root . '/scan-b.html');
|
||||
|
||||
// Navigation detection seeds the menus store, tags the nav in place, and later saves keep its attributes.
|
||||
$navPage = fn(string $cur) => '<!doctype html><html><head><title>T</title></head><body><header><nav class="site-nav" aria-label="Main"><ul><li><a href="a.html"' . ($cur === 'a' ? ' aria-current="page"' : '') . '>A</a></li><li><a href="b.html"' . ($cur === 'b' ? ' aria-current="page"' : '') . '>B</a></li></ul></nav></header><main><p>' . str_repeat('word ', 20) . '</p></main></body></html>';
|
||||
file_put_contents($root . '/nav-a.html', $navPage('a'));
|
||||
file_put_contents($root . '/nav-b.html', $navPage('b'));
|
||||
$navUris = json_encode(['/nav-a.html', '/nav-b.html']);
|
||||
$seen = $request('analyseSitePages', ['uris' => $navUris])['pages'];
|
||||
assertTrue($seen[0]['navs'][0]['menu'] === 'primary' && $seen[0]['navs'][0]['links'] === 2, 'Primary navigation was not detected');
|
||||
$request('applySiteTagging', ['uris' => $navUris, 'options' => json_encode(['menus' => true])], 'POST');
|
||||
$menus = $request('menus');
|
||||
assertTrue(isset($menus['primary']) && count($menus['primary']['items']) === 2 && $menus['primary']['items'][0]['url'] === '/a.html', 'Detected menu was not seeded: ' . json_encode($menus));
|
||||
assertTrue(str_contains((string) file_get_contents($root . '/nav-b.html'), '<nav data-neo-menu="primary" class="site-nav" aria-label="Main">'), 'Nav was not marked in place');
|
||||
assertTrue(!str_contains(json_encode(array_column($request('analyseSitePages', ['uris' => $navUris])['pages'], 'changes')), 'navigation'), 'Menu detection is not settled on the second run');
|
||||
$request('saveMenu', ['name' => 'primary', 'items' => json_encode([['label' => 'A', 'url' => '/a.html'], ['label' => 'B', 'url' => '/b.html'], ['label' => 'C', 'url' => '/c.html']])], 'POST');
|
||||
$navB = (string) file_get_contents($root . '/nav-b.html');
|
||||
assertTrue(str_contains($navB, 'class="site-nav" aria-label="Main"') && str_contains($navB, '<a href="/c.html">C</a>') && substr_count($navB, 'aria-current') === 0, 'Saved menu lost the nav attributes or wrongly marked the current page');
|
||||
$request('deleteMenu', ['name' => 'primary'], 'POST');
|
||||
assertTrue(!isset($request('menus')['primary']) && str_contains((string) file_get_contents($root . '/nav-b.html'), '/c.html'), 'Menu was not deleted, or pages lost their navigation');
|
||||
assertTrue(isset($asRole('publisher', 'deleteMenu', ['name' => 'secondary'])['error']), 'A non-administrator could delete a menu');
|
||||
// Scan-for-navigation mode also exposes navs that are not header/footer menus.
|
||||
file_put_contents($root . '/nav-c.html', '<html><head><title>T</title></head><body><nav aria-label="Side bar"><ul><li><a href="/x.html">X</a></li></ul></nav><p>hi</p></body></html>');
|
||||
$plain = $request('analyseSitePages', ['uris' => json_encode(['/nav-c.html'])])['pages'][0];
|
||||
$wide = $request('analyseSitePages', ['uris' => json_encode(['/nav-c.html']), 'allNavs' => 1])['pages'][0];
|
||||
assertTrue($plain['navs'] === [] && $wide['navs'][0]['menu'] === 'side-bar' && !$wide['navs'][0]['tagged'], 'allNavs did not expose an unclassified nav');
|
||||
$request('applySiteTagging', ['uris' => json_encode(['/nav-c.html']), 'options' => json_encode(['menus' => true, 'allNavs' => true])], 'POST');
|
||||
assertTrue(isset($request('menus')['side-bar']) && str_contains((string) file_get_contents($root . '/nav-c.html'), 'data-neo-menu="side-bar"'), 'Unclassified nav was not exposed as a menu');
|
||||
unlink($root . '/nav-c.html');
|
||||
unlink($root . '/nav-a.html');
|
||||
unlink($root . '/nav-b.html');
|
||||
|
||||
// A new page stays private (no file, not in its menu) until it is first published.
|
||||
$mainBefore = count($request('menus')['main']['items']);
|
||||
$created = $request('newPage', ['template' => 'example-template.html', 'name' => 'About Us!', 'menu' => 'main'], 'POST');
|
||||
assertTrue($created['url'] === '/about-us.html' && !file_exists($root . '/about-us.html'), 'New page was made public before publication');
|
||||
assertTrue($request('newPage', ['template' => 'example-template.html', 'name' => 'About us', 'menu' => ''], 'POST')['url'] === '/about-us-2.html', 'Duplicate name did not get a numbered filename');
|
||||
assertTrue($request('newPage', ['template' => 'example-template.html', 'name' => '???', 'menu' => ''], 'POST')['url'] === '/page.html', 'Symbol-only name did not fall back to page.html');
|
||||
assertRejected(fn() => $request('newPage', ['template' => 'example-template.html', 'name' => 'X', 'menu' => 'nope'], 'POST'), 'Unknown navigation group was accepted');
|
||||
$listed = array_values(array_filter($request('getPages'), fn($p) => $p['url'] === '/about-us.html'));
|
||||
assertTrue(count($listed) === 1 && !empty($listed[0]['pending']), 'Pending page was not listed as unpublished');
|
||||
$newDraft = $request('getDraft', ['uri' => '/about-us.html']);
|
||||
assertTrue($newDraft['exists'] && str_contains($newDraft['content'], '<title>About Us!</title>'), 'New page draft is missing or untitled');
|
||||
assertTrue(count($request('menus')['main']['items']) === $mainBefore && !str_contains((string) file_get_contents($root . '/index.html'), 'about-us.html'), 'Link appeared in the menu before publication');
|
||||
$request('save', ['uri' => '/about-us.html', 'content' => $newDraft['content']], 'POST');
|
||||
assertTrue(file_exists($root . '/about-us.html'), 'First publication did not create the page');
|
||||
assertTrue(in_array('/about-us.html', array_column($request('menus')['main']['items'], 'url'), true) && str_contains((string) file_get_contents($root . '/index.html'), 'href="/about-us.html"'), 'Published page was not added to its menu');
|
||||
$published = array_values(array_filter($request('getPages'), fn($p) => $p['url'] === '/about-us.html'));
|
||||
assertTrue(!array_filter($published, fn($p) => !empty($p['pending'])), 'Published page is still marked unpublished');
|
||||
$request('schedule', ['uri' => '/about-us-2.html', 'content' => $newDraft['content'], 'publish_at' => (new DateTimeImmutable('+1 day'))->format(DATE_ATOM)], 'POST');
|
||||
$request('discardNewPage', ['uri' => '/about-us-2.html'], 'POST');
|
||||
$request('discardNewPage', ['uri' => '/page.html'], 'POST');
|
||||
assertTrue(!file_exists($root . '/about-us-2.html') && !in_array('/about-us-2.html', array_column($request('dashboard')['schedules'], 'uri'), true) && !array_filter($request('getPages'), fn($p) => !empty($p['pending'])), 'Discard left a schedule or pending page behind');
|
||||
unlink($root . '/about-us.html');
|
||||
|
||||
// A page marked as a template is offered by New Page and copied (title replaced) into a private draft.
|
||||
assertRejected(fn() => $request('newPage', ['template' => 'page:/index.html', 'name' => 'Nope', 'menu' => ''], 'POST'), 'An unmarked page was usable as a template');
|
||||
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '1'], 'POST');
|
||||
assertTrue(in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true), 'Marked page is not offered as a template');
|
||||
$fromPage = $request('newPage', ['template' => 'page:/index.html', 'name' => 'From Page', 'menu' => ''], 'POST');
|
||||
$copied = $request('getDraft', ['uri' => $fromPage['url']])['content'];
|
||||
assertTrue(str_contains($copied, 'cms-content') && str_contains($copied, '<title>From Page</title>') && !file_exists($root . $fromPage['url']), 'Page template was not copied into a private draft');
|
||||
$request('discardNewPage', ['uri' => $fromPage['url']], 'POST');
|
||||
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '0'], 'POST');
|
||||
assertTrue(!in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true), 'Unmarked page is still offered as a template');
|
||||
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '1'], 'POST');
|
||||
$request('deleteTemplate', ['template' => 'page:/index.html'], 'POST');
|
||||
assertTrue(!in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true) && file_exists($root . '/index.html'), 'Deleting a page template removed the page or left it listed');
|
||||
assertRejected(fn() => $request('deleteTemplate', ['template' => '../config.php'], 'POST'), 'A non-template file could be deleted');
|
||||
assertRejected(fn() => $request('deleteTemplate', ['template' => 'missing.html'], 'POST'), 'A missing template file was reported deleted');
|
||||
|
||||
// Parallel read-modify-write must not lose updates: 6 processes x 20 increments = 120.
|
||||
$counterDir = sys_get_temp_dir() . '/neocms-counter-' . bin2hex(random_bytes(4));
|
||||
$worker = $counterDir . '.php';
|
||||
file_put_contents($worker, '<?php require ' . var_export(__DIR__ . '/../cms/src/NeoCMS/FileStore.php', true)
|
||||
. '; $s = new NeoCMS\FileStore($argv[1]); for ($i = 0; $i < 20; $i++) { $s->update("counter", fn($d) => ["n" => ($d["n"] ?? 0) + 1]); }');
|
||||
$procs = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$procs[] = proc_open([PHP_BINARY, $worker, $counterDir], [], $pipes);
|
||||
}
|
||||
foreach ($procs as $proc) {
|
||||
proc_close($proc);
|
||||
}
|
||||
$counted = (new NeoCMS\FileStore($counterDir))->read('counter')['n'] ?? 0;
|
||||
unlink($worker);
|
||||
removeTree($counterDir);
|
||||
assertTrue($counted === 120, "FileStore::update lost updates under concurrency ({$counted}/120)");
|
||||
|
||||
// ---- Users: roles, self-service, administrator management, invitations, and account protection ----
|
||||
$as = function (string $user, string $action, array $params = [], string $method = 'POST') use ($config): array {
|
||||
$saved = $_SESSION;
|
||||
$_SESSION = ['loggedIn' => true, 'loggedInUser' => $user, 'csrfToken' => 'test-token', 'authenticatedAt' => time(), 'lastActivityAt' => time(), 'lastRegeneratedAt' => time()];
|
||||
$_SERVER['REQUEST_METHOD'] = $method;
|
||||
$_POST = $method === 'POST' ? array_merge(['action' => $action, 'csrf_token' => 'test-token'], $params) : [];
|
||||
$_GET = $method === 'GET' ? array_merge(['action' => $action], $params) : [];
|
||||
$_REQUEST = array_merge($_GET, $_POST);
|
||||
ob_start();
|
||||
(new NeoCMS\CMSController($config))->handleRequest();
|
||||
$body = (string) ob_get_clean();
|
||||
$_SESSION = $saved;
|
||||
return (json_decode($body, true) ?? []) + ['_raw' => $body];
|
||||
};
|
||||
$ok = fn(array $r, string $why) => assertTrue(!isset($r['error']), $why . ' (' . ($r['error'] ?? '') . ')');
|
||||
$err = fn(array $r, string $why) => assertTrue(isset($r['error']), $why);
|
||||
$store = new NeoCMS\UserStore(new NeoCMS\FileStore($data), $config);
|
||||
$pw = 'unused';
|
||||
|
||||
// Two roles: the old publisher role is an editor (can publish, cannot manage).
|
||||
$alias = new NeoCMS\Authentication(['x' => $limitedHash], ['x' => 'publisher']);
|
||||
$alias->login('x', 'secret');
|
||||
assertTrue($alias->getRole() === 'editor' && $alias->can('publish') && $alias->can('schedule') && !$alias->can('manage'), 'Publisher did not map to a publishing editor');
|
||||
|
||||
$list = $as('tester', 'users', [], 'GET');
|
||||
$ok($list, 'users list failed');
|
||||
assertTrue($list['me']['managed'] === true && $list['me']['role'] === 'administrator' && array_keys($list['roles']) === ['editor', 'administrator'], 'Users response is missing the config account or roles');
|
||||
assertTrue(!str_contains($list['_raw'], 'hash') && !str_contains($list['_raw'], '$2y$'), 'A password hash leaked into the users response');
|
||||
|
||||
// Sensitive admin actions need the administrator's own password.
|
||||
$newUser = ['name' => 'Ed Example', 'email' => 'Ed@Example.com', 'role' => 'editor', 'username' => 'ed1', 'password' => 'correct horse 1'];
|
||||
$err($as('tester', 'saveUser', $newUser), 'Adding a user worked without password confirmation');
|
||||
$err($as('tester', 'saveUser', $newUser + ['confirm_password' => 'wrong']), 'Adding a user worked with a wrong confirmation password');
|
||||
$ok($as('tester', 'saveUser', $newUser + ['confirm_password' => $pw]), 'Adding a user failed');
|
||||
$accounts = array_column($as('tester', 'users', [], 'GET')['users'], null, 'username');
|
||||
assertTrue(($accounts['ed1']['email'] ?? '') === 'ed@example.com' && $accounts['ed1']['status'] === 'active' && !isset($accounts['ed1']['hash']), 'New account was not listed correctly');
|
||||
assertTrue(str_starts_with((string) json_decode((string) file_get_contents($data . '/users.json'), true)['users']['ed1']['hash'], '$2y$'), 'Password was not stored as a hash');
|
||||
|
||||
// Validation: duplicates, weak or over-long passwords, password equal to the username.
|
||||
$err($as('tester', 'saveUser', ['username' => 'ED1', 'name' => 'Dup', 'email' => 'other@example.com', 'role' => 'editor', 'password' => 'correct horse 2', 'confirm_password' => $pw]), 'A case-variant duplicate username was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Dup', 'email' => 'ED@example.com', 'role' => 'editor', 'password' => 'correct horse 2', 'confirm_password' => $pw]), 'A duplicate email was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Short', 'email' => '', 'role' => 'editor', 'password' => 'elevenchars', 'confirm_password' => $pw]), 'An 11-character password was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Long', 'email' => '', 'role' => 'editor', 'password' => str_repeat('a', 73), 'confirm_password' => $pw]), 'A 73-byte password was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'samepassword1', 'name' => 'Same', 'email' => '', 'role' => 'editor', 'password' => 'samepassword1', 'confirm_password' => $pw]), 'A password equal to the username was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed3', 'name' => 'Bad role', 'email' => '', 'role' => 'root', 'password' => 'correct horse 3', 'confirm_password' => $pw]), 'An unknown role was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'tester', 'name' => 'Shadow', 'email' => '', 'role' => 'editor', 'password' => 'correct horse 4', 'confirm_password' => $pw]), 'A CMS account could shadow a config username');
|
||||
|
||||
// An editor can publish but cannot manage users, and sees only their own account.
|
||||
$perms = $as('ed1', 'dashboard', [], 'GET')['permissions'];
|
||||
assertTrue($perms['publish'] === true && $perms['schedule'] === true && $perms['manage'] === false, 'Editor permissions are wrong');
|
||||
$mine = $as('ed1', 'users', [], 'GET');
|
||||
assertTrue($mine['users'] === [] && $mine['me']['username'] === 'ed1' && $mine['me']['role'] === 'editor', 'An editor could list other accounts');
|
||||
foreach (['saveUser' => $newUser + ['username' => 'ed9', 'confirm_password' => 'correct horse 1'], 'inviteUser' => ['name' => 'X', 'email' => 'x@example.com', 'role' => 'editor', 'confirm_password' => 'correct horse 1'], 'blockUser' => ['username' => 'tester', 'blocked' => '1', 'confirm_password' => 'correct horse 1'], 'deleteUser' => ['username' => 'tester', 'confirm_password' => 'correct horse 1']] as $action => $params) {
|
||||
assertTrue(($as('ed1', $action, $params)['error'] ?? '') === 'Your role cannot perform this action', "An editor could run {$action}");
|
||||
}
|
||||
|
||||
// ---- 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');
|
||||
}
|
||||
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 {
|
||||
|
||||
+3
-7
@@ -1,7 +1,7 @@
|
||||
# Uploaded media: images, documents, video and audio only. Deny executable and markup extensions even if one appears by other means.
|
||||
Options -Indexes -ExecCGI
|
||||
# Uploaded files are images only; deny executable extensions even if one appears by other means.
|
||||
Options -Indexes
|
||||
|
||||
<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)$">
|
||||
<FilesMatch "\.(?:php[0-9]?|phtml|phar|cgi|pl|py|sh)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
@@ -9,8 +9,4 @@ Options -Indexes -ExecCGI
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user