Merge pull request #40 from GeoffMoore1979/feature/media-users-pages
Add Users, media library, menus from the site scan, new-page wizard and deleted-page recovery
This commit was merged in pull request #40.
This commit is contained in:
@@ -23,19 +23,29 @@ Further technical documentation:
|
||||
* Seamless Integration: Simply add the CMS to any existing static website. Just assign a special class to the elements
|
||||
you want to make editable, and you’re all set.
|
||||
* Consistent Layouts: NeoCMS supports page templates to keep your layouts neat and uniform across all pages.
|
||||
* Drafts and Publishing: Editors can save drafts without changing the public page. Publishers can publish immediately or
|
||||
schedule a publication.
|
||||
* Revision History: Every changed or deleted page receives a timestamped filesystem snapshot that publishers can restore.
|
||||
* Page Management: Search, create, duplicate, rename, and delete editable HTML pages, including pages in subdirectories.
|
||||
* Media Library: Browse uploaded images, reuse them, maintain alternative text, see usage counts, and remove files.
|
||||
* Drafts and Publishing: Clicking Save in the editor keeps the change as a private draft without touching the public page.
|
||||
Editors can then publish immediately or schedule a publication.
|
||||
* Revision History: Every changed or deleted page receives a timestamped filesystem snapshot that editors can restore, and
|
||||
deleted pages can be recovered from the Dashboard.
|
||||
* Page Management: Search, duplicate, rename, and delete editable HTML pages, including pages in subdirectories. The New page section of the Pages dialogue is a
|
||||
three-step wizard (template, page name, navigation group); the new page stays a private draft until it is published. Any page can
|
||||
be offered as a template with Create Template.
|
||||
* Media Library: Upload images, documents, video, and audio; they are sorted into Imagery, Documents, Video, and Audio by file
|
||||
extension. Reuse them from the editor's Media library button, maintain alternative text or link text, see usage counts, and
|
||||
remove files. Executables and files containing code are refused.
|
||||
* SEO Settings: Edit the page title, description, canonical URL, Open Graph image, and robots indexing setting.
|
||||
* Shared Content: Mark content with `data-neo-shared="name"` and update matching regions across the site.
|
||||
* Navigation Menus: Build ordered and nested navigation markup and insert it into an editable region.
|
||||
* Responsive Preview: Preview the current page at desktop, tablet, and mobile widths.
|
||||
* Navigation Menus: The site scan finds each page's primary and secondary navigation and pre-fills the menus. Edit, rename, or
|
||||
delete a menu and the change is applied to every page that uses it. Menus are created by the web developer's markup, not by the
|
||||
CMS.
|
||||
* Responsive Preview: Preview the current page at desktop, tablet, and mobile widths using the device icons in the top bar.
|
||||
* Accessibility Checks: Check common authoring problems such as missing alt text, empty links, heading jumps, missing labels,
|
||||
and absent page metadata before publishing.
|
||||
* Roles and Activity: Use editor, publisher, and administrator roles. The dashboard displays drafts, schedules, system
|
||||
problems, and recent activity.
|
||||
* Site Scan: Analyse every managed page and the media library, then make pages editable in one step. The scan
|
||||
finds content areas, images, navigation, broken local references, and missing SEO tags, and adds the markers NeoCMS needs.
|
||||
* Users and Roles: Everyone can update their display name, email address, and password in Tools > Users. Administrators can add,
|
||||
edit, invite, block, and delete accounts. Two roles, Editor and Administrator, are described on that screen.
|
||||
* Activity: The dashboard displays drafts, schedules, deleted pages, system problems, and recent activity.
|
||||
|
||||
## Installation Guide
|
||||
|
||||
@@ -46,8 +56,15 @@ To install NeoCMS:
|
||||
* Copy `cms/config.local.php.example` to `cms/config.local.php`, add password hashes, and assign roles.
|
||||
* Update the elements you want to make editable by adding the configured editable class. It is `editable` by default.
|
||||
* Confirm that PHP may write to the managed HTML files, `cms/data/`, `cms/logs/`, and `uploads/`.
|
||||
* **Live sites:** create a folder outside the web root (for example `/var/neocms-data`), make it writable by PHP, and set
|
||||
`'dataDirectory' => '/var/neocms-data'` in `cms/config.local.php`. It must be an absolute path. Without this, accounts, drafts,
|
||||
and revisions live in `cms/data/`, which is protected only by the supplied `.htaccess`. Administrators see a Dashboard
|
||||
recommendation until the data folder is outside the web root. Audit logs remain in `cms/logs/`, also protected by `.htaccess`.
|
||||
* Visit `/cms/`, log in, and begin editing.
|
||||
|
||||
The site may also live in a subfolder (for example `http://localhost/mysite/`): the URL prefix is detected automatically. See
|
||||
[Subfolder installs](#subfolder-installs).
|
||||
|
||||
That’s it! You’re up and running.
|
||||
|
||||
## Configuration
|
||||
@@ -57,17 +74,21 @@ Tracked defaults live in `cms/config.php`. Deployment credentials and overrides
|
||||
|
||||
| Setting | Type | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `authentication` | array | Maps usernames to `password_hash()` values; plaintext credentials are rejected. |
|
||||
| `roles` | array | Maps usernames to `editor`, `publisher`, or `administrator`. |
|
||||
| `authentication` | array | Maps the main administrator usernames to `password_hash()` values; plaintext credentials are rejected. These accounts are managed only here. |
|
||||
| `roles` | array | Maps those usernames to `editor` or `administrator` (`publisher` is accepted as an alias of `editor`). |
|
||||
| `security` | array | Configures session expiry, login throttling, request limits, storage quotas, and audit-log retention. |
|
||||
| `uploads` | array | Configures image size, dimension, file-count, and aggregate-storage limits. |
|
||||
| `uploads` | array | Configures the per-file size limit (all media types), image dimension, file-count, and aggregate-storage limits. |
|
||||
| `audit` | boolean | Enables daily activity logs under `cms/logs/`. |
|
||||
| `skipWelcomePage` | boolean | Opens the public root instead of the built-in guide after login. |
|
||||
| `showFullUrl` | boolean | Displays the current page path in the toolbar. |
|
||||
| `editableClass` | string | Selects the single CSS class used to identify editable regions. |
|
||||
|
||||
`dataDirectory` is an internal override used by the integration suite and specialised deployments. Normal installations
|
||||
should allow NeoCMS to use `cms/data/`.
|
||||
`dataDirectory` is the absolute path of the folder holding accounts (`users.json`), drafts, revisions, schedules, and menus. It
|
||||
defaults to `cms/data/`; on a live site, point it at a folder outside the web root (see the Installation Guide) so those files
|
||||
can never be served. The folder is created on first use if PHP may create it, and the CLI scheduler uses the same setting.
|
||||
|
||||
`basePath` (URL prefix of the site root, such as `/mysite`) and `siteRoot` (the folder containing `cms/`) are detected
|
||||
automatically and only need setting behind a path-rewriting proxy.
|
||||
|
||||
### Editable Class
|
||||
|
||||
@@ -95,27 +116,52 @@ created pages are immediately discoverable by the page picker.
|
||||
|
||||
### Users and Roles
|
||||
|
||||
Passwords must be generated with `password_hash()` and stored under `authentication` in `cms/config.local.php`. Assign the
|
||||
same username a role under `roles`:
|
||||
There are two kinds of account:
|
||||
|
||||
* **Config accounts** are the main administrators. Generate a hash with `password_hash()` and store it under `authentication` in
|
||||
`cms/config.local.php`, with a role under `roles`. They can be changed only in that file (in Tools > Users they are listed as
|
||||
"Config"; only their display name and email can be set from the CMS).
|
||||
* **CMS accounts** are created in **Tools > Users** and stored in `cms/data/users.json`.
|
||||
|
||||
```php
|
||||
'authentication' => [
|
||||
'author' => '$2y$10$...',
|
||||
'publisher' => '$2y$10$...',
|
||||
'admin' => '$2y$10$...',
|
||||
],
|
||||
'roles' => [
|
||||
'author' => 'editor',
|
||||
'publisher' => 'publisher',
|
||||
'admin' => 'administrator',
|
||||
],
|
||||
```
|
||||
|
||||
* `editor`: edit content, save drafts, and upload media.
|
||||
* `publisher`: editor permissions plus immediate and scheduled publishing and revision restoration.
|
||||
* `administrator`: publisher permissions plus page, media, shared-content, and menu management.
|
||||
There are two roles:
|
||||
|
||||
Users without an explicit valid role receive `editor`, the least powerful role. Account removal, password-hash changes,
|
||||
and role changes affect active sessions on their next request. Sessions expire after 30 minutes of inactivity and 12 hours
|
||||
in total by default.
|
||||
| Role | Can do |
|
||||
| --- | --- |
|
||||
| `editor` | Edit content, save drafts, upload images, publish or schedule pages, and restore revisions or deleted pages. Cannot change the site structure or manage users. |
|
||||
| `administrator` | Everything an editor can, plus creating new pages, duplicate, rename and delete pages, templates, navigation menus, shared content, site scan, deleting media, and managing users. |
|
||||
|
||||
The former `publisher` role is now part of `editor`; existing `publisher` assignments are treated as `editor`. Users without an
|
||||
explicit valid role receive `editor`, the least powerful role.
|
||||
|
||||
#### The Users screen
|
||||
|
||||
* **My account** (every user): see your login name (set by an administrator), change the display name, the email address (needs the
|
||||
current password), and the password (current, new, confirmation; CMS accounts only). You sign in with your login name and
|
||||
password; the email address is contact information only.
|
||||
* **Roles**: a description of each role, with your own marked.
|
||||
* **User accounts** (administrators): add an account with a password, edit an account (name, email, role, optional password
|
||||
reset), invite someone, block or unblock, or delete. Add, edit, invite, block, and delete ask for the administrator's own
|
||||
password. You cannot block, delete, or demote yourself, and config accounts cannot be changed here.
|
||||
* **Invitations** produce a one-time link that is shown once and expires after 7 days. The invitee opens it and chooses their own
|
||||
password. No email is sent by the CMS.
|
||||
|
||||
#### Account protection
|
||||
|
||||
Passwords are 12 to 72 characters and are stored only as hashes. Invite tokens are stored only as hashes and work once. Secrets
|
||||
never appear in API responses or the activity log. Repeated wrong passwords are rate limited, every account event is recorded in
|
||||
the activity feed and audit log, and blocking, deleting, changing a role, or changing a password affects that user's sessions on
|
||||
their next request. `data/.htaccess` denies web access to `users.json`.
|
||||
|
||||
Sessions expire after 30 minutes of inactivity and 12 hours in total by default.
|
||||
|
||||
When HTTPS terminates at a trusted reverse proxy, set `'cookieSecure' => true` beneath `security` in the local override.
|
||||
This keeps the session cookie marked Secure even when PHP receives the proxy's internal HTTP request.
|
||||
@@ -156,7 +202,12 @@ of its shared regions.
|
||||
|
||||
### Navigation Menus
|
||||
|
||||
Menu items use one line each in this format:
|
||||
Menus come from your site's own navigation. The **site scan** finds each page's `<nav>`: a header, banner, or main navigation is
|
||||
treated as `primary`, a footer or legal navigation as `secondary`, and the block is marked in place with
|
||||
`data-neo-menu="name"`. The first page that contains a menu supplies its items, and a saved menu is never overwritten.
|
||||
|
||||
**Tools > Navigation Menus** (main toolbar) lists the menus. Choose **Edit** to change a menu's display name or items, one per
|
||||
line:
|
||||
|
||||
```text
|
||||
Home | /
|
||||
@@ -164,13 +215,82 @@ About | /about.html
|
||||
Team | /team.html | About
|
||||
```
|
||||
|
||||
The optional third value is the label of the parent item. After saving, the generated `<nav>` can be inserted into the
|
||||
currently selected editable region.
|
||||
The optional third value is the label of the parent item. Saving rewrites only the list inside each marked `<nav>` on every
|
||||
page (other bytes, the `<nav>` attributes, and `aria-current="page"` for the current page are handled), and skips pages that
|
||||
would not change. **Delete** removes a menu from the CMS; pages keep their current navigation. **Scan site for new navigation**
|
||||
finds navigation blocks a developer has added since the last scan.
|
||||
|
||||
The CMS does not create menus: new menus are the web developer's markup, discovered by the scan. The menu key cannot be renamed,
|
||||
but the display name can.
|
||||
|
||||
### Media Library
|
||||
|
||||
**Media** uploads and lists files in four categories, decided by the file extension:
|
||||
|
||||
| Category | Accepted extensions |
|
||||
| --- | --- |
|
||||
| Imagery | jpg, jpeg, png, gif, webp |
|
||||
| Documents | pdf, docx, xlsx, pptx, odt, ods, odp, txt, csv |
|
||||
| Video | mp4, m4v, webm, mov, ogv |
|
||||
| Audio | mp3, wav, ogg, oga, m4a, aac, flac |
|
||||
|
||||
Anything not in this list is refused: executables (`.exe`, `.dll`, `.bat`, `.msi`, ...), scripts and server code (`.php`, `.js`, `.sh`, ...),
|
||||
HTML, SVG, XML, archives, the older `.doc`/`.xls`/`.ppt` formats, and macro-enabled Office files. Every upload is also checked
|
||||
by content, not just name: the file type must match its extension; files that start like an executable or contain `<?php`,
|
||||
`<?=`, `<script`, or `<%` anywhere are refused (so a code payload hidden inside an image is caught); PDFs containing
|
||||
JavaScript, launch actions, or embedded files are refused; and Office/OpenDocument files are refused if they contain macros,
|
||||
embedded objects, scripts, or executables. Names such as `report.exe.pdf` are refused too. These checks are best-effort hardening,
|
||||
not a malware scanner, so keep server-side virus scanning where policy requires it.
|
||||
|
||||
Uploaded files are saved under `uploads/` with a generated name (`price-list-3f2a9c1d.pdf`); the original name is kept as the
|
||||
default link text. Every file is limited by `uploads.maxFileBytes` (10 MiB by default), and PHP's `upload_max_filesize` and
|
||||
`post_max_size` must allow at least that. In the editor, use the **Media library** button to insert an image, a document link, a
|
||||
video player, or an audio player at the cursor. Documents are served as downloads. `uploads/.htaccess` denies executable and markup
|
||||
extensions; for Nginx use the rule shown under Web Server Security.
|
||||
|
||||
### Site Scan
|
||||
|
||||
Administrators can open **Tools > Site scan** to analyse the whole site: page and folder counts, pages that can be made
|
||||
editable, images without alternative text, broken local image/CSS/script references, uploaded files that no page uses, and
|
||||
missing title, description, and Open Graph tags. Tick the pages and the kinds of change to make, then apply:
|
||||
|
||||
* **Content areas**: adds the editable class to `<main>` (or `role="main"`), otherwise to top-level `<article>` or
|
||||
text-bearing `<section>` elements. Headers, navigation, footers, asides, and forms are skipped, and `<body>` is never
|
||||
tagged. Pages that already contain an editable region are left alone; pages with no safe container are reported for manual
|
||||
tagging.
|
||||
* **Images**: adds `data-neo-image` to images outside editable regions. In the editor, clicking one opens a dialogue to pick
|
||||
a Media Library image or change its address and alternative text.
|
||||
* **Navigation**: marks each `<nav>` (primary/secondary) with `data-neo-menu` and pre-fills Navigation Menus from it.
|
||||
* **SEO tags**: inserts only the missing title, description, `og:title`, `og:description`, and `og:image` tags, with values
|
||||
taken from the page. Existing tags are never changed, and canonical and robots values are reported but not invented.
|
||||
|
||||
The scan edits page source directly rather than re-parsing it, so nothing else in a file changes, and a revision of every
|
||||
changed page is kept in Revision history.
|
||||
|
||||
### Drafts and Revisions
|
||||
|
||||
Drafts do not alter public HTML. When opening a page with a saved draft, NeoCMS offers to load it. Publishing creates a
|
||||
revision of the previous public file first. Deleting a page also retains its last revision.
|
||||
revision of the previous public file first. Deleting a page also retains its last revision, and **Dashboard > Deleted pages**
|
||||
lists pages whose newest revision is "Before delete" and whose file is gone, with a **Recover** button that recreates the page
|
||||
from that snapshot. Revisions are pruned by the configured retention limits.
|
||||
|
||||
### New Pages and Templates
|
||||
|
||||
The **New page** section (administrators), under the page list in **Pages**, is a three-step wizard: choose a template, give the page a name, then choose a navigation group
|
||||
(or none). The filename is generated from the name (`About Us` becomes `about-us.html`, numbered if taken). The page is saved
|
||||
only as a private draft: no file exists, so it is not visible on the site, until it is first published (Publish Now or a
|
||||
schedule). Publishing creates the file and adds a link to the chosen menu on every page. Unpublished pages appear in Pages as
|
||||
"New - unpublished" and can be discarded.
|
||||
|
||||
Templates are the files in `cms/templates/` plus any page you choose **Create Template** for in the Pages list. A template page
|
||||
is used live (later edits to it show in new pages), and the **Delete** action in the template list deletes a template file or
|
||||
stops using a page as a template (the page is kept).
|
||||
|
||||
### Subfolder Installs
|
||||
|
||||
The site can live in a subfolder such as `http://localhost/neocms/`. The URL prefix (`basePath`) and the folder that contains
|
||||
`cms/` (`siteRoot`) are detected automatically, page links stored by menus are prefixed accordingly, and the session cookie path
|
||||
follows the CMS. Root installs are unchanged.
|
||||
|
||||
NeoCMS stores metadata, drafts, scheduled content, and revisions under `cms/data/`. Back up this directory together with
|
||||
the website. The supplied `.htaccess` denies direct HTTP access on Apache; other web servers should deny `/cms/data/` and
|
||||
@@ -190,14 +310,20 @@ The server timezone controls PHP date handling; the browser converts the selecte
|
||||
|
||||
## Administration Workflow
|
||||
|
||||
1. Open a page from **Pages**.
|
||||
2. Select a region carrying the configured editable class and edit it in TinyMCE.
|
||||
3. Use **Save Draft** to retain private work, or **Publish** to replace the public HTML after creating a revision.
|
||||
4. Use **Schedule** when publication should occur later.
|
||||
5. Check revisions, accessibility findings, SEO metadata, shared content, and menus through the relevant toolbar dialogues.
|
||||
1. Open a page from **Pages** (or create one in the **New page** section under the list).
|
||||
2. Select a region carrying the configured editable class and edit it in TinyMCE. Clicking an image opens the image dialogue.
|
||||
3. In the editor, click **Save Draft** to keep your change private, **Publish Now** to replace the public HTML after
|
||||
creating a revision, or **Schedule Publish** when publication should occur later. **Cancel** discards unapplied edits.
|
||||
4. Check revisions, accessibility findings, SEO metadata, shared content, and menus through the relevant toolbar dialogues.
|
||||
|
||||
The Save button inside the content-editor modal only applies content to the in-browser preview. It does not write a public
|
||||
file. This distinction is intentional and prevents an innocent wording change from making an unscheduled public debut.
|
||||
The top bar shows the current page path on the left; your name and Log out are at the top right, with the toolbar buttons
|
||||
(Dashboard, Pages, Media, SEO, Navigation Menus, Tools) and the desktop/tablet/mobile preview icons beneath. Tools holds
|
||||
Revision history, Accessibility check, Shared content, Site scan, and Users. Dialogue buttons share one style: small
|
||||
secondary buttons for row actions, larger buttons for actions (Save, Next, Apply), and blue for the primary action.
|
||||
|
||||
Save Draft in the content editor (and Apply in the image and SEO dialogues) stores the change as a private draft. It never writes
|
||||
the public file, which only changes on Publish. This is intentional and prevents an innocent wording change from making an
|
||||
unscheduled public debut. The page header shows **Draft:** once a draft is saved, and reopening the page offers to load it.
|
||||
|
||||
## Page Markup Recommendations
|
||||
|
||||
@@ -208,7 +334,7 @@ common authoring issues but is not a complete accessibility audit.
|
||||
## Requirements
|
||||
|
||||
* PHP 8 running on Linux
|
||||
* PHP DOM and fileinfo extensions
|
||||
* PHP DOM and fileinfo extensions (the fileinfo extension is used to check uploads; the zip extension is not needed)
|
||||
* PHP sessions enabled
|
||||
* Write access to `cms/data/`, `cms/logs/`, `uploads/`, and managed HTML pages
|
||||
* A modern browser with JavaScript enabled
|
||||
@@ -225,7 +351,7 @@ Apache users should allow the supplied `.htaccess` rules. For Nginx, add equival
|
||||
location ^~ /cms/data/ { deny all; }
|
||||
location ^~ /cms/logs/ { deny all; }
|
||||
location ~ ^/cms/(config(?:\.local)?\.php|config\.local\.php\.example|publish_scheduled\.php)$ { deny all; }
|
||||
location ~ ^/uploads/.*\.(php[0-9]?|phtml|phar|cgi|pl|py|sh)$ { deny all; }
|
||||
location ~ ^/uploads/.*\.(php[0-9]?|phtml|phar|cgi|pl|py|sh|exe|dll|bat|cmd|com|msi|asp|aspx|jsp|jspx|html?|xhtml|shtml|svg|xml|js|jar|swf)$ { deny all; }
|
||||
```
|
||||
|
||||
Also disable directory listings, prevent PHP source download through server misconfiguration, serve the CMS only over
|
||||
@@ -254,8 +380,9 @@ php tests/run.php
|
||||
```
|
||||
|
||||
It exercises authentication hardening, session revocation, login throttling, iframe sandboxing, path containment, content
|
||||
limits, configurable editable regions, drafts, publication, revisions, page operations, shared content, menus, and
|
||||
scheduled publishing in temporary directories. See [Development and testing](docs/DEVELOPMENT.md) for the complete check
|
||||
limits, configurable editable regions, drafts, publication, revisions, page operations, new-page privacy and templates,
|
||||
shared content, menu detection and propagation, user accounts (roles, invitations, blocking, password confirmation, config
|
||||
accounts), and scheduled publishing in temporary directories. See [Development and testing](docs/DEVELOPMENT.md) for the complete check
|
||||
list used by this repository.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -11,6 +11,9 @@ return [
|
||||
'roles' => [
|
||||
'admin' => 'administrator',
|
||||
],
|
||||
// Live sites: store CMS data (accounts, drafts, revisions) OUTSIDE the web root, as an absolute path PHP can write to,
|
||||
// for example '/var/neocms-data'. The default cms/data/ is protected only by .htaccess.
|
||||
// 'dataDirectory' => '/var/neocms-data',
|
||||
// Set this to true when HTTPS is terminated by a trusted reverse proxy.
|
||||
'security' => [
|
||||
'cookieSecure' => true,
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ $config = [
|
||||
'authentication' => [
|
||||
// 'admin' => '$2y$10$replace-with-a-real-password-hash',
|
||||
],
|
||||
// Grant each user the least powerful role they need: editor, publisher, or administrator.
|
||||
// Accounts here are the main administrators and are managed only in this file. Other accounts are created in the CMS (Tools > Users).
|
||||
// Roles: editor (edit, publish, schedule) or administrator (everything, including users). The old 'publisher' role counts as editor.
|
||||
'roles' => [
|
||||
// 'admin' => 'administrator',
|
||||
],
|
||||
|
||||
+91
-46
@@ -27,14 +27,15 @@ body {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
-webkit-box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas: "logo url user" "logo url buttons";
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 6px 16px;
|
||||
padding: 10px 16px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Same-origin preview iframe; JavaScript narrows its width for responsive previews. */
|
||||
@@ -50,33 +51,29 @@ body {
|
||||
|
||||
/* Product mark remains a fixed size while neighbouring controls wrap around it. */
|
||||
.controls .logo {
|
||||
background-image: url("/cms/img/logo.png");
|
||||
background-image: url("../img/logo.png");
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: block;
|
||||
flex: 0 0 60px;
|
||||
grid-area: logo;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* The two toolbar rows stay together and yield horizontal space when necessary. */
|
||||
/* Top right: user and Log out; the menu buttons sit directly beneath them. */
|
||||
.controls .buttonContainer {
|
||||
margin-left: auto;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
grid-area: buttons;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 7px 12px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
justify-content: flex-end;
|
||||
margin: 3px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.publish-controls {
|
||||
opacity: 0.94;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -115,9 +112,12 @@ body {
|
||||
|
||||
/* Current page path and unpublished-state label. */
|
||||
.controls #urlbox {
|
||||
flex: 1 1 320px;
|
||||
grid-area: url;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
color: #dee2e6;
|
||||
@@ -135,14 +135,18 @@ body {
|
||||
|
||||
/* Compact account, role, logout, and operational-warning area. */
|
||||
.controls .loggedInDetails {
|
||||
flex: 0 1 auto;
|
||||
grid-area: user;
|
||||
justify-self: end;
|
||||
color: #dee2e6;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
/* Name and Log out share one line. */
|
||||
.controls .loggedInDetails br:first-of-type { display: none; }
|
||||
.controls .loggedInDetails .link-button { margin-left: 10px; }
|
||||
|
||||
.link-button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
@@ -219,6 +223,9 @@ body {
|
||||
|
||||
/* Consistent form controls across SEO, scheduling, shared-content, and menu dialogues. */
|
||||
.cms-dialog input[type="text"],
|
||||
.cms-dialog input[type="email"],
|
||||
.cms-dialog input[type="password"],
|
||||
.cms-dialog select,
|
||||
.cms-dialog input[type="url"],
|
||||
.cms-dialog input[type="datetime-local"],
|
||||
.cms-dialog textarea {
|
||||
@@ -347,44 +354,34 @@ body {
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
/* At medium widths the page path receives its own row. */
|
||||
/* At medium widths the buttons take their own row under the user block; the path stays beside the logo. */
|
||||
@media (max-width: 1100px) {
|
||||
.pageContainer .controls {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls #urlbox {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.controls .buttonContainer {
|
||||
margin-left: 0;
|
||||
grid-template-areas: "logo url user" "buttons buttons buttons";
|
||||
}
|
||||
}
|
||||
|
||||
/* On small screens controls stack into touch-friendly full-width rows. */
|
||||
/* On small screens controls stack into touch-friendly full-width rows; the user block stays top right. */
|
||||
@media (max-width: 760px) {
|
||||
.pageContainer .controls {
|
||||
align-items: flex-start;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-areas: "logo user" "url url" "buttons buttons";
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.controls .logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-basis: 48px;
|
||||
}
|
||||
|
||||
.controls .buttonContainer,
|
||||
.controls .loggedInDetails,
|
||||
.toolbar-row {
|
||||
flex: 1 1 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls #urlbox {
|
||||
flex-basis: 100%;
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.controls .buttonContainer ul {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -501,10 +498,58 @@ body {
|
||||
.newpage-content { padding: 0; background: none; border: 0; width: auto; }
|
||||
#newPageForm > label[for="filename"] { margin: 4px 0 6px; font-weight: 600; }
|
||||
#newPageForm input[type="text"] { width: 100%; box-sizing: border-box; padding: 9px; margin-bottom: 16px; border: 1px solid #c9cfd8; border-radius: 6px; }
|
||||
#newPageForm button[type="submit"] { align-self: flex-end; padding: 9px 22px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
|
||||
#newPageForm button[type="submit"]:hover { background: #2f5a8a; }
|
||||
|
||||
/* Primary submit buttons inside dialogue forms. */
|
||||
.cms-dialog form button[type="submit"] { padding: 9px 18px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
|
||||
.cms-dialog form button[type="submit"]:hover { background: #2f5a8a; }
|
||||
/* Dialogue buttons use the small row-button metrics; primary actions are filled blue. */
|
||||
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)) {
|
||||
padding: 4px 9px; font-family: inherit; font-size: 12px; font-weight: 400; color: #374151;
|
||||
background: #fff; border: 1px solid #c9cfd8; border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)):hover { background: #f3f5f8; }
|
||||
.ui-dialog .ui-dialog-content button.danger-text { color: #a51d2d; }
|
||||
.ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content #siteScanApply {
|
||||
border-color: #3b6ea5; background: #3b6ea5; color: #fff; font-weight: 600;
|
||||
}
|
||||
.ui-dialog .ui-dialog-content button[type="submit"]:hover, .ui-dialog .ui-dialog-content .step-next:hover, .ui-dialog .ui-dialog-content #siteScanApply:hover { background: #2f5a8a; }
|
||||
#fileList .row-actions button { padding: 4px 9px; font-size: 12px; }
|
||||
/* Action buttons (editor Save/Schedule/Publish, dialogue Save/Next/Apply) are a step larger than row buttons. */
|
||||
#editModal .editor-actions button, .ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content .step-back, .ui-dialog .ui-dialog-content #siteScanApply { padding: 6px 14px; font-size: 13px; }
|
||||
#sharedList, #menuList { margin-bottom: 16px; }
|
||||
|
||||
/* Device-preview buttons are icon-only; the active one is highlighted. */
|
||||
.viewport-button {
|
||||
min-width: 42px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.viewport-button.active {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.85);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* New-page wizard. */
|
||||
.step-indicator { margin: 0 0 4px; font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.step-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
#menuChoice { display: grid; gap: 8px; }
|
||||
#newPageForm section[hidden] { display: none; }
|
||||
|
||||
/* Wizard lists reuse the page-table look. */
|
||||
#radioList, #menuChoice { display: block; margin-bottom: 14px; }
|
||||
#radioList .table-wrap, #menuChoice .table-wrap { max-height: 46vh; overflow: auto; }
|
||||
|
||||
/* Users dialogue. */
|
||||
#usersDialog form { margin-bottom: 8px; }
|
||||
#usersDialog #inviteLink { width: calc(100% - 100px); box-sizing: border-box; padding: 6px; }
|
||||
|
||||
/* Important administrator recommendations (for example the data directory notice). */
|
||||
.important-note, .cms-dialog .scan-note.important-note { color: #b3141f; font-weight: 600; }
|
||||
|
||||
/* Media library: upload, category tabs, and file cards. */
|
||||
.media-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 10px; }
|
||||
.media-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.media-tab[aria-selected="true"] { background: #3b6ea5 !important; border-color: #3b6ea5 !important; color: #fff !important; }
|
||||
.media-thumb { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; height: 130px; background: #eef2f5; color: #4a7bb7; font-weight: 600; font-size: 12px; }
|
||||
.media-thumb svg { width: 40px; height: 40px; }
|
||||
.media-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
#mediaUploadReport:empty { display: none; }
|
||||
|
||||
/* New page wizard sits under the page list in the Pages dialogue. */
|
||||
#newPageSection { margin-top: 22px; padding-top: 6px; border-top: 1px solid #d4dde4; }
|
||||
|
||||
+27
-40
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* Authenticated TinyMCE image-upload endpoint.
|
||||
* Authenticated media-upload endpoint (images, documents, video, audio), also used by TinyMCE for pasted images.
|
||||
*
|
||||
* Files are checked by byte size, detected MIME type, and image decoding before receiving a random
|
||||
* server filename. Trusting only the browser-supplied extension would be admirably optimistic.
|
||||
* Every file is checked by MediaTypes::inspect (extension allow-list, content sniffing, code scanning) before it receives a
|
||||
* server-generated filename. Trusting only the browser-supplied extension would be admirably optimistic.
|
||||
*/
|
||||
|
||||
// Load credentials, role assignments, and shared application settings.
|
||||
@@ -13,12 +13,13 @@ use NeoCMS\Activity;
|
||||
use NeoCMS\Authentication;
|
||||
use NeoCMS\FileStore;
|
||||
use NeoCMS\Logger;
|
||||
use NeoCMS\MediaTypes;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
|
||||
SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
|
||||
// Authentication is required before any upload details are processed.
|
||||
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
if (!$authentication->isLoggedIn()) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorised']);
|
||||
@@ -48,14 +49,6 @@ if (!$authentication->isValidCsrfToken($csrfToken)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// Map explicitly supported image MIME types to controlled filename extensions.
|
||||
$allowed_types = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
];
|
||||
|
||||
// Apply configured byte, dimension, pixel-count, file-count, and total-storage limits.
|
||||
$uploadConfig = is_array($config['uploads'] ?? null) ? $config['uploads'] : [];
|
||||
$max_file_size = max(1024, (int) ($uploadConfig['maxFileBytes'] ?? 10 * 1024 * 1024));
|
||||
@@ -66,7 +59,7 @@ $maxFiles = max(1, (int) ($uploadConfig['maxFiles'] ?? 2000));
|
||||
$maxTotalBytes = max($max_file_size, (int) ($uploadConfig['maxTotalBytes'] ?? 500 * 1024 * 1024));
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max_file_size + 1024 * 1024) {
|
||||
http_response_code(413);
|
||||
echo json_encode(['error' => 'Upload request is too large.']);
|
||||
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -77,38 +70,32 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
// Enforce the application limit even if php.ini permits larger files.
|
||||
if ($file['size'] > $max_file_size) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'File size exceeds the maximum limit.']);
|
||||
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Inspect file bytes rather than trusting the user-controlled original filename.
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime_type = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
// A pasted image may arrive without an extension; name it from its detected type so it can be checked like any other file.
|
||||
$originalName = (string) $file['name'];
|
||||
if (pathinfo($originalName, PATHINFO_EXTENSION) === '') {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$detected = (string) finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
$originalName .= '.' . (['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'][$detected] ?? '');
|
||||
}
|
||||
|
||||
if (!array_key_exists($mime_type, $allowed_types)) {
|
||||
// Extension allow-list, content sniffing, code scanning and per-format checks (see MediaTypes::inspect).
|
||||
try {
|
||||
$inspection = MediaTypes::inspect($file['tmp_name'], $originalName, [
|
||||
'maxBytes' => $max_file_size, 'maxWidth' => $maxWidth, 'maxHeight' => $maxHeight, 'maxPixels' => $maxPixels,
|
||||
]);
|
||||
} catch (\RuntimeException $exception) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid file type.']);
|
||||
echo json_encode(['error' => $exception->getMessage()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Confirm that an image decoder recognises the payload as an actual image.
|
||||
$imageInfo = getimagesize($file['tmp_name']);
|
||||
if ($imageInfo === false) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Uploaded file is not a valid image.']);
|
||||
exit;
|
||||
}
|
||||
[$width, $height] = $imageInfo;
|
||||
if ($width > $maxWidth || $height > $maxHeight || $width * $height > $maxPixels) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Image dimensions exceed the configured limit.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Random names prevent collisions and avoid publishing the original local filename.
|
||||
$extension = $allowed_types[$mime_type];
|
||||
$filename = bin2hex(random_bytes(16)) . '.' . $extension;
|
||||
// Generated names keep the original filename out of the URL namespace and allow only one extension.
|
||||
$filename = MediaTypes::storedName($originalName, $inspection['ext']);
|
||||
|
||||
// Create the public upload directory on a fresh installation.
|
||||
$uploadRoot = __DIR__ . '/../uploads';
|
||||
@@ -164,7 +151,7 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
$metadata = $store->read('media');
|
||||
$originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME);
|
||||
$altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200);
|
||||
$metadata[$filename] = ['alt' => $altText];
|
||||
$metadata[$filename] = ['alt' => $altText, 'original' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', basename($originalName)) ?? '', 0, 200), 'by' => $authentication->getLoggedInUser()];
|
||||
try {
|
||||
$store->write('media', $metadata);
|
||||
} catch (\Throwable $exception) {
|
||||
@@ -180,11 +167,11 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||
// The file is saved by now, so a failure to write the audit entry must not fail the upload.
|
||||
try {
|
||||
(new Activity($store, new Logger($config['audit'] ?? true, $config['security'] ?? [])))
|
||||
->record($authentication->getLoggedInUser(), 'Uploaded image', $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
|
||||
->record($authentication->getLoggedInUser(), 'Uploaded ' . (['imagery' => 'image', 'documents' => 'document', 'video' => 'video', 'audio' => 'audio'][$inspection['category']]), $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
|
||||
} catch (\Throwable $exception) {
|
||||
// Deliberately ignored.
|
||||
}
|
||||
echo json_encode(['location' => $location]);
|
||||
echo json_encode(['location' => $location, 'name' => $filename, 'category' => $inspection['category']]);
|
||||
} else {
|
||||
flock($uploadLock, LOCK_UN);
|
||||
fclose($uploadLock);
|
||||
|
||||
+101
-27
@@ -8,7 +8,7 @@ use NeoCMS\Authentication;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
|
||||
// Prepare the session values required by both the rendered shell and JavaScript client.
|
||||
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
$csrfToken = $authentication->getCsrfToken();
|
||||
// Only a single valid CSS class is allowed; malformed values fall back safely.
|
||||
$editableClass = $config['editableClass'] ?? 'editable';
|
||||
@@ -54,20 +54,20 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
<div class="buttonContainer">
|
||||
<div class="toolbar-row">
|
||||
<button class="headerButton" id="dashboardButton">Dashboard</button>
|
||||
<button class="headerButton manage-only" id="newPage">New Page</button>
|
||||
<button class="headerButton" id="selectPage">Pages</button>
|
||||
<button class="headerButton" id="mediaButton">Media</button>
|
||||
<button class="headerButton" id="seoButton">SEO</button>
|
||||
<button class="headerButton manage-only" id="menusButton">Navigation Menus</button>
|
||||
<button class="headerButton" id="moreButton">Tools</button>
|
||||
</div>
|
||||
<div class="toolbar-row publish-controls">
|
||||
<button class="headerButton viewport-button" data-width="100%">Desktop</button>
|
||||
<button class="headerButton viewport-button" data-width="768px">Tablet</button>
|
||||
<button class="headerButton viewport-button" data-width="390px">Mobile</button>
|
||||
<button class="headerButton viewport-button active" data-width="100%" title="Desktop view" aria-label="Desktop view" aria-pressed="true"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4" width="19" height="12.5" rx="1.5"/><path d="M8 20.5h8M12 16.5v4"/></svg></button>
|
||||
<button class="headerButton viewport-button" data-width="768px" title="Tablet view" aria-label="Tablet view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2.5" width="14" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
|
||||
<button class="headerButton viewport-button" data-width="390px" title="Mobile view" aria-label="Mobile view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loggedInDetails">
|
||||
<?php echo htmlspecialchars($authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?>
|
||||
<span id="whoami"><?php $profile = \NeoCMS\UserStore::fromConfig($config)->profile($authentication->getLoggedInUser()); echo htmlspecialchars($profile['name'] !== '' ? $profile['name'] : $authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?></span>
|
||||
(<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>)<br/>
|
||||
<button id="logoutButton" class="link-button">Log out</button><br/>
|
||||
<?php
|
||||
@@ -91,24 +91,8 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template and target path controls for administrator page creation. -->
|
||||
<div id="newPageDialog" title="Create a New Page">
|
||||
<div class="newpage-content">
|
||||
<form id="newPageForm">
|
||||
<h3 class="dialog-section">Choose a template</h3>
|
||||
<div id="radioList">
|
||||
<!-- JavaScript populates available filesystem templates here. -->
|
||||
</div>
|
||||
<label for="filename">New Filename:</label>
|
||||
<input type="text" id="filename" name="filename" placeholder="Enter new filename" required>
|
||||
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Searchable page picker with role-dependent management actions. -->
|
||||
<div id="fileListDialog" title="Select an Existing Page">
|
||||
<div id="fileListDialog" title="Pages">
|
||||
<div class="filelist-content">
|
||||
<input id="pageSearch" type="search" placeholder="Search pages" aria-label="Search pages">
|
||||
<div class="table-wrap">
|
||||
@@ -123,6 +107,37 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
<p id="pageListEmpty" class="scan-note" hidden>No pages match your search.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New page wizard: template, name, navigation group. Administrators only. -->
|
||||
<div id="newPageSection" class="manage-only">
|
||||
<h3 class="dialog-section">New page</h3>
|
||||
<div class="newpage-content">
|
||||
<form id="newPageForm">
|
||||
<p id="newPageStep" class="step-indicator"></p>
|
||||
<section data-step="1">
|
||||
<h3 class="dialog-section">Choose a template</h3>
|
||||
<div id="radioList">
|
||||
<!-- JavaScript populates available filesystem templates here. -->
|
||||
</div>
|
||||
<div class="step-actions"><button type="button" class="step-next">Next</button></div>
|
||||
</section>
|
||||
<section data-step="2" hidden>
|
||||
<h3 class="dialog-section">Name your page</h3>
|
||||
<label for="pageName">Page name</label>
|
||||
<input type="text" id="pageName" maxlength="80" placeholder="For example: About us" autocomplete="off">
|
||||
<p class="scan-note">Used as the page title and the navigation link text. The filename is created for you.</p>
|
||||
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="button" class="step-next">Next</button></div>
|
||||
</section>
|
||||
<section data-step="3" hidden>
|
||||
<h3 class="dialog-section">Choose a navigation group</h3>
|
||||
<div id="menuChoice">
|
||||
<!-- JavaScript lists the saved navigation menus here. -->
|
||||
</div>
|
||||
<p class="scan-note">The page is saved as a private draft. It appears on the site, and in this menu, only once you publish it.</p>
|
||||
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="submit">Save</button></div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operational overview: pending work, schedules, activity, and filesystem warnings. -->
|
||||
@@ -134,14 +149,20 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
<button id="revisionsButton">Revision history</button>
|
||||
<button id="accessibilityButton">Accessibility check</button>
|
||||
<button id="sharedButton" class="manage-only">Shared content</button>
|
||||
<button id="menusButton" class="manage-only">Navigation menus</button>
|
||||
<button id="siteScanButton" class="manage-only">Site scan</button>
|
||||
<button id="usersButton">Users</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reusable uploaded-image browser and metadata editor. -->
|
||||
<div id="mediaDialog" class="cms-dialog" title="Media Library">
|
||||
<p>Select an image to insert it into the open editable region.</p>
|
||||
<div class="media-toolbar">
|
||||
<button type="button" id="mediaUploadButton">Upload files</button>
|
||||
<input type="file" id="mediaFile" multiple hidden accept="<?php echo htmlspecialchars('.' . implode(',.', \NeoCMS\MediaTypes::extensions()), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<div id="mediaTabs" class="media-tabs" role="tablist" aria-label="Media categories"></div>
|
||||
</div>
|
||||
<p id="mediaHint" class="scan-note"></p>
|
||||
<div id="mediaUploadReport" class="scan-note" role="status" aria-live="polite"></div>
|
||||
<div id="mediaList" class="media-grid"></div>
|
||||
</div>
|
||||
|
||||
@@ -184,13 +205,65 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
<!-- Line-oriented menu editor supporting an optional parent label for nesting. -->
|
||||
<div id="menusDialog" class="cms-dialog" title="Navigation Menus">
|
||||
<div id="menuList"></div>
|
||||
<form id="menuForm">
|
||||
<label>Menu name<input id="menuName" type="text" pattern="[A-Za-z0-9_-]+" required></label>
|
||||
<p class="scan-note"><button id="menuScanButton" type="button">Scan site for new navigation</button> Finds navigation added to any page since the last scan.</p>
|
||||
<form id="menuForm" hidden>
|
||||
<h3 class="dialog-section">Editing menu: <code id="menuKey"></code></h3>
|
||||
<input id="menuName" type="hidden">
|
||||
<label>Menu name<input id="menuTitle" type="text" maxlength="60" placeholder="Display name"></label>
|
||||
<label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | / About | /about.html Team | /team.html | About" required></textarea></label>
|
||||
<button type="submit">Save menu</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Own account (everyone), role descriptions, and account management (administrators only). -->
|
||||
<div id="usersDialog" class="cms-dialog" title="Users">
|
||||
<h3 class="dialog-section">My account</h3>
|
||||
<form id="profileForm" autocomplete="off">
|
||||
<label>Login name <span class="scan-note">(used to sign in; set by an administrator)</span><input id="profileLogin" type="text" readonly></label>
|
||||
<label>Display name<input id="profileName" type="text" maxlength="80" required autocomplete="name"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="profileEmail" type="email" maxlength="254" autocomplete="email"></label>
|
||||
<label>Current password <span class="scan-note">(only needed to change the email address)</span><input id="profileCurrent" type="password" maxlength="4096" autocomplete="current-password"></label>
|
||||
<button type="submit">Save details</button>
|
||||
</form>
|
||||
<form id="passwordForm" autocomplete="off">
|
||||
<h3 class="dialog-section">Change password</h3>
|
||||
<label>Current password<input id="pwCurrent" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<label>New password <span class="scan-note">(12 to 72 characters)</span><input id="pwNew" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<label>Confirm new password<input id="pwConfirm" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
|
||||
<button type="submit">Change password</button>
|
||||
</form>
|
||||
<p id="passwordManaged" class="scan-note" hidden>Your password is managed in the site configuration (config.local.php) and cannot be changed here.</p>
|
||||
|
||||
<h3 class="dialog-section">Roles</h3>
|
||||
<div id="rolesList"></div>
|
||||
|
||||
<div id="userAdmin" class="manage-only">
|
||||
<h3 class="dialog-section">User accounts</h3>
|
||||
<div id="userList"></div>
|
||||
<p class="scan-note"><button id="userAddButton" type="button">Add user</button> <button id="userInviteButton" type="button">Invite user</button> Accounts marked Config are managed in config.local.php and cannot be changed here.</p>
|
||||
<form id="userForm" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="userFormTitle"></h3>
|
||||
<input id="userExisting" type="hidden">
|
||||
<label id="userUsernameRow">Login name <span class="scan-note">(3 to 32 letters, numbers, . - _)</span><input id="userUsername" type="text" maxlength="32" autocomplete="off"></label>
|
||||
<label>Display name<input id="userName" type="text" maxlength="80" required autocomplete="off"></label>
|
||||
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="userEmail" type="email" maxlength="254" autocomplete="off"></label>
|
||||
<label>Role<select id="userRole"><option value="editor">Editor</option><option value="administrator">Administrator</option></select></label>
|
||||
<label id="userPasswordRow">Password <span class="scan-note" id="userPasswordNote"></span><input id="userPassword" type="password" maxlength="72" autocomplete="new-password"></label>
|
||||
<label>Your password <span class="scan-note">(to confirm this change)</span><input id="userConfirm" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit" id="userSubmit">Save user</button> <button type="button" id="userCancel">Cancel</button>
|
||||
</form>
|
||||
<form id="confirmBox" autocomplete="off" hidden>
|
||||
<h3 class="dialog-section" id="confirmTitle"></h3>
|
||||
<label>Your password<input id="confirmPassword" type="password" maxlength="4096" required autocomplete="current-password"></label>
|
||||
<button type="submit">Confirm</button> <button type="button" id="confirmCancel">Cancel</button>
|
||||
</form>
|
||||
<div id="inviteResult" hidden>
|
||||
<h3 class="dialog-section">Invitation link</h3>
|
||||
<p class="scan-note">Send this link to the person. It is shown only once, works once, and expires in 7 days. Their login name is <strong id="inviteLogin"></strong>.</p>
|
||||
<input id="inviteLink" type="text" readonly> <button type="button" id="inviteCopy">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Whole-site analysis with one-click tagging of content regions, images, and SEO tags. Administrators only. -->
|
||||
<div id="siteScanDialog" class="cms-dialog" title="Site Scan">
|
||||
<div id="siteScanProgress" hidden>
|
||||
@@ -203,6 +276,7 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
|
||||
<label><input type="checkbox" id="scanContent" checked> Make content areas editable</label>
|
||||
<label><input type="checkbox" id="scanImages" checked> Make images editable</label>
|
||||
<label><input type="checkbox" id="scanSeo" checked> Add missing SEO tags (never overwrites existing ones)</label>
|
||||
<label><input type="checkbox" id="scanMenus" checked> Detect navigation menus (fills Navigation Menus; never overwrites a saved menu)</label>
|
||||
</fieldset>
|
||||
<div id="siteScanPages"></div>
|
||||
<p class="scan-note">Each changed page keeps a revision, so this can be undone from Revision history.</p>
|
||||
|
||||
+475
-52
@@ -76,14 +76,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Latest response from the users action. */
|
||||
let usersData = null;
|
||||
|
||||
/** Configure all jQuery UI dialogues once, leaving individual workflows to populate them. */
|
||||
function initialiseDialogs() {
|
||||
$('.cms-dialog, #newPageDialog, #fileListDialog').hide();
|
||||
$('.cms-dialog, #newPageDialog, #fileListDialog').not('#editModal').each(function () {
|
||||
$('.cms-dialog, #fileListDialog').hide();
|
||||
$('.cms-dialog, #fileListDialog').not('#editModal').each(function () {
|
||||
$(this).dialog({autoOpen: false, modal: true, width: Math.min(760, window.innerWidth - 30)});
|
||||
});
|
||||
// The page list is a wide table, so it gets a wider dialogue than the other tools.
|
||||
$('#fileListDialog').dialog('option', 'width', Math.min(1000, window.innerWidth - 30));
|
||||
$('#fileListDialog').dialog('option', 'maxHeight', Math.round(window.innerHeight * 0.92));
|
||||
$('#editModal').dialog({
|
||||
autoOpen: false,
|
||||
modal: true,
|
||||
@@ -96,9 +100,10 @@
|
||||
/** Connect toolbar controls and forms to their workflow handlers. */
|
||||
function bindToolbar() {
|
||||
$('#dashboardButton').on('click', function () { loadDashboard(true); });
|
||||
$('#newPage').on('click', openNewPage);
|
||||
$('#selectPage').on('click', openPages);
|
||||
$('#mediaButton').on('click', openMedia);
|
||||
$('#mediaButton').on('click', function () { openMedia(); });
|
||||
$('#mediaUploadButton').on('click', function () { $('#mediaFile').trigger('click'); });
|
||||
$('#mediaFile').on('change', function () { const files = Array.from(this.files); this.value = ''; if (files.length) uploadMedia(files); });
|
||||
$('#seoButton').on('click', openSeo);
|
||||
$('#moreButton').on('click', function () { $('#toolsDialog').dialog('open'); });
|
||||
$('#revisionsButton').on('click', openRevisions);
|
||||
@@ -106,18 +111,25 @@
|
||||
$('#sharedButton').on('click', openShared);
|
||||
$('#menusButton').on('click', openMenus);
|
||||
$('#siteScanButton').on('click', openSiteScan);
|
||||
$('#usersButton').on('click', openUsers);
|
||||
bindUsers();
|
||||
$('#siteScanApply').on('click', applySiteScan);
|
||||
$('#imageForm').on('submit', applyImage);
|
||||
$('#seoForm').on('submit', applySeo);
|
||||
$('#scheduleForm').on('submit', schedulePage);
|
||||
$('#sharedForm').on('submit', saveShared);
|
||||
$('#menuForm').on('submit', saveMenu);
|
||||
$('#menuScanButton').on('click', scanForNavigation);
|
||||
$('#newPageForm').on('submit', createPage);
|
||||
$('#newPageForm').on('click', '.step-next', function () { stepNewPage(1); });
|
||||
$('#newPageForm').on('click', '.step-back', function () { stepNewPage(-1); });
|
||||
$('#pageSearch').on('input', filterPages);
|
||||
$('#logoutButton').on('click', logout);
|
||||
$('#closeEditorBtn').on('click', function () { $('#editModal').dialog('close'); });
|
||||
$('.viewport-button').on('click', function () {
|
||||
$('#frameContainer').css({width: $(this).data('width'), margin: '0 auto'});
|
||||
$('.viewport-button').removeClass('active').attr('aria-pressed', 'false');
|
||||
$(this).addClass('active').attr('aria-pressed', 'true');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +151,8 @@
|
||||
return;
|
||||
}
|
||||
revalidatedHref = null;
|
||||
if (!draftWrite) {
|
||||
// about:blank is only ever our own draft surface, so it must not reset the page path or draft state.
|
||||
if (!draftWrite && href !== 'about:blank') {
|
||||
draftSaved = false;
|
||||
framePath = iframe.contentWindow.location.pathname;
|
||||
}
|
||||
@@ -202,7 +215,14 @@
|
||||
tinymce.init({
|
||||
selector: '#editor', height: 360, branding: false, promotion: false, license_key: 'gpl',
|
||||
plugins: 'preview searchreplace autolink autosave directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount quickbars emoticons help',
|
||||
toolbar: 'undo redo | blocks | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image media table | code preview fullscreen',
|
||||
toolbar: 'undo redo | blocks | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image media neomedia table | code preview fullscreen',
|
||||
setup: function (editor) {
|
||||
editor.ui.registry.addButton('neomedia', {
|
||||
text: 'Media library', icon: 'gallery', tooltip: 'Insert an uploaded image, document, video or audio file',
|
||||
onAction: function () { openMedia(function (item) { editor.insertContent(mediaHtml(item)); }); }
|
||||
});
|
||||
},
|
||||
toolbar_mode: 'wrap',
|
||||
images_upload_handler: uploadImage,
|
||||
automatic_uploads: true,
|
||||
convert_urls: false
|
||||
@@ -423,20 +443,23 @@
|
||||
$('#pageSearch').val('');
|
||||
$('#pageListEmpty').prop('hidden', true);
|
||||
pages.forEach(function (page) {
|
||||
const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { navigateTo(page.url); });
|
||||
const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { page.pending ? openPendingPage(page.url) : navigateTo(page.url); });
|
||||
const name = $('<td class="page-cell">').append(docIcon());
|
||||
$('<button type="button" class="page-open">').text(page.name).appendTo(name);
|
||||
if (page.draft) $('<span class="badge-draft">').text('Draft').appendTo(name);
|
||||
if (page.draft) $('<span class="badge-draft">').text(page.pending ? 'New - unpublished' : 'Draft').appendTo(name);
|
||||
row.append(name, $('<td class="page-title">').text(page.title), $('<td class="page-date">').text(formatDate(page.modified)));
|
||||
const actions = $('<td class="row-actions manage-col">').toggle(permissions.manage).appendTo(row);
|
||||
if (permissions.manage) {
|
||||
[['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
|
||||
if (permissions.manage && page.pending) {
|
||||
$('<button type="button" class="danger-text">').text('Discard').on('click', function (event) { event.stopPropagation(); discardNewPage(page.url); }).appendTo(actions);
|
||||
} else if (permissions.manage) {
|
||||
[['Create Template', 'template'], ['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
|
||||
$('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
|
||||
.on('click', function (event) { event.stopPropagation(); managePage(action[1], page.url); }).appendTo(actions);
|
||||
.on('click', function (event) { event.stopPropagation(); action[1] === 'template' ? setPageTemplate(page.url, true) : managePage(action[1], page.url); }).appendTo(actions);
|
||||
});
|
||||
}
|
||||
body.append(row);
|
||||
});
|
||||
await loadNewPage();
|
||||
$('#fileListDialog').dialog('open');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
@@ -511,29 +534,118 @@
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Load available templates and open the new-page dialogue. */
|
||||
async function openNewPage() {
|
||||
/** Fill the New page section of the Pages dialogue with templates and navigation groups, at step 1. */
|
||||
async function loadNewPage() {
|
||||
$('#newPageSection').toggle(permissions.manage);
|
||||
if (!permissions.manage) return;
|
||||
try {
|
||||
const templates = await api('getTemplates');
|
||||
const menus = permissions.manage ? await api('menus') : {};
|
||||
// Both lists use the same table styling as the page picker; a row selects its radio button.
|
||||
const pick = function (body, name, value, checked, cells) {
|
||||
const row = $('<tr class="page-row">');
|
||||
const radio = $('<input type="radio">').attr('name', name).val(value).prop('checked', checked).on('click', function (event) { event.stopPropagation(); });
|
||||
row.on('click', function () { radio.prop('checked', true); });
|
||||
$('<td class="check-cell">').append(radio).appendTo(row);
|
||||
cells.forEach(function (cell) { row.append(cell); });
|
||||
body.append(row);
|
||||
};
|
||||
const list = $('#radioList').empty();
|
||||
const templateBody = dataTable(list, ['', 'Template', 'Source', 'Actions']);
|
||||
templates.forEach(function (item, index) {
|
||||
$('<label class="template-option">').append($('<input type="radio" name="item">').val(item.id).prop('checked', index === 0), docIcon(), $('<span>').text(item.name)).appendTo(list);
|
||||
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(item.name));
|
||||
const actions = $('<td class="row-actions">');
|
||||
if (permissions.manage) actions.append(rowButton('Delete', function () { deleteTemplate(item); }, true));
|
||||
pick(templateBody, 'item', item.id, index === 0, [name, $('<td class="page-title">').text(item.detail ? 'Page ' + item.detail : 'Template file'), actions]);
|
||||
});
|
||||
$('#newPageDialog').dialog('open');
|
||||
const groups = $('#menuChoice').empty();
|
||||
const groupBody = dataTable(groups, ['', 'Navigation group', 'Items']);
|
||||
pick(groupBody, 'menu', '', true, [$('<td class="page-cell">').append($('<strong>').text('None')), $('<td class="page-title">').text('Do not add to navigation')]);
|
||||
Object.keys(menus).forEach(function (key) {
|
||||
pick(groupBody, 'menu', key, false, [$('<td class="page-cell">').append(docIcon(), $('<strong>').text(menus[key].title || key)), $('<td class="page-title">').text(menus[key].items.length + ' link(s)')]);
|
||||
});
|
||||
$('#pageName').val('');
|
||||
showNewPageStep(1);
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Create a template-based page and immediately navigate the preview to it. */
|
||||
/** Show one step of the new-page wizard. */
|
||||
function showNewPageStep(step) {
|
||||
$('#newPageForm section').each(function () { $(this).prop('hidden', Number($(this).data('step')) !== step); });
|
||||
$('#newPageStep').text('Step ' + step + ' of 3');
|
||||
if (step > 1) $('#newPageForm section[data-step="' + step + '"]').find('input[type="text"]').first().trigger('focus');
|
||||
}
|
||||
|
||||
/** Move between wizard steps, checking the current step is complete before going forward. */
|
||||
function stepNewPage(direction) {
|
||||
const step = Number($('#newPageForm section:not([hidden])').data('step'));
|
||||
if (direction > 0 && step === 1 && !$('input[name="item"]:checked').length) { showMessage('Choose a template.', 'error'); return; }
|
||||
if (direction > 0 && step === 2 && !$('#pageName').val().trim()) { showMessage('Enter a page name.', 'error'); return; }
|
||||
showNewPageStep(step + direction);
|
||||
}
|
||||
|
||||
/** Create the page as a private draft (nothing is public yet) and open it for editing. */
|
||||
async function createPage(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await api('newPage', {template: $('input[name="item"]:checked').val(), filename: $('#filename').val()}, 'POST');
|
||||
$('#newPageDialog').dialog('close');
|
||||
navigateTo(result.url);
|
||||
const result = await api('newPage', {template: $('input[name="item"]:checked').val(), name: $('#pageName').val(), menu: $('input[name="menu"]:checked').val() || ''}, 'POST');
|
||||
await openPendingPage(result.url);
|
||||
showMessage(result.message, 'success');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Open a page that has never been published: it exists only as a draft, so it is written into the preview. */
|
||||
async function openPendingPage(uri) {
|
||||
if (dirty && !window.confirm('Discard unpublished changes?')) return;
|
||||
try {
|
||||
const draft = await api('getDraft', {uri: uri});
|
||||
if (!draft.exists) throw new Error('This new page has no saved content.');
|
||||
dirty = false;
|
||||
framePath = basePath + uri;
|
||||
loadedDrafts.add(uri);
|
||||
writingDraft = true;
|
||||
$('#fileListDialog').dialog('close');
|
||||
$('#frameContainer').one('load.pending', function () {
|
||||
writingDraft = true;
|
||||
iframeDoc.open(); iframeDoc.write(withBase(draft.content, location.origin + basePath + uri)); iframeDoc.close();
|
||||
// Writing into a blank frame does not raise a second load event, so bind the editing handlers directly.
|
||||
writingDraft = true;
|
||||
initialiseFrame();
|
||||
draftSaved = true;
|
||||
updateUrl();
|
||||
});
|
||||
$('#frameContainer').attr('src', 'about:blank');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Remove a template: a template file is deleted; a page used as a template is only unmarked (the page stays). */
|
||||
async function deleteTemplate(item) {
|
||||
const isPage = item.id.indexOf('page:') === 0;
|
||||
const message = isPage
|
||||
? 'Stop using ' + item.detail + ' as a template? The page itself is not deleted.'
|
||||
: 'Delete the template file "' + item.name + '"? This cannot be undone. Pages already made from it are not affected.';
|
||||
if (!window.confirm(message)) return;
|
||||
try {
|
||||
showMessage((await api('deleteTemplate', {template: item.id}, 'POST')).message, 'success');
|
||||
loadNewPage();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
async function setPageTemplate(uri, on) {
|
||||
try {
|
||||
showMessage((await api('setPageTemplate', {uri: uri, value: on ? '1' : '0'}, 'POST')).message, 'success');
|
||||
openPages();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
async function discardNewPage(uri) {
|
||||
if (!window.confirm('Discard this unpublished page and its draft? This cannot be undone.')) return;
|
||||
try {
|
||||
showMessage((await api('discardNewPage', {uri: uri}, 'POST')).message, 'success');
|
||||
openPages();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Load the current page's revision history and render restore controls for publishers. */
|
||||
async function openRevisions() {
|
||||
try {
|
||||
@@ -568,42 +680,111 @@
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Build the media library with previews, metadata controls, use counts, and deletion. */
|
||||
async function openMedia() {
|
||||
/** Media categories in display order; the server derives each file's category from its extension. */
|
||||
const MEDIA_CATEGORIES = [['imagery', 'Imagery'], ['documents', 'Documents'], ['video', 'Video'], ['audio', 'Audio']];
|
||||
let mediaItems = [];
|
||||
let mediaCategory = 'all';
|
||||
/** When set, the library is open to choose a file for the editor: called with the chosen item. */
|
||||
let mediaPick = null;
|
||||
|
||||
/** Open the media library, optionally to pick a file for the open editor. */
|
||||
async function openMedia(pick) {
|
||||
mediaPick = typeof pick === 'function' ? pick : null;
|
||||
$('#mediaUploadReport').empty();
|
||||
await loadMedia();
|
||||
$('#mediaHint').text(mediaPick ? 'Choose a file to insert at the cursor.' : 'Upload files and organise them by type. Open a page and use the Media library button in the editor to insert them.');
|
||||
$('#mediaDialog').dialog('open');
|
||||
}
|
||||
|
||||
/** Fetch the library and draw the category tabs and cards. */
|
||||
async function loadMedia() {
|
||||
try {
|
||||
const media = await api('media');
|
||||
const list = $('#mediaList').empty();
|
||||
media.forEach(function (item) {
|
||||
const card = $('<div class="media-card">');
|
||||
$('<img>').attr({src: item.url, alt: item.alt || ''}).on('click', function () { insertMedia(item); }).appendTo(card);
|
||||
$('<strong>').text(item.name).appendTo(card);
|
||||
$('<small>').text(formatBytes(item.size) + ' - used ' + item.uses + ' time(s)').appendTo(card);
|
||||
const alt = $('<input type="text" placeholder="Alternative text">').val(item.alt).appendTo(card);
|
||||
$('<button>').text('Save alt text').on('click', async function () {
|
||||
try { showMessage((await api('updateMedia', {name: item.name, alt: alt.val()}, 'POST')).message, 'success'); } catch (error) { showMessage(error.message, 'error'); }
|
||||
}).appendTo(card);
|
||||
if (permissions.manage) $('<button class="danger-text">').text('Delete').on('click', function () { deleteMedia(item); }).appendTo(card);
|
||||
list.append(card);
|
||||
});
|
||||
$('#mediaDialog').dialog('open');
|
||||
mediaItems = await api('media');
|
||||
renderMedia();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Append a library image to the currently selected editable region. */
|
||||
function insertMedia(item) {
|
||||
if (!currentElement) {
|
||||
showMessage('Open an editable region first, then select an image.', 'error');
|
||||
return;
|
||||
}
|
||||
currentElement.append($('<img>').attr({src: item.url, alt: item.alt || ''}));
|
||||
function mediaIcon(category) {
|
||||
if (category === 'video') return svgIcon('<rect x="3" y="5" width="14" height="14" rx="2"/><path d="M17 10l4-2v8l-4-2z"/>');
|
||||
if (category === 'audio') return svgIcon('<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>');
|
||||
return docIcon();
|
||||
}
|
||||
|
||||
/** HTML inserted into the editor for a library file, by category. */
|
||||
function mediaHtml(item) {
|
||||
const label = item.alt || item.original || item.name;
|
||||
if (item.category === 'imagery') return $('<img>').attr({src: item.url, alt: item.alt || ''})[0].outerHTML;
|
||||
if (item.category === 'video') return $('<video controls preload="metadata" style="max-width:100%">').attr('src', item.url)[0].outerHTML;
|
||||
if (item.category === 'audio') return $('<audio controls preload="none">').attr('src', item.url)[0].outerHTML;
|
||||
return $('<a>').attr('href', item.url).text(label + ' (' + item.ext.toUpperCase() + ', ' + formatBytes(item.size) + ')')[0].outerHTML;
|
||||
}
|
||||
|
||||
function renderMedia() {
|
||||
const counts = {all: mediaItems.length};
|
||||
MEDIA_CATEGORIES.forEach(function (c) { counts[c[0]] = mediaItems.filter(function (item) { return item.category === c[0]; }).length; });
|
||||
const tabs = $('#mediaTabs').empty();
|
||||
[['all', 'All']].concat(MEDIA_CATEGORIES).forEach(function (c) {
|
||||
$('<button type="button" role="tab" class="media-tab">').text(c[1] + ' (' + counts[c[0]] + ')').attr('aria-selected', c[0] === mediaCategory ? 'true' : 'false')
|
||||
.on('click', function () { mediaCategory = c[0]; renderMedia(); }).appendTo(tabs);
|
||||
});
|
||||
const list = $('#mediaList').empty();
|
||||
const shown = mediaItems.filter(function (item) { return mediaCategory === 'all' || item.category === mediaCategory; });
|
||||
if (!shown.length) $('<p class="empty-state">').text(mediaItems.length ? 'Nothing in this category yet.' : 'No media yet. Use Upload files to add some.').appendTo(list);
|
||||
shown.forEach(function (item) {
|
||||
const card = $('<div class="media-card">');
|
||||
const thumb = item.category === 'imagery'
|
||||
? $('<img>').attr({src: item.url, alt: item.alt || ''})
|
||||
: $('<div class="media-thumb">').append(mediaIcon(item.category), $('<span>').text(item.ext.toUpperCase()));
|
||||
thumb.on('click', function () { if (mediaPick) chooseMedia(item); }).appendTo(card);
|
||||
$('<strong>').text(item.original || item.name).attr('title', item.name).appendTo(card);
|
||||
$('<small>').text(item.ext.toUpperCase() + ' - ' + formatBytes(item.size) + ' - used ' + item.uses + ' time(s)').appendTo(card);
|
||||
const alt = $('<input type="text">').attr('placeholder', item.category === 'imagery' ? 'Alternative text' : 'Link text or description').val(item.alt).appendTo(card);
|
||||
const actions = $('<div class="media-actions">').appendTo(card);
|
||||
if (mediaPick) $('<button type="button">').text('Insert').addClass('media-insert').on('click', function () { item.alt = alt.val(); chooseMedia(item); }).appendTo(actions);
|
||||
else $('<button type="button">').text('Copy link').on('click', function () { copyText(location.origin + item.url); }).appendTo(actions);
|
||||
$('<button type="button">').text('Save description').on('click', async function () {
|
||||
try { showMessage((await api('updateMedia', {name: item.name, alt: alt.val()}, 'POST')).message, 'success'); item.alt = alt.val(); } catch (error) { showMessage(error.message, 'error'); }
|
||||
}).appendTo(actions);
|
||||
if (permissions.manage) $('<button type="button" class="danger-text">').text('Delete').on('click', function () { deleteMedia(item); }).appendTo(actions);
|
||||
list.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
function chooseMedia(item) {
|
||||
const pick = mediaPick;
|
||||
mediaPick = null;
|
||||
$('#mediaDialog').dialog('close');
|
||||
commitEdit();
|
||||
if (pick) pick(item);
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
(navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).then(function () { showMessage('Link copied.', 'success'); }, function () { window.prompt('Copy this link:', text); });
|
||||
}
|
||||
|
||||
/** Upload the chosen files one request each; every refusal is reported with its reason. */
|
||||
async function uploadMedia(files) {
|
||||
const report = $('#mediaUploadReport').empty();
|
||||
let done = 0;
|
||||
for (const file of files) {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
form.append('csrf_token', csrfToken);
|
||||
try {
|
||||
await $.ajax({url: basePath + '/cms/image_upload.php', method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'});
|
||||
done++;
|
||||
} catch (xhr) {
|
||||
$('<div class="issue">').text(file.name + ': ' + ((xhr.responseJSON && xhr.responseJSON.error) || 'Upload failed')).appendTo(report);
|
||||
}
|
||||
}
|
||||
if (done) showMessage('Uploaded ' + done + ' of ' + files.length + ' file(s).', 'success');
|
||||
else showMessage('No files were uploaded.', 'error');
|
||||
await loadMedia();
|
||||
}
|
||||
|
||||
/** Delete a media file, warning more firmly when public pages still reference it. */
|
||||
async function deleteMedia(item) {
|
||||
if (item.uses && !window.confirm('This image is used on ' + item.uses + ' page location(s). Delete it anyway?')) return;
|
||||
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); openMedia(); } catch (error) { showMessage(error.message, 'error'); }
|
||||
if (item.uses && !window.confirm('This file is used on ' + item.uses + ' page location(s). Delete it anyway?')) return;
|
||||
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); await loadMedia(); } catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
// Pages per request: small enough for smooth progress updates, large enough to keep request overhead low.
|
||||
@@ -696,6 +877,19 @@
|
||||
[s.brokenRefs, 'broken references', 'Local image, CSS, or script files that do not exist.'],
|
||||
[s.seoGaps, 'missing title or description', ''], [scan.uploads.files + ' (' + scan.uploads.unused + ' unused)', 'uploaded files', '']
|
||||
].forEach(function (stat) { $('<div class="stat">').attr('title', stat[2]).append($('<strong>').text(stat[0]), document.createTextNode(' ' + stat[1])).appendTo(stats); });
|
||||
const menus = {};
|
||||
scan.pages.forEach(function (page) {
|
||||
(page.navs || []).forEach(function (nav) {
|
||||
const menu = menus[nav.menu] = menus[nav.menu] || {pages: 0, links: nav.links, sigs: {}};
|
||||
menu.pages++;
|
||||
menu.sigs[nav.sig] = (menu.sigs[nav.sig] || 0) + 1;
|
||||
});
|
||||
});
|
||||
const menuText = Object.keys(menus).map(function (name) {
|
||||
const menu = menus[name], variants = Object.keys(menu.sigs).length;
|
||||
return name + ' (' + menu.links + ' links on ' + menu.pages + ' pages' + (variants > 1 ? ', ' + variants + ' different versions' : '') + ')';
|
||||
});
|
||||
if (menuText.length) $('<p class="scan-note">').text('Navigation: ' + menuText.join(', ')).appendTo(summary);
|
||||
$('<p class="scan-note">').text('Folders: ' + Object.keys(scan.structure).map(function (dir) { return dir + ' (' + scan.structure[dir] + ')'; }).join(', ')).appendTo(summary);
|
||||
|
||||
// After the first run only pages with something new to do are listed; finished pages stay out of the way.
|
||||
@@ -728,7 +922,7 @@
|
||||
const uris = $('#siteScanPages tbody input:checked').map(function () { return $(this).val(); }).get();
|
||||
if (!uris.length) { showMessage('Select at least one page.', 'error'); return; }
|
||||
if (!window.confirm('Add editing markers to ' + uris.length + ' page(s)? A revision of each is kept.')) return;
|
||||
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked')};
|
||||
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked'), menus: $('#scanMenus').is(':checked')};
|
||||
setScanBusy(true);
|
||||
let updated = 0;
|
||||
try {
|
||||
@@ -755,7 +949,7 @@
|
||||
$('#imageAlt').val(image.is('img') ? image.attr('alt') || '' : '');
|
||||
const picker = $('#imagePicker').empty();
|
||||
try {
|
||||
(await api('media')).forEach(function (item) {
|
||||
(await api('media')).filter(function (item) { return item.category === 'imagery'; }).forEach(function (item) {
|
||||
$('<img>').attr({src: item.url, alt: item.alt || '', title: item.name}).on('click', function () {
|
||||
$('#imageSrc').val(item.url);
|
||||
if (!$('#imageAlt').val()) $('#imageAlt').val(item.alt || '');
|
||||
@@ -898,25 +1092,188 @@
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Own account, role descriptions, and (administrators) account management. Secrets never come back from the server. */
|
||||
async function openUsers() {
|
||||
try {
|
||||
$('#userForm, #confirmBox, #inviteResult').prop('hidden', true);
|
||||
$('#profileCurrent, #pwCurrent, #pwNew, #pwConfirm').val('');
|
||||
await loadUsers();
|
||||
$('#toolsDialog').dialog('close');
|
||||
$('#usersDialog').dialog('open');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const data = await api('users');
|
||||
usersData = data;
|
||||
$('#profileLogin').val(data.me.username);
|
||||
$('#profileName').val(data.me.name || data.me.username);
|
||||
$('#profileEmail').val(data.me.email);
|
||||
$('#passwordForm').prop('hidden', data.me.managed);
|
||||
$('#passwordManaged').prop('hidden', !data.me.managed);
|
||||
const roles = dataTable($('#rolesList').empty(), ['Role', 'What it can do']);
|
||||
Object.keys(data.roles).forEach(function (role) {
|
||||
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(role.charAt(0).toUpperCase() + role.slice(1)));
|
||||
if (role === data.me.role) name.append($('<span class="badge-pending">').text('You'));
|
||||
roles.append($('<tr>').append(name, $('<td class="page-title">').text(data.roles[role])));
|
||||
});
|
||||
$('#userAdmin').toggle(permissions.manage);
|
||||
const list = $('#userList').empty();
|
||||
if (!permissions.manage) return;
|
||||
const body = dataTable(list, ['Name and login name', 'Email', 'Role', 'Status', 'Actions']);
|
||||
data.users.forEach(function (account) {
|
||||
const isMe = account.username === data.me.username;
|
||||
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(account.name || account.username), $('<span class="page-sub">').text(account.username));
|
||||
const status = $('<td>');
|
||||
if (account.managed) $('<span class="badge-draft">').attr('title', 'Managed in config.local.php').text('Config').appendTo(status);
|
||||
else if (account.status === 'blocked') $('<span class="badge-failed">').text('Blocked').appendTo(status);
|
||||
else if (account.status === 'invited') $('<span class="badge-draft">').text(account.inviteExpired ? 'Invite expired' : 'Invited').appendTo(status);
|
||||
else $('<span class="badge-pending">').text('Active').appendTo(status);
|
||||
const actions = $('<td class="row-actions">');
|
||||
if (!account.managed && !isMe) {
|
||||
if (account.status === 'invited') actions.append(rowButton('Re-invite', function () { showUserForm('reinvite', account); }));
|
||||
else actions.append(rowButton('Edit', function () { showUserForm('edit', account); }));
|
||||
if (account.status !== 'invited') actions.append(rowButton(account.status === 'blocked' ? 'Unblock' : 'Block', function () { blockUser(account); }));
|
||||
actions.append(rowButton('Delete', function () { deleteUser(account); }, true));
|
||||
}
|
||||
body.append($('<tr>').append(name, $('<td class="page-date">').text(account.email), $('<td>').text(account.role.charAt(0).toUpperCase() + account.role.slice(1)), status, actions));
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the add/edit/invite form; the username and password fields appear only where they apply. */
|
||||
function showUserForm(mode, account) {
|
||||
const isNew = mode === 'add' || mode === 'invite';
|
||||
const invite = mode === 'invite' || mode === 'reinvite';
|
||||
$('#inviteResult, #confirmBox').prop('hidden', true);
|
||||
$('#userForm').data('mode', mode).prop('hidden', false);
|
||||
$('#userFormTitle').text({add: 'Add user', edit: 'Edit user', invite: 'Invite user', reinvite: 'Re-invite user'}[mode]);
|
||||
$('#userExisting').val(isNew ? '' : account.username);
|
||||
$('#userUsername').val('').prop('required', mode === 'add');
|
||||
$('#userUsernameRow').prop('hidden', mode !== 'add');
|
||||
$('#userName').val(isNew ? '' : account.name);
|
||||
$('#userEmail').val(isNew ? '' : account.email).prop('required', invite);
|
||||
$('#userRole').val(isNew ? 'editor' : account.role);
|
||||
$('#userPassword').val('').prop('required', mode === 'add');
|
||||
$('#userPasswordRow').prop('hidden', invite);
|
||||
$('#userPasswordNote').text(mode === 'edit' ? '(leave blank to keep the current password; 12 to 72 characters)' : '(12 to 72 characters)');
|
||||
$('#userConfirm').val('');
|
||||
$('#userSubmit').text(invite ? 'Create invitation' : 'Save user');
|
||||
$('#userName').trigger('focus');
|
||||
}
|
||||
|
||||
async function saveUserForm(event) {
|
||||
event.preventDefault();
|
||||
const mode = $('#userForm').data('mode');
|
||||
const invite = mode === 'invite' || mode === 'reinvite';
|
||||
const data = {existing: $('#userExisting').val(), name: $('#userName').val(), email: $('#userEmail').val(), role: $('#userRole').val(), confirm_password: $('#userConfirm').val()};
|
||||
if (!invite) { data.password = $('#userPassword').val(); if (mode === 'add') data.username = $('#userUsername').val(); }
|
||||
try {
|
||||
const result = await api(invite ? 'inviteUser' : 'saveUser', data, 'POST');
|
||||
$('#userForm').prop('hidden', true);
|
||||
$('#userPassword, #userConfirm').val('');
|
||||
if (invite) {
|
||||
$('#inviteLink').val(location.origin + result.link);
|
||||
$('#inviteLogin').text(result.username);
|
||||
$('#inviteResult').prop('hidden', false);
|
||||
}
|
||||
showMessage(result.message, 'success');
|
||||
await loadUsers();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Ask for the administrator's own password inline; resolves to the password, or null when cancelled. */
|
||||
function askPassword(title) {
|
||||
return new Promise(function (resolve) {
|
||||
$('#userForm, #inviteResult').prop('hidden', true);
|
||||
$('#confirmTitle').text(title);
|
||||
$('#confirmPassword').val('');
|
||||
$('#confirmBox').prop('hidden', false).off('submit.ask').on('submit.ask', function (event) {
|
||||
event.preventDefault();
|
||||
const value = $('#confirmPassword').val();
|
||||
$('#confirmPassword').val('');
|
||||
$('#confirmBox').prop('hidden', true);
|
||||
resolve(value);
|
||||
});
|
||||
$('#confirmCancel').off('click.ask').on('click.ask', function () { $('#confirmPassword').val(''); $('#confirmBox').prop('hidden', true); resolve(null); });
|
||||
$('#confirmPassword').trigger('focus');
|
||||
});
|
||||
}
|
||||
|
||||
async function blockUser(account) {
|
||||
const blocking = account.status !== 'blocked';
|
||||
const password = await askPassword((blocking ? 'Block ' : 'Unblock ') + account.username + '? Confirm with your password');
|
||||
if (password === null) return;
|
||||
try {
|
||||
showMessage((await api('blockUser', {username: account.username, blocked: blocking ? '1' : '0', confirm_password: password}, 'POST')).message, 'success');
|
||||
await loadUsers();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteUser(account) {
|
||||
if (!window.confirm('Delete ' + account.username + '? This cannot be undone.')) return;
|
||||
const password = await askPassword('Delete ' + account.username + '? Confirm with your password');
|
||||
if (password === null) return;
|
||||
try {
|
||||
showMessage((await api('deleteUser', {username: account.username, confirm_password: password}, 'POST')).message, 'success');
|
||||
await loadUsers();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Wire the Users dialogue forms once. */
|
||||
function bindUsers() {
|
||||
$('#profileForm').on('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await api('saveProfile', {name: $('#profileName').val(), email: $('#profileEmail').val(), current_password: $('#profileCurrent').val()}, 'POST');
|
||||
$('#profileCurrent').val('');
|
||||
$('#whoami').text(result.name);
|
||||
showMessage(result.message, 'success');
|
||||
await loadUsers();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
});
|
||||
$('#passwordForm').on('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await api('changePassword', {current_password: $('#pwCurrent').val(), new_password: $('#pwNew').val(), confirm_password: $('#pwConfirm').val()}, 'POST');
|
||||
$('#pwCurrent, #pwNew, #pwConfirm').val('');
|
||||
showMessage(result.message, 'success');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
});
|
||||
$('#userAddButton').on('click', function () { showUserForm('add'); });
|
||||
$('#userInviteButton').on('click', function () { showUserForm('invite'); });
|
||||
$('#userCancel').on('click', function () { $('#userForm').prop('hidden', true); $('#userPassword, #userConfirm').val(''); });
|
||||
$('#userForm').on('submit', saveUserForm);
|
||||
$('#inviteCopy').on('click', function () {
|
||||
const field = $('#inviteLink')[0];
|
||||
field.select();
|
||||
(navigator.clipboard ? navigator.clipboard.writeText(field.value) : Promise.reject()).then(function () { showMessage('Invitation link copied.', 'success'); }, function () { document.execCommand('copy'); showMessage('Invitation link selected; press Ctrl+C to copy.', 'success'); });
|
||||
});
|
||||
}
|
||||
|
||||
/** Load named navigation menus and their editable line-based representation. */
|
||||
async function openMenus() {
|
||||
try {
|
||||
const menus = await api('menus');
|
||||
const names = Object.keys(menus);
|
||||
const list = $('#menuList').empty();
|
||||
$('#menuForm').prop('hidden', true);
|
||||
if (!names.length) {
|
||||
$('<p class="empty-state">').text('No menus yet. Use the form below to create one.').appendTo(list);
|
||||
$('<p class="empty-state">').text('No menus yet. Use "Scan site for new navigation" to find them.').appendTo(list);
|
||||
} else {
|
||||
const body = dataTable(list, ['Menu', 'Items', 'Last updated', 'Actions']);
|
||||
names.forEach(function (name) {
|
||||
const load = function () {
|
||||
$('#menuName').val(name);
|
||||
$('#menuKey').text(name);
|
||||
$('#menuTitle').val(menus[name].title || '');
|
||||
$('#menuForm').prop('hidden', false);
|
||||
$('#menuItems').val(menus[name].items.map(function (item) { return item.label + ' | ' + item.url + (item.parent ? ' | ' + item.parent : ''); }).join('\n'));
|
||||
};
|
||||
const row = $('<tr class="page-row">').on('click', load);
|
||||
$('<td class="page-cell">').append(docIcon(), $('<strong>').text(name)).appendTo(row);
|
||||
const cell = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(menus[name].title || name)).appendTo(row);
|
||||
if (menus[name].title) $('<span class="page-sub">').text(name).appendTo(cell);
|
||||
row.append($('<td>').text(menus[name].items.length), $('<td class="page-date">').text(formatDate(menus[name].updated)),
|
||||
$('<td class="row-actions">').append(rowButton('Edit', load)));
|
||||
$('<td class="row-actions">').append(rowButton('Edit', load), rowButton('Delete', function () { deleteMenu(name); }, true)));
|
||||
body.append(row);
|
||||
});
|
||||
}
|
||||
@@ -926,6 +1283,48 @@
|
||||
}
|
||||
|
||||
/** Parse menu lines, save the structure, and optionally insert its rendered navigation. */
|
||||
/** Find navigation blocks no menu is attached to yet (for example, one a developer added to a page) and offer to expose them. */
|
||||
async function scanForNavigation() {
|
||||
const button = $('#menuScanButton').prop('disabled', true).text('Scanning...');
|
||||
try {
|
||||
const listing = await api('listSitePages');
|
||||
const found = {};
|
||||
for (let i = 0; i < listing.pages.length; i += SCAN_BATCH) {
|
||||
const batch = listing.pages.slice(i, i + SCAN_BATCH);
|
||||
button.text('Scanning ' + Math.min(i + SCAN_BATCH, listing.pages.length) + ' of ' + listing.pages.length + '...');
|
||||
(await api('analyseSitePages', {uris: JSON.stringify(batch), allNavs: 1})).pages.forEach(function (page) {
|
||||
page.navs.filter(function (nav) { return !nav.tagged; }).forEach(function (nav) {
|
||||
const entry = found[nav.menu] = found[nav.menu] || {links: nav.links, pages: []};
|
||||
entry.pages.push(page.uri);
|
||||
});
|
||||
});
|
||||
}
|
||||
const names = Object.keys(found);
|
||||
if (!names.length) { showMessage('No new navigation found. Every navigation block is already a menu.', 'success'); return; }
|
||||
const known = await api('menus');
|
||||
const summary = names.map(function (name) {
|
||||
return '- ' + name + ': ' + found[name].links + ' links on ' + found[name].pages.length + ' page(s)' + (known[name] ? ' (joins the existing menu)' : ' (new menu)');
|
||||
}).join('\n');
|
||||
if (!window.confirm('New navigation found:\n' + summary + '\n\nAdd these to Navigation Menus? Each changed page keeps a revision.')) return;
|
||||
const pages = Array.from(new Set([].concat.apply([], names.map(function (name) { return found[name].pages; }))));
|
||||
for (let i = 0; i < pages.length; i += APPLY_BATCH) {
|
||||
await api('applySiteTagging', {uris: JSON.stringify(pages.slice(i, i + APPLY_BATCH)), options: JSON.stringify({menus: true, allNavs: true})}, 'POST');
|
||||
}
|
||||
showMessage('Added ' + names.length + ' menu(s) from ' + pages.length + ' page(s).', 'success');
|
||||
if (!dirty) document.getElementById('frameContainer').contentWindow.location.reload();
|
||||
openMenus();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
finally { button.prop('disabled', false).text('Scan site for new navigation'); }
|
||||
}
|
||||
|
||||
async function deleteMenu(name) {
|
||||
if (!window.confirm("Delete the menu '" + name + "'? Pages keep the navigation they already have, but it will no longer update from this menu.")) return;
|
||||
try {
|
||||
showMessage((await api('deleteMenu', {name: name}, 'POST')).message, 'success');
|
||||
openMenus();
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
async function saveMenu(event) {
|
||||
event.preventDefault();
|
||||
const items = $('#menuItems').val().split('\n').map(function (line) {
|
||||
@@ -933,7 +1332,7 @@
|
||||
return {label: (parts[0] || '').trim(), url: (parts[1] || '').trim(), parent: (parts[2] || '').trim()};
|
||||
}).filter(function (item) { return item.url; });
|
||||
try {
|
||||
const result = await api('saveMenu', {name: $('#menuName').val(), items: JSON.stringify(items)}, 'POST');
|
||||
const result = await api('saveMenu', {name: $('#menuName').val(), title: $('#menuTitle').val(), items: JSON.stringify(items)}, 'POST');
|
||||
if (currentElement && window.confirm('Insert this menu into the currently selected editable region?')) {
|
||||
currentElement.html(result.html);
|
||||
await commitEdit(result.message + ' This page was also saved as a draft.');
|
||||
@@ -987,6 +1386,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
if ((dashboard.deleted || []).length) {
|
||||
$('<h3 class="dialog-section">').text('Deleted pages').appendTo(content);
|
||||
const body = dataTable(content, ['Page', 'Deleted', 'By', 'Actions']);
|
||||
dashboard.deleted.forEach(function (item) {
|
||||
body.append($('<tr>').append($('<td class="page-cell">').append(docIcon(), document.createTextNode(item.uri)), $('<td class="page-date">').text(formatDate(item.created)),
|
||||
$('<td>').text(item.user || ''), $('<td class="row-actions">').append(rowButton('Recover', function () { recoverPage(item); }))));
|
||||
});
|
||||
}
|
||||
|
||||
$('<h3 class="dialog-section">').text('Recent activity').appendTo(content);
|
||||
if (!dashboard.activity.length) {
|
||||
$('<p class="empty-state">').text('No activity has been recorded yet.').appendTo(content);
|
||||
@@ -997,6 +1405,10 @@
|
||||
$('<td>').text(entry.action), $('<td class="page-title">').text(entry.target)));
|
||||
});
|
||||
}
|
||||
if ((dashboard.notices || []).length) {
|
||||
$('<h3 class="dialog-section important-note">').text('Recommendations').appendTo(content);
|
||||
dashboard.notices.forEach(function (notice) { $('<p class="scan-note important-note">').text(notice).appendTo(content); });
|
||||
}
|
||||
if (dashboard.problems.length) {
|
||||
$('<h3 class="dialog-section">').text('System problems').appendTo(content);
|
||||
dashboard.problems.forEach(function (problem) { $('<div class="issue">').text(problem).appendTo(content); });
|
||||
@@ -1005,6 +1417,17 @@
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Recreate a deleted page from its snapshot, then open it. */
|
||||
async function recoverPage(item) {
|
||||
if (!window.confirm('Recover ' + item.uri + '? It will be published again as it was when deleted.')) return;
|
||||
try {
|
||||
const result = await api('restoreRevision', {id: item.id}, 'POST');
|
||||
$('#dashboardDialog').dialog('close');
|
||||
navigateTo(result.url);
|
||||
showMessage('Page recovered: ' + result.url, 'success');
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
/** Cancel one queued publication and refresh the dashboard. */
|
||||
async function cancelSchedule(id) {
|
||||
try { showMessage((await api('cancelSchedule', {id: id}, 'POST')).message, 'success'); loadDashboard(true); } catch (error) { showMessage(error.message, 'error'); }
|
||||
|
||||
+50
-7
@@ -8,17 +8,23 @@ use NeoCMS\Authentication;
|
||||
use NeoCMS\LoginRateLimiter;
|
||||
use NeoCMS\Logger;
|
||||
use NeoCMS\SecurityHeaders;
|
||||
use NeoCMS\UserStore;
|
||||
|
||||
// Keep one escaped, user-facing error message for the rendered form.
|
||||
$error = '';
|
||||
|
||||
// Authentication owns the session; the logger records successful and failed attempts.
|
||||
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
|
||||
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
|
||||
$logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
|
||||
$dataDirectory = (string) ($config['dataDirectory'] ?? (__DIR__ . '/../data'));
|
||||
$rateLimiter = new LoginRateLimiter($dataDirectory, $config['security'] ?? []);
|
||||
SecurityHeaders::html(false, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
|
||||
|
||||
// An invitation link (?invite=TOKEN) shows a set-password form instead of the login form.
|
||||
$users = UserStore::fromConfig($config);
|
||||
$invite = is_string($_POST['invite'] ?? null) ? $_POST['invite'] : (is_string($_GET['invite'] ?? null) ? $_GET['invite'] : '');
|
||||
$invite = preg_match('/^[a-f0-9]{64}$/', $invite) ? $invite : '';
|
||||
|
||||
// GET displays the form, while POST validates its token and submitted credentials.
|
||||
$maxLoginRequestBytes = 16 * 1024;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $maxLoginRequestBytes) {
|
||||
@@ -33,6 +39,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
'anonymous'
|
||||
);
|
||||
$error = "Invalid request token";
|
||||
} elseif ($invite !== '' && isset($_POST['invite'])) {
|
||||
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
$confirm = is_string($_POST['confirm'] ?? null) ? $_POST['confirm'] : '';
|
||||
if (($retryAfter = $rateLimiter->retryAfter($address, 'invite')) > 0) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
$error = 'Too many attempts. Please try again later.';
|
||||
} elseif (!hash_equals($password, $confirm)) {
|
||||
$error = 'The passwords do not match';
|
||||
} else {
|
||||
try {
|
||||
$username = $users->acceptInvite($invite, $password);
|
||||
$rateLimiter->clear($address, 'invite');
|
||||
$authentication = new Authentication(...UserStore::authArgs($config));
|
||||
$authentication->login($username, $password);
|
||||
$logger->write("Invitation accepted for {$username} from {$address}", $username);
|
||||
header('Location: ' . $config['basePath'] . '/cms/');
|
||||
exit;
|
||||
} catch (\RuntimeException $exception) {
|
||||
$rateLimiter->recordFailure($address, 'invite');
|
||||
$logger->write('Invitation rejected from ' . $address, 'anonymous');
|
||||
$error = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$username = is_string($_POST['username'] ?? null) ? trim($_POST['username']) : '';
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
@@ -40,7 +71,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
|
||||
if (strlen($username) > 128 || strlen($password) > 4096) {
|
||||
http_response_code(400);
|
||||
$error = 'Invalid username or password';
|
||||
$error = 'Invalid login name or password';
|
||||
} elseif (($retryAfter = $rateLimiter->retryAfter($address, $username)) > 0) {
|
||||
http_response_code(429);
|
||||
header('Retry-After: ' . $retryAfter);
|
||||
@@ -48,6 +79,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
$error = 'Too many login attempts. Please try again later.';
|
||||
} elseif ($authentication->login($username, $password)) {
|
||||
$rateLimiter->clear($address, $username);
|
||||
$users->rehashIfNeeded($username, $password);
|
||||
|
||||
// Record the source address before entering the privileged interface.
|
||||
$logger->write(
|
||||
@@ -66,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
$username
|
||||
);
|
||||
|
||||
$error = "Invalid username or password";
|
||||
$error = "Invalid login name or password";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,20 +114,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
|
||||
<!-- The compact login card is intentionally independent of the heavier administration UI. -->
|
||||
<div class="login-container">
|
||||
<img class="logo" src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/img/loginlogo.png" alt="NeoCMS logo"/>
|
||||
<h2>NeoCMS Login</h2>
|
||||
<h2><?php echo $invite !== '' ? 'Set your password' : 'NeoCMS Login'; ?></h2>
|
||||
<?php if (!empty($error)): ?>
|
||||
<div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
|
||||
<?php endif; ?>
|
||||
<!-- CSRF protection applies to login too, preventing forced authentication state changes. -->
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($authentication->getCsrfToken(), ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
<?php if ($invite !== ''): ?>
|
||||
<input type="hidden" name="invite" value="<?php echo htmlspecialchars($invite, ENT_QUOTES, 'UTF-8'); ?>">
|
||||
<label for="password">New password (12 to 72 characters)</label>
|
||||
<input type="password" id="password" name="password" required minlength="12" maxlength="72" autocomplete="new-password" autofocus>
|
||||
|
||||
<label for="confirm">Confirm password</label>
|
||||
<input type="password" id="confirm" name="confirm" required minlength="12" maxlength="72" autocomplete="new-password">
|
||||
|
||||
<button type="submit">Set password and sign in</button>
|
||||
<?php else: ?>
|
||||
<label for="username">Login name</label>
|
||||
<input type="text" id="username" name="username" required autofocus autocomplete="username">
|
||||
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||
|
||||
<button type="submit">Log In</button>
|
||||
<?php endif; ?>
|
||||
<p class="shamelessPlug">NeoCMS ©<?php echo date('Y'); ?> Andy Dixon</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ class Authentication
|
||||
/** Map of usernames to password hashes. */
|
||||
private array $credentials;
|
||||
|
||||
/** Map of usernames to editor, publisher, or administrator roles. */
|
||||
/** Map of usernames to editor or administrator roles (the legacy publisher role counts as editor). */
|
||||
private array $roles;
|
||||
|
||||
/** Maximum permitted inactivity for an authenticated session. */
|
||||
@@ -91,10 +91,7 @@ class Authentication
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['loggedIn'] = true;
|
||||
$_SESSION['loggedInUser'] = $username;
|
||||
$configuredRole = $this->roles[$username] ?? 'editor';
|
||||
$_SESSION['role'] = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
|
||||
? $configuredRole
|
||||
: 'editor';
|
||||
$_SESSION['role'] = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
|
||||
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
|
||||
$_SESSION['authenticatedAt'] = time();
|
||||
@@ -131,10 +128,7 @@ class Authentication
|
||||
public function getRole(): string
|
||||
{
|
||||
$username = $this->getLoggedInUser();
|
||||
$configuredRole = $this->roles[$username] ?? 'editor';
|
||||
$role = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
|
||||
? $configuredRole
|
||||
: 'editor';
|
||||
$role = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
|
||||
$_SESSION['role'] = $role;
|
||||
return $role;
|
||||
}
|
||||
@@ -147,14 +141,29 @@ class Authentication
|
||||
public function can(string $capability): bool
|
||||
{
|
||||
$permissions = [
|
||||
'editor' => ['draft', 'upload'],
|
||||
'publisher' => ['draft', 'upload', 'publish', 'schedule'],
|
||||
'editor' => ['draft', 'upload', 'publish', 'schedule'],
|
||||
'administrator' => ['draft', 'upload', 'publish', 'schedule', 'manage'],
|
||||
];
|
||||
|
||||
return in_array($capability, $permissions[$this->getRole()] ?? [], true);
|
||||
}
|
||||
|
||||
/** Re-bind the current session to a changed credential so the user stays signed in while their other sessions end. */
|
||||
public function renewSession(string $credentialHash): void
|
||||
{
|
||||
$_SESSION['credentialFingerprint'] = hash('sha256', $credentialHash);
|
||||
if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
$_SESSION['lastRegeneratedAt'] = time();
|
||||
}
|
||||
|
||||
/** Verify the signed-in user's password (for re-authentication before sensitive actions). */
|
||||
public function verifyCurrentPassword(string $password): bool
|
||||
{
|
||||
return $this->credentialsAreValid($this->getLoggedInUser(), $password);
|
||||
}
|
||||
|
||||
/** Return the session's CSRF token, generating a cryptographically random token when absent. */
|
||||
public function getCsrfToken(): string
|
||||
{
|
||||
|
||||
@@ -45,15 +45,19 @@ final class CMSController
|
||||
/** Maximum aggregate revision storage. */
|
||||
private int $maxRevisionBytes;
|
||||
|
||||
/** Account registry (config accounts plus CMS-managed accounts). */
|
||||
private UserStore $users;
|
||||
|
||||
/** Build the controller services and normalise all configured filesystem paths. */
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
|
||||
$this->authentication = new Authentication(...UserStore::authArgs($config));
|
||||
$this->logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
|
||||
// A custom data directory is primarily useful for tests and hardened deployments.
|
||||
$dataDirectory = $config['dataDirectory'] ?? (__DIR__ . '/../../data');
|
||||
$this->store = new FileStore((string) $dataDirectory);
|
||||
$this->users = new UserStore($this->store, $config);
|
||||
$this->activity = new Activity($this->store, $this->logger);
|
||||
$templatesDir = realpath(__DIR__ . '/../../templates');
|
||||
$documentRoot = realpath((string) ($config['siteRoot'] ?? $_SERVER['DOCUMENT_ROOT'] ?? ''));
|
||||
@@ -147,6 +151,14 @@ final class CMSController
|
||||
$this->requirePost('draft');
|
||||
$uri = $this->requiredPost('uri');
|
||||
$content = $this->requiredContentPost('content');
|
||||
$this->storeDraft($uri, $content);
|
||||
$this->activity('Saved draft', $uri);
|
||||
$this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]);
|
||||
}
|
||||
|
||||
/** Write a private draft (within the draft quota) and record it in the drafts index. */
|
||||
private function storeDraft(string $uri, string $content): void
|
||||
{
|
||||
$draftName = hash('sha256', $this->paths->normaliseUri($uri)) . '.html';
|
||||
$draftPath = $this->store->directory('drafts') . $draftName;
|
||||
$draftBytes = $this->directoryBytes('drafts') - (is_file($draftPath) ? (int) filesize($draftPath) : 0);
|
||||
@@ -161,8 +173,6 @@ final class CMSController
|
||||
$drafts[$draftUri] = $entry;
|
||||
return $drafts;
|
||||
});
|
||||
$this->activity('Saved draft', $uri);
|
||||
$this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]);
|
||||
}
|
||||
|
||||
/** Return the saved draft for one URI, if present. */
|
||||
@@ -179,7 +189,7 @@ final class CMSController
|
||||
$this->requirePost('schedule');
|
||||
$uri = $this->requiredPost('uri');
|
||||
$content = $this->requiredContentPost('content');
|
||||
$this->paths->existing($uri);
|
||||
$this->assertPublishable($uri);
|
||||
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
|
||||
if ($publishAt <= new \DateTimeImmutable()) {
|
||||
throw new \RuntimeException('Publish time must be in the future');
|
||||
@@ -225,35 +235,211 @@ final class CMSController
|
||||
$templates[] = ['id' => $file, 'name' => pathinfo($file, PATHINFO_FILENAME)];
|
||||
}
|
||||
}
|
||||
// Pages an administrator has marked as templates are offered too.
|
||||
foreach (array_keys($this->store->read('pagetemplates')) as $uri) {
|
||||
try {
|
||||
$html = (string) file_get_contents($this->paths->existing($uri));
|
||||
} catch (PageNotFoundException) {
|
||||
continue;
|
||||
}
|
||||
$templates[] = ['id' => 'page:' . $uri, 'name' => $this->extractTitle($html) ?: $uri, 'detail' => $uri];
|
||||
}
|
||||
$this->respond($templates);
|
||||
}
|
||||
|
||||
/** Create a new page from a selected template; administrators only. */
|
||||
/** Delete a template file, or stop using a page as a template (the page itself is kept); administrators only. */
|
||||
private function deleteTemplateAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$template = $this->requiredPost('template');
|
||||
if (str_starts_with($template, 'page:')) {
|
||||
$uri = $this->paths->normaliseUri(substr($template, 5));
|
||||
$this->store->update('pagetemplates', function (array $marked) use ($uri) {
|
||||
unset($marked[$uri]);
|
||||
return $marked;
|
||||
});
|
||||
$this->activity('Unmarked page as template', $uri);
|
||||
$this->respond(['message' => 'Page is no longer a template']);
|
||||
return;
|
||||
}
|
||||
$file = basename($template);
|
||||
$path = realpath($this->templatesDir . $file);
|
||||
if (!$path || !str_starts_with($path, $this->templatesDir) || !preg_match('/\.html?$/i', $file) || !unlink($path)) {
|
||||
throw new \RuntimeException('Template not found');
|
||||
}
|
||||
$this->activity('Deleted template', $file);
|
||||
$this->respond(['message' => 'Template deleted']);
|
||||
}
|
||||
|
||||
/** Mark a page as a template for New Page, or remove the mark; administrators only. */
|
||||
private function setPageTemplateAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
|
||||
$this->paths->existing($uri);
|
||||
$on = ($_POST['value'] ?? '') === '1';
|
||||
$this->store->update('pagetemplates', function (array $marked) use ($uri, $on) {
|
||||
if ($on) {
|
||||
$marked[$uri] = ['user' => $this->user(), 'created' => date(DATE_ATOM)];
|
||||
} else {
|
||||
unset($marked[$uri]);
|
||||
}
|
||||
return $marked;
|
||||
});
|
||||
$this->activity($on ? 'Marked page as template' : 'Unmarked page as template', $uri);
|
||||
$this->respond(['message' => $on ? 'Template created. It is now available in New Page.' : 'Page is no longer a template']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new page from a template. Nothing is written to the site: the page exists only as a private draft
|
||||
* and a pending record until it is first published, so it is not visible on the front end before then.
|
||||
*/
|
||||
private function newPageAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$uri = $this->paths->normaliseNewUri($this->requiredPost('filename'));
|
||||
$template = basename($this->requiredPost('template'));
|
||||
$source = realpath($this->templatesDir . $template);
|
||||
if (!$source || !str_starts_with($source, $this->templatesDir)) {
|
||||
throw new \RuntimeException('Template not found');
|
||||
$name = mb_substr(trim((string) preg_replace('/\s+/u', ' ', $this->requiredPost('name'))), 0, 80);
|
||||
if ($name === '') {
|
||||
throw new \RuntimeException('A page name is required');
|
||||
}
|
||||
if (count($this->paths->files()) >= $this->maxManagedPages) {
|
||||
$template = $this->requiredPost('template');
|
||||
if (str_starts_with($template, 'page:')) {
|
||||
// A page marked as a template: it must still be marked, and still exist.
|
||||
$templateUri = $this->paths->normaliseUri(substr($template, 5));
|
||||
if (!isset($this->store->read('pagetemplates')[$templateUri])) {
|
||||
throw new \RuntimeException('Template not found');
|
||||
}
|
||||
$source = $this->paths->existing($templateUri);
|
||||
} else {
|
||||
$template = basename($template);
|
||||
$source = realpath($this->templatesDir . $template);
|
||||
if (!$source || !str_starts_with($source, $this->templatesDir)) {
|
||||
throw new \RuntimeException('Template not found');
|
||||
}
|
||||
}
|
||||
$menu = trim((string) ($_POST['menu'] ?? ''));
|
||||
if ($menu !== '' && !isset($this->store->read('menus')[$menu])) {
|
||||
throw new \RuntimeException('Navigation group not found');
|
||||
}
|
||||
if (count($this->paths->files()) + count($this->store->read('newpages')) >= $this->maxManagedPages) {
|
||||
throw new \RuntimeException('Managed page limit has been reached');
|
||||
}
|
||||
$destination = $this->paths->newPath($uri);
|
||||
if (file_exists($destination)) {
|
||||
throw new \RuntimeException('Page already exists');
|
||||
}
|
||||
$this->ensureParentDirectory($destination);
|
||||
$this->paths->assertNoSymlinks($destination);
|
||||
if (!copy($source, $destination)) {
|
||||
throw new \RuntimeException('Template copy failed');
|
||||
}
|
||||
$this->activity('Created page', $uri);
|
||||
$this->respond(['message' => 'Page created', 'url' => $uri]);
|
||||
$uri = $this->uniqueUri($name);
|
||||
$html = (string) file_get_contents($source);
|
||||
$safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$html = (string) preg_replace_callback('#(<title\b[^>]*>).*?(</title>)#is', fn(array $m) => $m[1] . $safeName . $m[2], $html, 1);
|
||||
$this->storeDraft($uri, $html);
|
||||
$entry = ['title' => $name, 'menu' => $menu, 'template' => $template, 'user' => $this->user(), 'created' => date(DATE_ATOM)];
|
||||
$this->store->update('newpages', function (array $pending) use ($uri, $entry) {
|
||||
$pending[$uri] = $entry;
|
||||
return $pending;
|
||||
});
|
||||
$this->activity('Created page (unpublished)', $uri);
|
||||
$this->respond(['message' => 'Page created as a draft. It is not visible on the site until you publish it.', 'url' => $uri]);
|
||||
}
|
||||
|
||||
/** A filename derived from the page name (about-us.html), numbered when a page or pending page already uses it. */
|
||||
private function uniqueUri(string $name): string
|
||||
{
|
||||
$ascii = function_exists('iconv') ? (string) @iconv('UTF-8', 'ASCII//TRANSLIT', $name) : $name;
|
||||
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($ascii)), '-') ?: 'page';
|
||||
$pending = $this->store->read('newpages');
|
||||
for ($n = 1; $n < 1000; $n++) {
|
||||
$uri = $this->paths->normaliseNewUri('/' . $slug . ($n > 1 ? '-' . $n : '') . '.html');
|
||||
if (!isset($pending[$uri]) && !file_exists($this->paths->newPath($uri))) {
|
||||
return $uri;
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Could not find a free filename for this page');
|
||||
}
|
||||
|
||||
/** The pending record for a page that has been created but never published, or null. */
|
||||
private function pendingPage(string $uri): ?array
|
||||
{
|
||||
return $this->store->read('newpages')[$this->paths->normaliseUri($uri)] ?? null;
|
||||
}
|
||||
|
||||
/** Whether a page can be published or scheduled: it exists, or it is a new page waiting for its first publication. */
|
||||
private function assertPublishable(string $uri): void
|
||||
{
|
||||
try {
|
||||
$this->paths->existing($uri);
|
||||
} catch (PageNotFoundException $exception) {
|
||||
if (!$this->pendingPage($uri)) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw away a new page that was never published: its pending record, draft and any scheduled publication. */
|
||||
private function discardNewPageAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
|
||||
if (!$this->pendingPage($uri)) {
|
||||
throw new \RuntimeException('Unpublished page not found');
|
||||
}
|
||||
$this->store->update('newpages', function (array $pending) use ($uri) {
|
||||
unset($pending[$uri]);
|
||||
return $pending;
|
||||
});
|
||||
$this->deleteDraft($uri);
|
||||
$staged = [];
|
||||
$this->store->update('schedules', function (array $jobs) use ($uri, &$staged) {
|
||||
foreach ($jobs as $id => $job) {
|
||||
if (($job['uri'] ?? '') === $uri) {
|
||||
$staged[] = (string) $id;
|
||||
unset($jobs[$id]);
|
||||
}
|
||||
}
|
||||
return $jobs;
|
||||
});
|
||||
foreach ($staged as $id) {
|
||||
@unlink($this->store->directory('scheduled') . basename($id) . '.html');
|
||||
}
|
||||
$this->activity('Discarded unpublished page', $uri);
|
||||
$this->respond(['message' => 'Unpublished page discarded']);
|
||||
}
|
||||
|
||||
/** After a new page's first publication: add its link to the chosen menu on every page, then forget the pending record. */
|
||||
private function finishNewPage(string $uri): void
|
||||
{
|
||||
$uri = $this->paths->normaliseUri($uri);
|
||||
$pending = $this->pendingPage($uri);
|
||||
if (!$pending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$menu = (string) ($pending['menu'] ?? '');
|
||||
$items = null;
|
||||
if ($menu !== '') {
|
||||
$url = (string) ($this->config['basePath'] ?? '') . $uri;
|
||||
$this->store->update('menus', function (array $menus) use ($menu, $url, $pending, &$items) {
|
||||
if (!isset($menus[$menu])) {
|
||||
return $menus;
|
||||
}
|
||||
$menus[$menu]['items'] = $menus[$menu]['items'] ?? [];
|
||||
if (!in_array($url, array_column($menus[$menu]['items'], 'url'), true)) {
|
||||
$menus[$menu]['items'][] = ['label' => (string) $pending['title'], 'url' => $url, 'parent' => ''];
|
||||
$menus[$menu]['updated'] = date(DATE_ATOM);
|
||||
}
|
||||
$items = $menus[$menu]['items'];
|
||||
return $menus;
|
||||
});
|
||||
if ($items !== null) {
|
||||
$this->propagateMenu($menu, $items);
|
||||
$this->activity('Added page to menu', $uri . ' -> ' . $menu);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
// The page is already live; a menu problem is reported in the log rather than failing the publish.
|
||||
$this->logger->write("Adding {$uri} to its menu failed: {$exception->getMessage()}", $this->user());
|
||||
}
|
||||
$this->store->update('newpages', function (array $all) use ($uri) {
|
||||
unset($all[$uri]);
|
||||
return $all;
|
||||
});
|
||||
$this->deleteDraft($uri);
|
||||
}
|
||||
/** Rename, duplicate, or revision-then-delete an existing managed page. */
|
||||
private function pageAction(): void
|
||||
{
|
||||
@@ -268,6 +454,10 @@ final class CMSController
|
||||
if (!unlink($source)) {
|
||||
throw new \RuntimeException('Unable to delete page');
|
||||
}
|
||||
$this->store->update('pagetemplates', function (array $marked) use ($sourceUri) {
|
||||
unset($marked[$sourceUri]);
|
||||
return $marked;
|
||||
});
|
||||
$this->activity('Deleted page', $sourceUri);
|
||||
$this->respond(['message' => 'Page deleted']);
|
||||
return;
|
||||
@@ -284,6 +474,15 @@ final class CMSController
|
||||
if (!$ok) {
|
||||
throw new \RuntimeException('Page operation failed');
|
||||
}
|
||||
if ($operation === 'rename') {
|
||||
$this->store->update('pagetemplates', function (array $marked) use ($sourceUri, $targetUri) {
|
||||
if (isset($marked[$sourceUri])) {
|
||||
$marked[$targetUri] = $marked[$sourceUri];
|
||||
unset($marked[$sourceUri]);
|
||||
}
|
||||
return $marked;
|
||||
});
|
||||
}
|
||||
$this->activity(ucfirst($operation) . 'd page', $sourceUri . ' -> ' . $targetUri);
|
||||
$this->respond(['message' => 'Page ' . $operation . 'd', 'url' => $targetUri]);
|
||||
}
|
||||
@@ -307,6 +506,9 @@ final class CMSController
|
||||
'draft' => isset($drafts[$uri]),
|
||||
];
|
||||
}
|
||||
foreach ($this->store->read('newpages') as $uri => $entry) {
|
||||
$pages[] = ['name' => $uri, 'url' => $uri, 'title' => (string) ($entry['title'] ?? ''), 'modified' => $entry['created'] ?? date(DATE_ATOM), 'draft' => true, 'pending' => true];
|
||||
}
|
||||
usort($pages, fn(array $a, array $b) => strcmp($a['name'], $b['name']));
|
||||
$this->respond($pages);
|
||||
}
|
||||
@@ -368,7 +570,8 @@ final class CMSController
|
||||
$this->requireCapability('manage');
|
||||
$uris = $this->uriBatch($this->requiredRequest('uris'));
|
||||
$basePath = (string) ($this->config['basePath'] ?? '');
|
||||
$all = ['content' => true, 'images' => true, 'seo' => true];
|
||||
$allNavs = !empty($_REQUEST['allNavs']);
|
||||
$all = ['content' => true, 'images' => true, 'seo' => true, 'menus' => true, 'allNavs' => $allNavs];
|
||||
$pages = [];
|
||||
$usedUploads = [];
|
||||
foreach ($uris as $uri) {
|
||||
@@ -378,21 +581,21 @@ final class CMSController
|
||||
continue;
|
||||
}
|
||||
$html = (string) file_get_contents($path);
|
||||
$a = SiteAnalyser::analyse($html, $this->editableClass);
|
||||
$a = SiteAnalyser::analyse($html, $this->editableClass, $allNavs);
|
||||
$broken = [];
|
||||
foreach ($a['refs'] as $ref) {
|
||||
if ($this->paths->refExists($path, $ref['url'], $basePath) === false) {
|
||||
$broken[] = $ref['url'];
|
||||
}
|
||||
}
|
||||
if (preg_match_all('#/uploads/([a-f0-9]{32}\.(?:jpg|png|gif|webp))#', $html, $matches)) {
|
||||
if (preg_match_all('#/uploads/(' . MediaTypes::nameRegex() . ')#', $html, $matches)) {
|
||||
array_push($usedUploads, ...$matches[1]);
|
||||
}
|
||||
$plan = SiteAnalyser::apply($html, $this->editableClass, $all);
|
||||
$pages[] = [
|
||||
'uri' => $this->paths->uriFor($path), 'title' => $a['title'], 'editableRegions' => $a['existingRegions'], 'proposed' => $a['proposed'],
|
||||
'images' => count($a['images']), 'missingAlt' => count(array_filter($a['images'], fn($i) => !$i['hasAlt'])),
|
||||
'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
|
||||
'navs' => $a['navs'], 'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
|
||||
'changes' => $plan['changes'] ?? [],
|
||||
];
|
||||
}
|
||||
@@ -420,18 +623,37 @@ final class CMSController
|
||||
if (!is_array($options)) {
|
||||
throw new \RuntimeException('Choose at least one kind of change');
|
||||
}
|
||||
$flags = ['content' => !empty($options['content']), 'images' => !empty($options['images']), 'seo' => !empty($options['seo'])];
|
||||
$flags = ['content' => !empty($options['content']), 'images' => !empty($options['images']), 'seo' => !empty($options['seo']), 'menus' => !empty($options['menus']), 'allNavs' => !empty($options['allNavs'])];
|
||||
if (!array_filter($flags)) {
|
||||
throw new \RuntimeException('Choose at least one kind of change');
|
||||
}
|
||||
$pages = [];
|
||||
$count = $this->rewritePages(function (string $html, string $uri) use ($flags, &$pages) {
|
||||
$seed = [];
|
||||
$basePath = (string) ($this->config['basePath'] ?? '');
|
||||
$count = $this->rewritePages(function (string $html, string $uri) use ($flags, &$pages, &$seed, $basePath) {
|
||||
$result = SiteAnalyser::apply($html, $this->editableClass, $flags);
|
||||
if ($result) {
|
||||
$pages[$uri] = $result['changes'];
|
||||
// The first page seen supplies a menu's items; links become site paths so they work from any folder.
|
||||
foreach ($result['menus'] as $name => $links) {
|
||||
$seed[$name] ??= array_map(function (array $link) use ($uri, $basePath) {
|
||||
$path = ContentDom::resolveLink($link['url'], $uri);
|
||||
return ['label' => $link['label'], 'url' => $path === null ? $link['url'] : $basePath . $path, 'parent' => $link['parent']];
|
||||
}, $links);
|
||||
}
|
||||
}
|
||||
return $result['html'] ?? null;
|
||||
}, 'Before auto-tagging', $uris);
|
||||
if ($seed) {
|
||||
// A menu someone has already saved is never replaced by a scan.
|
||||
$this->store->update('menus', function (array $menus) use ($seed) {
|
||||
foreach ($seed as $name => $items) {
|
||||
$menus[$name] ??= ['items' => $items, 'updated' => date(DATE_ATOM)];
|
||||
}
|
||||
return $menus;
|
||||
});
|
||||
$this->activity('Detected navigation menus', implode(', ', array_keys($seed)));
|
||||
}
|
||||
$this->activity('Auto-tagged pages', $count . ' page(s)');
|
||||
$this->respond(['message' => "Updated {$count} page(s). Each has a revision to restore.", 'updated_pages' => $count, 'pages' => $pages]);
|
||||
}
|
||||
@@ -466,6 +688,24 @@ final class CMSController
|
||||
$this->respond($this->store->read('menus'));
|
||||
}
|
||||
|
||||
/** Remove a saved menu. Pages keep the navigation they already contain; only the stored definition goes. */
|
||||
private function deleteMenuAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$name = preg_replace('/[^a-zA-Z0-9_-]/', '', $this->requiredPost('name'));
|
||||
$found = false;
|
||||
$this->store->update('menus', function (array $menus) use ($name, &$found) {
|
||||
$found = isset($menus[$name]);
|
||||
unset($menus[$name]);
|
||||
return $menus;
|
||||
});
|
||||
if (!$found) {
|
||||
throw new \RuntimeException('Menu not found');
|
||||
}
|
||||
$this->activity('Deleted menu', $name);
|
||||
$this->respond(['message' => "Menu '{$name}' deleted. Pages keep their current navigation."]);
|
||||
}
|
||||
|
||||
/** Validate, save, render, and propagate a named navigation menu. */
|
||||
private function saveMenuAction(): void
|
||||
{
|
||||
@@ -483,12 +723,23 @@ final class CMSController
|
||||
}
|
||||
$clean[] = ['label' => trim((string) ($item['label'] ?? $item['url'])), 'url' => $this->paths->normaliseLink((string) $item['url']), 'parent' => trim((string) ($item['parent'] ?? ''))];
|
||||
}
|
||||
$this->store->update('menus', function (array $menus) use ($name, $clean) {
|
||||
$menus[$name] = ['items' => $clean, 'updated' => date(DATE_ATOM)];
|
||||
// Menus come from navigation in the site's pages; the CMS edits and renames them but never creates them.
|
||||
if (!isset($this->store->read('menus')[$name])) {
|
||||
throw new \RuntimeException("Menu not found. New menus are added to the pages by the web developer, then found with the navigation scan.");
|
||||
}
|
||||
$title = mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 60);
|
||||
$this->store->update('menus', function (array $menus) use ($name, $clean, $title) {
|
||||
$entry = ['items' => $clean, 'updated' => date(DATE_ATOM)] + $menus[$name];
|
||||
if ($title !== '') {
|
||||
$entry['title'] = $title;
|
||||
} else {
|
||||
unset($entry['title']);
|
||||
}
|
||||
$menus[$name] = $entry;
|
||||
return $menus;
|
||||
});
|
||||
$html = ContentDom::renderMenu($name, $clean);
|
||||
$updated = $this->propagateMenu($name, $html);
|
||||
$updated = $this->propagateMenu($name, $clean);
|
||||
$this->activity('Updated menu', $name);
|
||||
$this->respond(['message' => "Menu saved and updated on {$updated} page(s)", 'html' => $html, 'updated_pages' => $updated]);
|
||||
}
|
||||
@@ -510,6 +761,8 @@ final class CMSController
|
||||
'name' => basename($file), 'url' => ($this->config['basePath'] ?? '') . $url, 'size' => filesize($file),
|
||||
'modified' => date(DATE_ATOM, filemtime($file)), 'alt' => $metadata[basename($file)]['alt'] ?? '',
|
||||
'uses' => $usage[$url] ?? 0,
|
||||
'category' => MediaTypes::categoryForName(basename($file)), 'ext' => strtolower(pathinfo($file, PATHINFO_EXTENSION)),
|
||||
'original' => $metadata[basename($file)]['original'] ?? '',
|
||||
];
|
||||
}
|
||||
usort($items, fn(array $a, array $b) => strcmp($b['modified'], $a['modified']));
|
||||
@@ -524,10 +777,10 @@ final class CMSController
|
||||
$alt = is_string($_POST['alt'] ?? null) ? trim($_POST['alt']) : '';
|
||||
$entry = ['alt' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $alt) ?? '', 0, 500)];
|
||||
$this->store->update('media', function (array $metadata) use ($name, $entry) {
|
||||
$metadata[$name] = $entry;
|
||||
$metadata[$name] = $entry + ($metadata[$name] ?? []);
|
||||
return $metadata;
|
||||
});
|
||||
$this->activity('Updated image alt text', $name);
|
||||
$this->activity(MediaTypes::categoryForName($name) === 'imagery' ? 'Updated image alt text' : 'Updated media description', $name);
|
||||
$this->respond(['message' => 'Media details saved']);
|
||||
}
|
||||
|
||||
@@ -555,18 +808,201 @@ final class CMSController
|
||||
$schedules = $this->store->read('schedules');
|
||||
$activity = array_slice(array_reverse($this->store->read('activity')), 0, 20);
|
||||
$problems = [];
|
||||
foreach ([__DIR__ . '/../../data', dirname(__DIR__, 3) . '/uploads'] as $directory) {
|
||||
foreach ([(string) ($this->config['dataDirectory'] ?? (__DIR__ . '/../../data')), dirname(__DIR__, 3) . '/uploads'] as $directory) {
|
||||
if (!is_dir($directory) || !is_writable($directory)) {
|
||||
$problems[] = $directory . ' is not writable';
|
||||
}
|
||||
}
|
||||
$notices = [];
|
||||
$dataPath = realpath($this->config['dataDirectory'] ?? (__DIR__ . '/../../data'));
|
||||
$sitePath = realpath((string) ($this->config['siteRoot'] ?? dirname(__DIR__, 3)));
|
||||
if ($this->authentication->can('manage') && $dataPath !== false && $sitePath !== false
|
||||
&& str_starts_with(str_replace('\\', '/', $dataPath) . '/', rtrim(str_replace('\\', '/', $sitePath), '/') . '/')) {
|
||||
$notices[] = 'Account and page data is stored inside the web root (cms/data). For a live site, set dataDirectory in config.local.php to a folder outside the web root so it can never be served, even if the web server ignores the .htaccess rules.';
|
||||
}
|
||||
$this->respond([
|
||||
'notices' => $notices,
|
||||
'deleted' => $this->authentication->can('publish') ? $this->deletedPages() : [],
|
||||
'user' => $this->user(), 'role' => $this->authentication->getRole(),
|
||||
'permissions' => ['draft' => $this->authentication->can('draft'), 'publish' => $this->authentication->can('publish'), 'schedule' => $this->authentication->can('schedule'), 'manage' => $this->authentication->can('manage')],
|
||||
'drafts' => $drafts, 'schedules' => $schedules, 'activity' => $activity, 'problems' => $problems,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Pages whose newest revision is a "Before delete" snapshot and whose file is gone, newest deletion first. */
|
||||
private function deletedPages(): array
|
||||
{
|
||||
$latest = [];
|
||||
foreach ($this->store->read('revisions') as $revision) {
|
||||
$uri = $revision['uri'] ?? '';
|
||||
if (!isset($latest[$uri]) || self::revisionTime($revision) > self::revisionTime($latest[$uri])) {
|
||||
$latest[$uri] = $revision;
|
||||
}
|
||||
}
|
||||
$deleted = [];
|
||||
foreach ($latest as $uri => $revision) {
|
||||
if (($revision['reason'] ?? '') !== 'Before delete') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$this->paths->existing($uri);
|
||||
} catch (PageNotFoundException) {
|
||||
$deleted[] = $revision;
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
usort($deleted, fn(array $a, array $b) => self::revisionTime($b) <=> self::revisionTime($a));
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/** The signed-in user's account, the role descriptions, and (administrators only) every account. Never includes hashes or tokens. */
|
||||
private function usersAction(): void
|
||||
{
|
||||
$me = $this->user();
|
||||
$profile = $this->users->profile($me);
|
||||
$this->respond([
|
||||
'me' => ['username' => $me, 'name' => $profile['name'], 'email' => $profile['email'], 'role' => $this->authentication->getRole(), 'managed' => $profile['managed']],
|
||||
'roles' => UserStore::ROLE_INFO,
|
||||
'users' => $this->authentication->can('manage') ? $this->users->accounts() : [],
|
||||
]);
|
||||
}
|
||||
|
||||
/** Update the signed-in user's own display name and email; changing the email needs the current password. */
|
||||
private function saveProfileAction(): void
|
||||
{
|
||||
$this->requirePost();
|
||||
$me = $this->user();
|
||||
$current = $this->users->profile($me);
|
||||
$name = $this->requiredPost('name');
|
||||
$email = is_string($_POST['email'] ?? null) ? trim($_POST['email']) : '';
|
||||
if (strtolower($email) !== strtolower($current['email'])) {
|
||||
$this->confirmPassword('current_password');
|
||||
}
|
||||
$this->users->setProfile($me, $name, $email);
|
||||
$this->activity('Updated own account details', $me);
|
||||
$this->respond(['message' => 'Account updated', 'name' => $this->users->profile($me)['name']]);
|
||||
}
|
||||
|
||||
/** Change the signed-in user's own password. The current password is required and other sessions end. */
|
||||
private function changePasswordAction(): void
|
||||
{
|
||||
$this->requirePost();
|
||||
$me = $this->user();
|
||||
if ($this->users->isManaged($me)) {
|
||||
throw new \RuntimeException('This password is managed in config.local.php');
|
||||
}
|
||||
$new = is_string($_POST['new_password'] ?? null) ? $_POST['new_password'] : '';
|
||||
if (!hash_equals($new, is_string($_POST['confirm_password'] ?? null) ? $_POST['confirm_password'] : "\0")) {
|
||||
throw new \RuntimeException('The new password and confirmation do not match');
|
||||
}
|
||||
$this->confirmPassword('current_password');
|
||||
$hash = $this->users->setPassword($me, $new);
|
||||
$this->authentication->renewSession($hash);
|
||||
$this->activity('Changed own password', $me);
|
||||
$this->respond(['message' => 'Password changed. Your other sessions have been signed out.']);
|
||||
}
|
||||
|
||||
/** Add a CMS account with a password, or edit one (blank password keeps it). Administrators only, with password confirmation. */
|
||||
private function saveUserAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$this->confirmPassword('confirm_password');
|
||||
$existing = is_string($_POST['existing'] ?? null) ? trim($_POST['existing']) : '';
|
||||
$name = $this->requiredPost('name');
|
||||
$email = is_string($_POST['email'] ?? null) ? $_POST['email'] : '';
|
||||
$role = $this->requiredPost('role');
|
||||
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
|
||||
if ($existing === '') {
|
||||
$username = $this->requiredPost('username');
|
||||
$this->users->create($username, $name, $email, $role, $password);
|
||||
$this->activity('Added user', $username . ' (' . $role . ')');
|
||||
$this->respond(['message' => 'User added']);
|
||||
return;
|
||||
}
|
||||
$this->refuseSelf($existing);
|
||||
$before = null;
|
||||
foreach ($this->users->accounts() as $account) {
|
||||
if ($account['username'] === $existing) {
|
||||
$before = $account;
|
||||
}
|
||||
}
|
||||
$this->users->update($existing, $name, $email, $role, $password);
|
||||
$this->activity('Updated user', $existing);
|
||||
if ($before && $before['role'] !== $role) {
|
||||
$this->activity('Changed user role', $existing . ': ' . $before['role'] . ' -> ' . $role);
|
||||
}
|
||||
if ($password !== '') {
|
||||
$this->activity('Reset user password', $existing);
|
||||
}
|
||||
$this->respond(['message' => 'User updated']);
|
||||
}
|
||||
|
||||
/** Create or re-issue a one-time invitation link (shown once; only its hash is stored). */
|
||||
private function inviteUserAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$this->confirmPassword('confirm_password');
|
||||
$existing = is_string($_POST['existing'] ?? null) ? trim($_POST['existing']) : '';
|
||||
[$username, $token] = $this->users->invite($this->requiredPost('name'), $this->requiredPost('email'), $this->requiredPost('role'), $existing);
|
||||
$this->activity($existing === '' ? 'Invited user' : 'Re-issued invitation', $username);
|
||||
$this->respond([
|
||||
'message' => 'Invitation created. Copy the link now; it is shown only once and expires in 7 days.',
|
||||
'link' => (string) ($this->config['basePath'] ?? '') . '/cms/login/?invite=' . $token,
|
||||
'username' => $username,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Block or unblock a CMS account; a blocked account is signed out on its next request. */
|
||||
private function blockUserAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$this->confirmPassword('confirm_password');
|
||||
$username = $this->requiredPost('username');
|
||||
$this->refuseSelf($username);
|
||||
$blocked = ($_POST['blocked'] ?? '') === '1';
|
||||
$this->users->setBlocked($username, $blocked);
|
||||
$this->activity($blocked ? 'Blocked user' : 'Unblocked user', $username);
|
||||
$this->respond(['message' => $blocked ? 'User blocked' : 'User unblocked']);
|
||||
}
|
||||
|
||||
/** Delete a CMS account. */
|
||||
private function deleteUserAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$this->confirmPassword('confirm_password');
|
||||
$username = $this->requiredPost('username');
|
||||
$this->refuseSelf($username);
|
||||
$this->users->delete($username);
|
||||
$this->activity('Deleted user', $username);
|
||||
$this->respond(['message' => 'User deleted']);
|
||||
}
|
||||
|
||||
/** Stop administrators locking themselves out through the account-management actions. */
|
||||
private function refuseSelf(string $username): void
|
||||
{
|
||||
if ($username === $this->user()) {
|
||||
throw new \RuntimeException('You cannot do that to your own account. Use My account for your own details.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Require the signed-in user's own password before a sensitive account action; failures are rate limited and audited. */
|
||||
private function confirmPassword(string $field): void
|
||||
{
|
||||
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
$key = 'reauth:' . $this->user();
|
||||
$limiter = new LoginRateLimiter((string) ($this->config['dataDirectory'] ?? (__DIR__ . '/../../data')), $this->config['security'] ?? []);
|
||||
if (($wait = $limiter->retryAfter($address, $key)) > 0) {
|
||||
throw new \RuntimeException("Too many incorrect passwords. Try again in {$wait} seconds.");
|
||||
}
|
||||
$password = is_string($_POST[$field] ?? null) ? $_POST[$field] : '';
|
||||
if ($password === '' || strlen($password) > 4096 || !$this->authentication->verifyCurrentPassword($password)) {
|
||||
$limiter->recordFailure($address, $key);
|
||||
$this->activity('Password confirmation failed', 'users');
|
||||
throw new \RuntimeException('Your password is incorrect');
|
||||
}
|
||||
$limiter->clear($address, $key);
|
||||
}
|
||||
|
||||
/** End the authenticated session after CSRF validation. */
|
||||
private function logoutAction(): void
|
||||
{
|
||||
@@ -584,14 +1020,32 @@ final class CMSController
|
||||
*/
|
||||
private function publishContent(string $uri, string $content, string $reason): void
|
||||
{
|
||||
$path = $this->paths->existing($uri);
|
||||
$old = (string) file_get_contents($path);
|
||||
if ($old !== $content) {
|
||||
$isNew = false;
|
||||
try {
|
||||
$path = $this->paths->existing($uri);
|
||||
} catch (PageNotFoundException $exception) {
|
||||
// A new page has no file until its first publication.
|
||||
if (!$this->pendingPage($uri)) {
|
||||
throw $exception;
|
||||
}
|
||||
if (count($this->paths->files()) >= $this->maxManagedPages) {
|
||||
throw new \RuntimeException('Managed page limit has been reached');
|
||||
}
|
||||
$path = $this->paths->newPath($this->paths->normaliseNewUri($uri));
|
||||
$this->ensureParentDirectory($path);
|
||||
$this->paths->assertNoSymlinks($path);
|
||||
$isNew = true;
|
||||
}
|
||||
$old = $isNew ? '' : (string) file_get_contents($path);
|
||||
if (!$isNew && $old !== $content) {
|
||||
$this->createRevision($uri, $old, $reason);
|
||||
}
|
||||
$this->atomicWrite($path, $content);
|
||||
$this->captureSharedBlocks($content);
|
||||
$this->activity($reason, $uri);
|
||||
if ($isNew) {
|
||||
$this->finishNewPage($uri);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write beside the destination and rename, so a public page is never served half-written. */
|
||||
@@ -764,10 +1218,11 @@ final class CMSController
|
||||
}
|
||||
|
||||
/** Replace matching generated menus across public pages, revisioning each page first. */
|
||||
private function propagateMenu(string $name, string $menuHtml): int
|
||||
private function propagateMenu(string $name, array $items): int
|
||||
{
|
||||
$basePath = (string) ($this->config['basePath'] ?? '');
|
||||
return $this->rewritePages(
|
||||
fn(string $html) => ContentDom::withMenu($html, $name, $menuHtml),
|
||||
fn(string $html, string $uri) => ContentDom::withMenu($html, $name, $items, $uri, $basePath),
|
||||
'Before menu update'
|
||||
);
|
||||
}
|
||||
@@ -830,7 +1285,7 @@ final class CMSController
|
||||
$uses = [];
|
||||
foreach ($this->paths->files() as $path) {
|
||||
$html = (string) file_get_contents($path);
|
||||
if (preg_match_all('#/uploads/[a-f0-9]{32}\.(?:jpg|png|gif|webp)#', $html, $matches)) {
|
||||
if (preg_match_all('#/uploads/' . MediaTypes::nameRegex() . '#', $html, $matches)) {
|
||||
foreach ($matches[0] as $url) {
|
||||
$uses[$url] = ($uses[$url] ?? 0) + 1;
|
||||
}
|
||||
@@ -839,7 +1294,7 @@ final class CMSController
|
||||
return $uses;
|
||||
}
|
||||
|
||||
/** Validate that a filename belongs to the randomised image namespace created by uploads. */
|
||||
/** Validate that a filename belongs to the generated media namespace created by uploads. */
|
||||
private function managedMediaName(string $name): string
|
||||
{
|
||||
$name = basename($name);
|
||||
@@ -849,10 +1304,10 @@ final class CMSController
|
||||
return $name;
|
||||
}
|
||||
|
||||
/** Return whether a filename is a random NeoCMS image name with an allowed extension. */
|
||||
/** Return whether a filename is a NeoCMS-generated media name with an allowed extension. */
|
||||
private function isManagedMediaName(string $name): bool
|
||||
{
|
||||
return preg_match('/^[a-f0-9]{32}\.(?:jpg|png|gif|webp)$/', $name) === 1;
|
||||
return MediaTypes::isManagedName($name);
|
||||
}
|
||||
|
||||
/** Extract a plain-text document title for the page picker. */
|
||||
|
||||
@@ -36,35 +36,50 @@ final class ContentDom
|
||||
return $dom->saveHTML();
|
||||
}
|
||||
|
||||
/** Replace every element marked data-neo-menu="$name" with the rendered <nav>; null when nothing to change. */
|
||||
public static function withMenu(string $html, string $name, string $menuHtml): ?string
|
||||
/**
|
||||
* Replace the list inside every element marked data-neo-menu="$name", keeping the element's own attributes
|
||||
* and every other byte of the page. Null when nothing needs to change.
|
||||
*/
|
||||
public static function withMenu(string $html, string $name, array $items, string $uri = '', string $basePath = ''): ?string
|
||||
{
|
||||
if (!str_contains($html, 'data-neo-menu')) {
|
||||
return null;
|
||||
}
|
||||
$dom = self::load($html);
|
||||
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-menu="' . $name . '"]');
|
||||
$sourceNav = self::load($menuHtml)->getElementsByTagName('nav')->item(0);
|
||||
if ($nodes->length === 0 || !$sourceNav) {
|
||||
return null;
|
||||
$list = self::menuList($items, $uri, $basePath);
|
||||
$flat = fn(string $s) => preg_replace('/>\s+</', '><', trim($s));
|
||||
$edits = [];
|
||||
foreach (SiteAnalyser::navs($html) as $nav) {
|
||||
if ($nav['menu'] !== $name || $nav['close'] === null) {
|
||||
continue;
|
||||
}
|
||||
if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) {
|
||||
$edits[] = [$nav['end'], $nav['close'] - $nav['end']];
|
||||
}
|
||||
}
|
||||
foreach (iterator_to_array($nodes) as $node) {
|
||||
$node->parentNode->replaceChild($dom->importNode($sourceNav, true), $node);
|
||||
rsort($edits);
|
||||
foreach ($edits as [$offset, $length]) {
|
||||
$html = substr($html, 0, $offset) . $list . substr($html, $offset + $length);
|
||||
}
|
||||
return $dom->saveHTML();
|
||||
return $edits ? $html : null;
|
||||
}
|
||||
|
||||
/** A menu as a standalone <nav> (the list itself comes from menuList). */
|
||||
public static function renderMenu(string $name, array $items): string
|
||||
{
|
||||
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . self::menuList($items) . '</nav>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a nested, escaped navigation list from flat parent-labelled items.
|
||||
* Render a nested, escaped list from flat parent-labelled items, marking the link to the page being written.
|
||||
* Circular parent references are stopped by the ancestor list rather than pursued forever.
|
||||
*/
|
||||
public static function renderMenu(string $name, array $items): string
|
||||
public static function menuList(array $items, string $uri = '', string $basePath = ''): string
|
||||
{
|
||||
$children = [];
|
||||
foreach ($items as $item) {
|
||||
$children[$item['parent'] ?? ''][] = $item;
|
||||
}
|
||||
$render = function (string $parent, array $ancestors = []) use (&$render, $children): string {
|
||||
$render = function (string $parent, array $ancestors = []) use (&$render, $children, $uri, $basePath): string {
|
||||
if (empty($children[$parent])) {
|
||||
return '';
|
||||
}
|
||||
@@ -72,14 +87,48 @@ final class ContentDom
|
||||
foreach ($children[$parent] as $item) {
|
||||
$label = htmlspecialchars($item['label'], ENT_QUOTES);
|
||||
$url = htmlspecialchars($item['url'], ENT_QUOTES);
|
||||
$current = $uri !== '' && self::isPage($item['url'], $uri, $basePath) ? ' aria-current="page"' : '';
|
||||
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
|
||||
$html .= '<li><a href="' . $url . '">' . $label . '</a>' . $nested . '</li>';
|
||||
$html .= '<li><a href="' . $url . '"' . $current . '>' . $label . '</a>' . $nested . '</li>';
|
||||
}
|
||||
return $html . '</ul>';
|
||||
};
|
||||
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . $render('') . '</nav>';
|
||||
return $render('');
|
||||
}
|
||||
|
||||
/** Turn a page link into a site path (relative links resolved against the page, query/fragment kept); null for external or fragment-only links. */
|
||||
public static function resolveLink(string $url, string $pageUri): ?string
|
||||
{
|
||||
$url = trim($url);
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
if ($url === '' || $url[0] === '#' || preg_match('#^([a-z][a-z0-9+.-]*:|//)#i', $url) || !is_string($path) || $path === '') {
|
||||
return null;
|
||||
}
|
||||
if ($path[0] !== '/') {
|
||||
$path = rtrim(str_replace('\\', '/', dirname($pageUri)), '/') . '/' . $path;
|
||||
}
|
||||
$out = [];
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
$segment === '..' ? array_pop($out) : $out[] = $segment;
|
||||
}
|
||||
return '/' . implode('/', $out) . (str_ends_with($path, '/') && $out ? '/' : '') . substr($url, strcspn($url, '?#'));
|
||||
}
|
||||
|
||||
/** Whether a menu link points at the page with this URI (a folder link means its index.html; a subfolder install's prefix is ignored). */
|
||||
private static function isPage(string $url, string $uri, string $basePath): bool
|
||||
{
|
||||
$resolved = self::resolveLink($url, $uri);
|
||||
if ($resolved === null) {
|
||||
return false;
|
||||
}
|
||||
$index = fn(string $p) => str_ends_with($p, '/') ? $p . 'index.html' : $p;
|
||||
$path = $index((string) parse_url($resolved, PHP_URL_PATH));
|
||||
$uri = $index($uri);
|
||||
return $path === $uri || ($basePath !== '' && $path === $basePath . $uri);
|
||||
}
|
||||
/** Determine whether a document contains at least one element carrying exactly this CSS class token. */
|
||||
public static function hasClass(string $html, string $class): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* The one list of uploadable media: allowed extensions, their categories, and the safety checks applied to every upload.
|
||||
*
|
||||
* Only extensions listed here are accepted, so executables, scripts, archives, HTML/SVG and macro-enabled Office files are refused
|
||||
* by omission. On top of that, content is sniffed and scanned for embedded code. This is best-effort hardening, not a malware scanner.
|
||||
*/
|
||||
final class MediaTypes
|
||||
{
|
||||
public const CATEGORIES = ['imagery' => 'Imagery', 'documents' => 'Documents', 'video' => 'Video', 'audio' => 'Audio'];
|
||||
|
||||
/** extension => category */
|
||||
private const EXTENSIONS = [
|
||||
'jpg' => 'imagery', 'jpeg' => 'imagery', 'png' => 'imagery', 'gif' => 'imagery', 'webp' => 'imagery',
|
||||
'pdf' => 'documents', 'docx' => 'documents', 'xlsx' => 'documents', 'pptx' => 'documents',
|
||||
'odt' => 'documents', 'ods' => 'documents', 'odp' => 'documents', 'txt' => 'documents', 'csv' => 'documents',
|
||||
'mp4' => 'video', 'm4v' => 'video', 'webm' => 'video', 'mov' => 'video', 'ogv' => 'video',
|
||||
'mp3' => 'audio', 'wav' => 'audio', 'ogg' => 'audio', 'oga' => 'audio', 'm4a' => 'audio', 'aac' => 'audio', 'flac' => 'audio',
|
||||
];
|
||||
|
||||
/** Extensions that must never appear anywhere in a filename (for example report.exe.pdf), even though the file is renamed. */
|
||||
private const DENIED_PARTS = [
|
||||
'exe', 'dll', 'bat', 'cmd', 'com', 'msi', 'scr', 'ps1', 'vbs', 'vbe', 'js', 'jse', 'jar', 'wsf', 'hta', 'cpl', 'reg',
|
||||
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'cgi', 'pl', 'py', 'rb', 'sh', 'bash', 'asp', 'aspx', 'jsp', 'jspx',
|
||||
'html', 'htm', 'xhtml', 'shtml', 'svg', 'xml', 'swf', 'lnk', 'apk', 'app', 'dmg', 'iso', 'so', 'bin',
|
||||
'docm', 'xlsm', 'pptm', 'dotm', 'xltm', 'potm', 'xlam', 'ppam',
|
||||
];
|
||||
|
||||
/** MIME types that always mean executable or markup content, whatever the extension claims. */
|
||||
private const BLOCKED_MIMES = [
|
||||
'application/x-dosexec', 'application/x-executable', 'application/x-sharedlib', 'application/x-mach-binary', 'application/x-msdownload',
|
||||
'application/x-elf', 'application/x-msi', 'application/java-archive', 'application/x-httpd-php', 'application/javascript',
|
||||
'text/javascript', 'text/x-php', 'text/x-shellscript', 'text/x-script.python', 'text/html', 'image/svg+xml', 'text/xml', 'application/xml',
|
||||
];
|
||||
|
||||
/** Case-insensitive markers of server-side or browser code, refused anywhere in any upload. Long enough never to occur by chance in compressed data. */
|
||||
private const CODE_MARKERS = ['<?php', '<script'];
|
||||
|
||||
/** Three-byte markers also refused, but only in plain-text uploads: in compressed media they appear by chance in any file of a few megabytes. */
|
||||
private const TEXT_CODE_MARKERS = ['<?=', '<%@', '<%='];
|
||||
|
||||
/** Entry names refused inside Office/OpenDocument packages. */
|
||||
private const DENIED_ENTRY_ENDINGS = ['.exe', '.dll', '.bat', '.cmd', '.com', '.msi', '.scr', '.ps1', '.vbs', '.js', '.jar', '.php', '.html', '.htm', '.sh', '.py', '.pl', '.bin'];
|
||||
|
||||
/** Expected ODF mimetype entry per extension. */
|
||||
private const ODF = ['odt' => 'application/vnd.oasis.opendocument.text', 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', 'odp' => 'application/vnd.oasis.opendocument.presentation'];
|
||||
|
||||
/** Expected marker entry per OOXML extension. */
|
||||
private const OOXML = ['docx' => 'word/document.xml', 'xlsx' => 'xl/workbook.xml', 'pptx' => 'ppt/presentation.xml'];
|
||||
|
||||
public static function extensions(): array
|
||||
{
|
||||
return array_keys(self::EXTENSIONS);
|
||||
}
|
||||
|
||||
public static function categoryForExtension(string $extension): ?string
|
||||
{
|
||||
return self::EXTENSIONS[strtolower($extension)] ?? null;
|
||||
}
|
||||
|
||||
/** Category of a stored filename, from its extension. */
|
||||
public static function categoryForName(string $name): ?string
|
||||
{
|
||||
return self::categoryForExtension(pathinfo($name, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
/** Regular-expression fragment (unanchored) matching every stored media filename: legacy 32-hex names or slug-8hex names. */
|
||||
public static function nameRegex(): string
|
||||
{
|
||||
return '(?:[a-f0-9]{32}|[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?-[a-f0-9]{8})\.(?:' . implode('|', self::extensions()) . ')';
|
||||
}
|
||||
|
||||
public static function isManagedName(string $name): bool
|
||||
{
|
||||
return preg_match('/^' . self::nameRegex() . '$/', $name) === 1;
|
||||
}
|
||||
|
||||
/** New stored filename: a slug of the original name, a random suffix, and exactly one dot. */
|
||||
public static function storedName(string $originalName, string $extension): string
|
||||
{
|
||||
$base = pathinfo(basename($originalName), PATHINFO_FILENAME);
|
||||
if (function_exists('iconv')) {
|
||||
$base = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $base) ?: $base;
|
||||
}
|
||||
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($base)), '-');
|
||||
$slug = trim(substr($slug, 0, 60), '-');
|
||||
return ($slug !== '' ? $slug : 'file') . '-' . bin2hex(random_bytes(4)) . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check an uploaded file and report how to store it.
|
||||
*
|
||||
* @param array $limits maxBytes, maxWidth, maxHeight, maxPixels
|
||||
* @return array{ext: string, category: string, mime: string}
|
||||
* @throws \RuntimeException with a message safe to show the user
|
||||
*/
|
||||
public static function inspect(string $path, string $originalName, array $limits = []): array
|
||||
{
|
||||
$parts = explode('.', strtolower(basename(str_replace('\\', '/', $originalName))));
|
||||
$extension = count($parts) > 1 ? (string) end($parts) : '';
|
||||
$category = self::EXTENSIONS[$extension] ?? null;
|
||||
foreach (array_slice($parts, 1) as $part) {
|
||||
if (in_array($part, self::DENIED_PARTS, true)) {
|
||||
throw new \RuntimeException('Files of this type (.' . $part . ') are not allowed.');
|
||||
}
|
||||
}
|
||||
if ($category === null) {
|
||||
throw new \RuntimeException('This file type is not allowed. Accepted: images, documents (PDF, Word, Excel, PowerPoint, OpenDocument, text, CSV), video, and audio.');
|
||||
}
|
||||
$size = @filesize($path);
|
||||
if ($size === false || $size < 1) {
|
||||
throw new \RuntimeException('The file is empty.');
|
||||
}
|
||||
if (isset($limits['maxBytes']) && $size > $limits['maxBytes']) {
|
||||
throw new \RuntimeException('This file is larger than the ' . round($limits['maxBytes'] / 1048576, 1) . ' MB limit.');
|
||||
}
|
||||
|
||||
$head = (string) file_get_contents($path, false, null, 0, 8);
|
||||
foreach (["MZ", "\x7fELF", "#!", "\xCF\xFA\xED\xFE", "\xFE\xED\xFA\xCF", "\xCA\xFE\xBA\xBE"] as $signature) {
|
||||
if (str_starts_with($head, $signature)) {
|
||||
throw new \RuntimeException('Executable files are not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = (string) finfo_file($finfo, $path);
|
||||
finfo_close($finfo);
|
||||
if (in_array($mime, self::BLOCKED_MIMES, true)) {
|
||||
throw new \RuntimeException('Executable or script content is not allowed.');
|
||||
}
|
||||
self::assertNoCode($path, $extension === 'txt' || $extension === 'csv');
|
||||
|
||||
match (true) {
|
||||
$category === 'imagery' => self::checkImage($path, $extension, $mime, $limits),
|
||||
$extension === 'pdf' => self::checkPdf($path, $mime),
|
||||
isset(self::OOXML[$extension]) || isset(self::ODF[$extension]) => self::checkPackage($path, $extension),
|
||||
$extension === 'txt' || $extension === 'csv' => self::checkText($path, $mime),
|
||||
default => self::checkAv($path, $extension, $mime, $category),
|
||||
};
|
||||
return ['ext' => $extension === 'jpeg' ? 'jpg' : $extension, 'category' => $category, 'mime' => $mime];
|
||||
}
|
||||
|
||||
private static function assertNoCode(string $path, bool $plainText): void
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('The file could not be read.');
|
||||
}
|
||||
$carry = '';
|
||||
while (!feof($handle)) {
|
||||
$chunk = (string) fread($handle, 1048576);
|
||||
$window = strtolower($carry . $chunk);
|
||||
foreach ($plainText ? array_merge(self::CODE_MARKERS, self::TEXT_CODE_MARKERS) : self::CODE_MARKERS as $marker) {
|
||||
if (str_contains($window, $marker)) {
|
||||
fclose($handle);
|
||||
throw new \RuntimeException('Files containing code are not allowed.');
|
||||
}
|
||||
}
|
||||
$carry = substr($chunk, -8);
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
private static function checkImage(string $path, string $extension, string $mime, array $limits): void
|
||||
{
|
||||
$expected = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp'][$extension];
|
||||
if ($mime !== $expected) {
|
||||
throw new \RuntimeException('The file contents do not match its image type.');
|
||||
}
|
||||
$info = @getimagesize($path);
|
||||
if ($info === false) {
|
||||
throw new \RuntimeException('Uploaded file is not a valid image.');
|
||||
}
|
||||
[$width, $height] = $info;
|
||||
if ($width > ($limits['maxWidth'] ?? 8192) || $height > ($limits['maxHeight'] ?? 8192) || $width * $height > ($limits['maxPixels'] ?? 24 * 1024 * 1024)) {
|
||||
throw new \RuntimeException('Image dimensions exceed the configured limit.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkPdf(string $path, string $mime): void
|
||||
{
|
||||
$data = (string) file_get_contents($path);
|
||||
if ($mime !== 'application/pdf' || !str_contains(substr($data, 0, 1024), '%PDF-')) {
|
||||
throw new \RuntimeException('The file contents do not match a PDF.');
|
||||
}
|
||||
if (preg_match('#/JavaScript|/JS\b|/Launch|/EmbeddedFile|/RichMedia#', $data)) {
|
||||
throw new \RuntimeException('PDFs containing scripts, launch actions or embedded files are not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkText(string $path, string $mime): void
|
||||
{
|
||||
$data = (string) file_get_contents($path);
|
||||
if (str_contains($data, "\0") || !preg_match('//u', $data)) {
|
||||
throw new \RuntimeException('Text files must be plain UTF-8 text.');
|
||||
}
|
||||
if (!str_starts_with($mime, 'text/') && $mime !== 'application/csv') {
|
||||
throw new \RuntimeException('The file contents do not match a text file.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Audio and video must sniff as such; when libmagic only says "octet-stream", the container signature must match the extension. */
|
||||
private static function checkAv(string $path, string $extension, string $mime, string $category): void
|
||||
{
|
||||
if (str_starts_with($mime, 'audio/') || str_starts_with($mime, 'video/') || $mime === 'application/ogg') {
|
||||
return;
|
||||
}
|
||||
$head = (string) file_get_contents($path, false, null, 0, 12);
|
||||
$sync = strlen($head) > 1 && ord($head[0]) === 0xFF;
|
||||
$matches = match ($extension) {
|
||||
'mp4', 'm4v', 'mov', 'm4a' => in_array(substr($head, 4, 4), ['ftyp', 'moov', 'mdat', 'free', 'wide', 'skip', 'pnot'], true),
|
||||
'webm' => str_starts_with($head, "\x1A\x45\xDF\xA3"),
|
||||
'ogv', 'ogg', 'oga' => str_starts_with($head, 'OggS'),
|
||||
'mp3' => str_starts_with($head, 'ID3') || ($sync && (ord($head[1]) & 0xE0) === 0xE0),
|
||||
'aac' => $sync && (ord($head[1]) & 0xF0) === 0xF0,
|
||||
'wav' => str_starts_with($head, 'RIFF'),
|
||||
'flac' => str_starts_with($head, 'fLaC'),
|
||||
default => false,
|
||||
};
|
||||
if ($mime !== 'application/octet-stream' || !$matches) {
|
||||
throw new \RuntimeException('The file contents do not match ' . ($category === 'video' ? 'a video' : 'an audio') . ' file.');
|
||||
}
|
||||
}
|
||||
|
||||
/** OOXML and OpenDocument files are ZIP packages: read the directory only, and refuse macros, embedded objects, and executables. */
|
||||
private static function checkPackage(string $path, string $extension): void
|
||||
{
|
||||
$entries = self::zipEntries($path);
|
||||
if (isset(self::ODF[$extension])) {
|
||||
$type = self::zipRead($path, $entries['mimetype'] ?? null);
|
||||
if ($type !== self::ODF[$extension]) {
|
||||
throw new \RuntimeException('The file contents do not match an OpenDocument file.');
|
||||
}
|
||||
} elseif (!isset($entries['[Content_Types].xml']) || !isset($entries[self::OOXML[$extension]])) {
|
||||
throw new \RuntimeException('The file contents do not match an Office document.');
|
||||
}
|
||||
foreach (array_keys($entries) as $name) {
|
||||
$lower = strtolower((string) $name);
|
||||
$bad = str_contains($lower, 'vbaproject') || str_contains($lower, 'activex') || str_contains($lower, 'embeddings/')
|
||||
|| str_starts_with($lower, 'basic/') || str_starts_with($lower, 'scripts/') || str_contains($lower, '..') || str_starts_with($lower, '/');
|
||||
foreach (self::DENIED_ENTRY_ENDINGS as $ending) {
|
||||
$bad = $bad || str_ends_with($lower, $ending);
|
||||
}
|
||||
if ($bad) {
|
||||
throw new \RuntimeException('Documents containing macros, embedded objects or scripts are not allowed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array{offset: int, csize: int, usize: int, method: int}> */
|
||||
private static function zipEntries(string $path): array
|
||||
{
|
||||
$size = (int) filesize($path);
|
||||
$tail = (string) file_get_contents($path, false, null, max(0, $size - 65557));
|
||||
$eocd = strrpos($tail, "PK\x05\x06");
|
||||
if ($eocd === false || strlen($tail) < $eocd + 22 || !str_starts_with((string) file_get_contents($path, false, null, 0, 4), "PK\x03\x04")) {
|
||||
throw new \RuntimeException('The file is not a valid document package.');
|
||||
}
|
||||
$count = unpack('v', substr($tail, $eocd + 10, 2))[1];
|
||||
$directorySize = unpack('V', substr($tail, $eocd + 12, 4))[1];
|
||||
$directoryOffset = unpack('V', substr($tail, $eocd + 16, 4))[1];
|
||||
if ($count === 0xFFFF || $directorySize === 0xFFFFFFFF || $count > 5000 || $directorySize > 5 * 1048576) {
|
||||
throw new \RuntimeException('The document package is too complex.');
|
||||
}
|
||||
$directory = (string) file_get_contents($path, false, null, $directoryOffset, $directorySize);
|
||||
$entries = [];
|
||||
$position = 0;
|
||||
while ($position + 46 <= strlen($directory) && substr($directory, $position, 4) === "PK\x01\x02") {
|
||||
$method = unpack('v', substr($directory, $position + 10, 2))[1];
|
||||
$csize = unpack('V', substr($directory, $position + 20, 4))[1];
|
||||
$usize = unpack('V', substr($directory, $position + 24, 4))[1];
|
||||
[$nameLength, $extraLength, $commentLength] = array_values(unpack('v3', substr($directory, $position + 28, 6)));
|
||||
$offset = unpack('V', substr($directory, $position + 42, 4))[1];
|
||||
$entries[substr($directory, $position + 46, $nameLength)] = ['offset' => $offset, 'csize' => $csize, 'usize' => $usize, 'method' => $method];
|
||||
$position += 46 + $nameLength + $extraLength + $commentLength;
|
||||
}
|
||||
if (!$entries) {
|
||||
throw new \RuntimeException('The file is not a valid document package.');
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** Read one stored (uncompressed) entry, used only for the small ODF mimetype file. */
|
||||
private static function zipRead(string $path, ?array $entry): string
|
||||
{
|
||||
if ($entry === null || $entry['method'] !== 0 || $entry['usize'] > 256) {
|
||||
return '';
|
||||
}
|
||||
$header = (string) file_get_contents($path, false, null, $entry['offset'], 30);
|
||||
if (strlen($header) < 30) {
|
||||
return '';
|
||||
}
|
||||
[$nameLength, $extraLength] = array_values(unpack('v2', substr($header, 26, 4)));
|
||||
return (string) file_get_contents($path, false, null, $entry['offset'] + 30 + $nameLength + $extraLength, $entry['usize']);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ final class SiteAnalyser
|
||||
private const MIN_SECTION_TEXT = 40;
|
||||
|
||||
/** Describe a page: existing and proposed editable regions, images, local references, and SEO tags present. */
|
||||
public static function analyse(string $html, string $editableClass): array
|
||||
public static function analyse(string $html, string $editableClass, bool $allNavs = false): array
|
||||
{
|
||||
$c = self::scan($html, $editableClass, true);
|
||||
return [
|
||||
@@ -26,14 +26,24 @@ final class SiteAnalyser
|
||||
'images' => array_map(fn(array $i) => ['src' => $i['src'], 'hasAlt' => $i['hasAlt'], 'inRegion' => $i['inRegion'], 'tagged' => $i['tagged']], $c['images']),
|
||||
'refs' => $c['refs'],
|
||||
'seo' => $c['seo'],
|
||||
'navs' => array_values(array_map(
|
||||
fn(array $n) => ['menu' => $n['menu'] ?? ($allNavs ? $n['name'] : $n['slot']), 'links' => count($n['links']), 'tagged' => $n['menu'] !== null, 'sig' => md5(implode("\n", array_column($n['links'], 'url')))],
|
||||
array_filter($c['navs'], fn(array $n) => $n['menu'] !== null || ($allNavs ? $n['name'] : $n['slot']) !== null)
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Every navigation block in a page with its byte offsets, links and any data-neo-menu name (used to replace menus in place). */
|
||||
public static function navs(string $html): array
|
||||
{
|
||||
return self::navList($html, self::elements($html));
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the requested markers into a page.
|
||||
*
|
||||
* @param array $options Booleans: content, images, seo.
|
||||
* @return array|null ['html' => string, 'changes' => string[]], or null when the page needs no change.
|
||||
* @param array $options Booleans: content, images, seo, menus; allNavs also names navs that are not clearly header or footer menus.
|
||||
* @return array|null ['html' => string, 'changes' => string[], 'menus' => [name => links]], or null when the page needs no change.
|
||||
*/
|
||||
public static function apply(string $html, string $editableClass, array $options): ?array
|
||||
{
|
||||
@@ -64,6 +74,19 @@ final class SiteAnalyser
|
||||
$edits[] = $seo['edit'];
|
||||
array_push($changes, ...$seo['changes']);
|
||||
}
|
||||
$menus = [];
|
||||
if (!empty($options['menus'])) {
|
||||
$named = array_filter(array_column($c['navs'], 'menu'));
|
||||
foreach ($c['navs'] as $nav) {
|
||||
$name = !empty($options['allNavs']) ? $nav['name'] : $nav['slot'];
|
||||
// One nav per name per page, and never over a menu the page already marks by hand.
|
||||
if ($nav['menu'] === null && $name !== null && !isset($menus[$name]) && !in_array($name, $named, true)) {
|
||||
$edits[] = [$nav['insertAt'], ' data-neo-menu="' . $name . '"'];
|
||||
$changes[] = 'Marked ' . $name . ' navigation as a menu (' . count($nav['links']) . ' links)';
|
||||
$menus[$name] = $nav['links'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$edits) {
|
||||
return null;
|
||||
}
|
||||
@@ -72,7 +95,72 @@ final class SiteAnalyser
|
||||
foreach ($edits as [$offset, $text]) {
|
||||
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
|
||||
}
|
||||
return ['html' => $html, 'changes' => $changes];
|
||||
return ['html' => $html, 'changes' => $changes, 'menus' => $menus];
|
||||
}
|
||||
|
||||
/** Navigation blocks: <nav> or role=navigation, classified as the primary (header) or secondary (footer) menu when recognisable. */
|
||||
private static function navList(string $html, array $els): array
|
||||
{
|
||||
$navs = [];
|
||||
foreach ($els as $k => $e) {
|
||||
if ($e['name'] !== 'nav' && strtolower((string) self::attr($e['attrs'], 'role')) !== 'navigation') {
|
||||
continue;
|
||||
}
|
||||
$links = [];
|
||||
foreach ($els as $a) {
|
||||
if ($a['name'] !== 'a' || ($href = self::attr($a['attrs'], 'href')) === null) {
|
||||
continue;
|
||||
}
|
||||
$inside = false;
|
||||
$lis = [];
|
||||
for ($p = $a['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
if ($p === $k) {
|
||||
$inside = true;
|
||||
break;
|
||||
}
|
||||
if ($els[$p]['name'] === 'li') {
|
||||
$lis[] = $p;
|
||||
}
|
||||
}
|
||||
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
$label = self::text($html, $a) ?: trim((string) self::attr($a['attrs'], 'aria-label'));
|
||||
if (!$inside || $href === '' || $label === '' || stripos($href, 'javascript:') === 0) {
|
||||
continue;
|
||||
}
|
||||
// A link inside a nested list is a child of the link that owns the enclosing list item.
|
||||
$parent = '';
|
||||
if (isset($lis[1])) {
|
||||
foreach ($els as $b) {
|
||||
if ($b['name'] === 'a' && $b['parent'] === $lis[1]) {
|
||||
$parent = self::text($html, $b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$links[] = ['label' => $label, 'url' => $href, 'parent' => $parent];
|
||||
}
|
||||
|
||||
$ancestors = [];
|
||||
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
|
||||
$ancestors[] = $els[$p]['name'] . ':' . strtolower((string) self::attr($els[$p]['attrs'], 'role'));
|
||||
}
|
||||
$in = fn(string $tag, string $role) => in_array($tag . ':', $ancestors, true) || in_array(':' . $role, $ancestors, true) || in_array($tag . ':' . $role, $ancestors, true);
|
||||
$label = strtolower(self::attr($e['attrs'], 'aria-label') . ' ' . self::attr($e['attrs'], 'class') . ' ' . self::attr($e['attrs'], 'id'));
|
||||
$slot = null;
|
||||
if ($links) {
|
||||
if ($in('footer', 'contentinfo') || preg_match('/\b(footer|secondary|legal)\b/', $label)) {
|
||||
$slot = 'secondary';
|
||||
} elseif ($in('header', 'banner') || preg_match('/\b(main|primary|site-nav|top)\b/', $label)) {
|
||||
$slot = 'primary';
|
||||
}
|
||||
}
|
||||
$navs[] = [
|
||||
'menu' => ($m = self::attr($e['attrs'], 'data-neo-menu')) !== null && $m !== '' ? $m : null,
|
||||
'slot' => $slot, 'name' => $slot ?? ($links ? (trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(substr((string) (self::attr($e['attrs'], 'aria-label') ?: self::attr($e['attrs'], 'id')), 0, 40))), '-') ?: 'navigation') : null), 'links' => $links, 'attrs' => $e['attrs'],
|
||||
'end' => $e['end'], 'close' => $e['close'], 'insertAt' => $e['start'] + 1 + strlen($e['name']),
|
||||
];
|
||||
}
|
||||
return $navs;
|
||||
}
|
||||
|
||||
/** Tokenise a page and gather everything analyse() and apply() need. */
|
||||
@@ -163,6 +251,7 @@ final class SiteAnalyser
|
||||
}
|
||||
|
||||
return [
|
||||
'navs' => self::navList($html, $els),
|
||||
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
|
||||
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
|
||||
'seo' => [
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
namespace NeoCMS;
|
||||
|
||||
/**
|
||||
* Account registry: accounts from config.local.php are authoritative and locked; accounts created in the CMS live in data/users.json.
|
||||
*
|
||||
* Only active CMS accounts and config accounts ever reach Authentication, so blocking or deleting an account also ends its sessions.
|
||||
* Password hashes and invite tokens never leave this class except as the hash handed to Authentication.
|
||||
*/
|
||||
final class UserStore
|
||||
{
|
||||
public const ROLES = ['editor', 'administrator'];
|
||||
public const INVITE_LIFETIME = 604800;
|
||||
public const MIN_PASSWORD = 12;
|
||||
public const MAX_PASSWORD = 72;
|
||||
|
||||
/** Plain-language role descriptions shown on the Users screen. */
|
||||
public const ROLE_INFO = [
|
||||
'editor' => 'Edits content and saves drafts, uploads images, publishes and schedules pages, and restores revisions or deleted pages. Cannot change the site structure or manage users.',
|
||||
'administrator' => 'Everything an editor can do, plus creating, duplicating, renaming and deleting pages, templates, navigation menus, shared content, site scan, deleting media, and managing users.',
|
||||
];
|
||||
|
||||
public function __construct(private FileStore $store, private array $config)
|
||||
{
|
||||
}
|
||||
|
||||
/** Build a store from the site configuration. */
|
||||
public static function fromConfig(array $config): self
|
||||
{
|
||||
$directory = (string) ($config['dataDirectory'] ?? (dirname(__DIR__, 2) . '/data'));
|
||||
return new self(new FileStore($directory), $config);
|
||||
}
|
||||
|
||||
/** Arguments for `new Authentication(...)`: config credentials plus active CMS accounts, roles, and session options. */
|
||||
public static function authArgs(array $config): array
|
||||
{
|
||||
[$credentials, $roles] = self::fromConfig($config)->forAuth();
|
||||
return [$credentials, $roles, $config['security'] ?? []];
|
||||
}
|
||||
|
||||
/** Map an arbitrary configured role onto the two supported roles (the old publisher role is an editor). */
|
||||
public static function normaliseRole(mixed $role): string
|
||||
{
|
||||
return $role === 'administrator' ? 'administrator' : 'editor';
|
||||
}
|
||||
|
||||
/** Credentials and roles of every account allowed to sign in. */
|
||||
public function forAuth(): array
|
||||
{
|
||||
$credentials = [];
|
||||
$roles = [];
|
||||
foreach ($this->data()['users'] as $username => $record) {
|
||||
if (($record['status'] ?? '') === 'active' && is_string($record['hash'] ?? null) && $record['hash'] !== '') {
|
||||
$credentials[$username] = $record['hash'];
|
||||
$roles[$username] = self::normaliseRole($record['role'] ?? 'editor');
|
||||
}
|
||||
}
|
||||
foreach ($this->configAccounts() as $username => $account) {
|
||||
$credentials[$username] = $account['hash'];
|
||||
$roles[$username] = $account['role'];
|
||||
}
|
||||
return [$credentials, $roles];
|
||||
}
|
||||
|
||||
/** The name and email shown for an account, or blanks. */
|
||||
public function profile(string $username): array
|
||||
{
|
||||
foreach ($this->accounts() as $account) {
|
||||
if ($account['username'] === $username) {
|
||||
return ['name' => $account['name'], 'email' => $account['email'], 'managed' => $account['managed']];
|
||||
}
|
||||
}
|
||||
return ['name' => '', 'email' => '', 'managed' => false];
|
||||
}
|
||||
|
||||
/** Every account without secrets: config accounts first (locked), then CMS accounts. */
|
||||
public function accounts(): array
|
||||
{
|
||||
$data = $this->data();
|
||||
$list = [];
|
||||
foreach ($this->configAccounts() as $username => $account) {
|
||||
$profile = $data['profiles'][$username] ?? [];
|
||||
$list[] = [
|
||||
'username' => $username, 'name' => (string) ($profile['name'] ?? ''), 'email' => (string) ($profile['email'] ?? ''),
|
||||
'role' => $account['role'], 'status' => 'active', 'managed' => true, 'created' => '', 'inviteExpired' => false,
|
||||
];
|
||||
}
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
if (isset($this->configAccounts()[$username])) {
|
||||
continue;
|
||||
}
|
||||
$list[] = [
|
||||
'username' => $username, 'name' => (string) ($record['name'] ?? ''), 'email' => (string) ($record['email'] ?? ''),
|
||||
'role' => self::normaliseRole($record['role'] ?? 'editor'), 'status' => (string) ($record['status'] ?? 'active'),
|
||||
'managed' => false, 'created' => (string) ($record['created'] ?? ''),
|
||||
'inviteExpired' => ($record['status'] ?? '') === 'invited' && (int) ($record['invite']['expires'] ?? 0) < time(),
|
||||
];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function isManaged(string $username): bool
|
||||
{
|
||||
return isset($this->configAccounts()[$username]);
|
||||
}
|
||||
|
||||
/** Set display name and email. Config accounts keep their credentials in config; only this profile overlay is stored. */
|
||||
public function setProfile(string $username, string $name, string $email): void
|
||||
{
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$managed = $this->isManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $managed): array {
|
||||
$data = $this->shape($data);
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
if ($managed) {
|
||||
$data['profiles'][$username] = ['name' => $name, 'email' => $email];
|
||||
} elseif (isset($data['users'][$username])) {
|
||||
$data['users'][$username]['name'] = $name;
|
||||
$data['users'][$username]['email'] = $email;
|
||||
$data['users'][$username]['updated'] = date(DATE_ATOM);
|
||||
} else {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create an active CMS account with a password. */
|
||||
public function create(string $username, string $name, string $email, string $role, string $password): void
|
||||
{
|
||||
$username = trim($username);
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$this->assertRole($role);
|
||||
$this->assertPassword($password, $username, $email);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
$this->assertUsernameFree($data, $username);
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
$now = date(DATE_ATOM);
|
||||
$data['users'][$username] = ['hash' => $hash, 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'active', 'created' => $now, 'updated' => $now];
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Edit a CMS account; a blank password leaves the current one. Config accounts are refused. */
|
||||
public function update(string $username, string $name, string $email, string $role, string $password = ''): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
$this->assertRole($role);
|
||||
$hash = null;
|
||||
if ($password !== '') {
|
||||
$this->assertPassword($password, $username, $email);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
}
|
||||
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
|
||||
$this->assertEmailFree($data, $email, $username);
|
||||
$record['name'] = $name;
|
||||
$record['email'] = $email;
|
||||
$record['role'] = $role;
|
||||
if ($hash !== null) {
|
||||
$record['hash'] = $hash;
|
||||
}
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$username] = $record;
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Change a CMS account's own password. Returns the new hash so the caller can refresh the session. */
|
||||
public function setPassword(string $username, string $password): string
|
||||
{
|
||||
$this->assertNotManaged($username, 'This password is managed in config.local.php');
|
||||
$profile = $this->profile($username);
|
||||
$this->assertPassword($password, $username, $profile['email']);
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
if (!isset($data['users'][$username])) {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
$data['users'][$username]['hash'] = $hash;
|
||||
$data['users'][$username]['updated'] = date(DATE_ATOM);
|
||||
return $data;
|
||||
});
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/** Upgrade a stored hash after a successful login when PHP's default algorithm or cost has changed. */
|
||||
public function rehashIfNeeded(string $username, string $password): void
|
||||
{
|
||||
$record = $this->data()['users'][$username] ?? null;
|
||||
if ($record && !$this->isManaged($username) && is_string($record['hash'] ?? null) && $record['hash'] !== '' && password_needs_rehash($record['hash'], PASSWORD_DEFAULT)) {
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($username, $hash): array {
|
||||
$data = $this->shape($data);
|
||||
if (isset($data['users'][$username])) {
|
||||
$data['users'][$username]['hash'] = $hash;
|
||||
}
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function setBlocked(string $username, bool $blocked): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username, $blocked): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
|
||||
if ($blocked) {
|
||||
$record['status'] = 'blocked';
|
||||
} elseif (($record['status'] ?? '') === 'blocked') {
|
||||
$record['status'] = ($record['hash'] ?? '') === '' ? 'invited' : 'active';
|
||||
}
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$username] = $record;
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(string $username): void
|
||||
{
|
||||
$this->assertNotManaged($username);
|
||||
$this->store->update('users', function (array $data) use ($username): array {
|
||||
$data = $this->shape($data);
|
||||
if (!isset($data['users'][$username])) {
|
||||
throw new \RuntimeException('Account not found');
|
||||
}
|
||||
unset($data['users'][$username]);
|
||||
return $data;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create (or re-issue) a pending invitation. Returns the username and the one-time token, which is stored only as a hash. */
|
||||
public function invite(string $name, string $email, string $role, string $existing = ''): array
|
||||
{
|
||||
$name = $this->cleanName($name);
|
||||
$email = $this->cleanEmail($email);
|
||||
if ($email === '') {
|
||||
throw new \RuntimeException('An email address is required to invite someone');
|
||||
}
|
||||
$this->assertRole($role);
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $token);
|
||||
$username = '';
|
||||
$this->store->update('users', function (array $data) use ($name, $email, $role, $existing, $tokenHash, &$username): array {
|
||||
$data = $this->shape($data);
|
||||
$now = date(DATE_ATOM);
|
||||
$invite = ['token' => $tokenHash, 'expires' => time() + self::INVITE_LIFETIME];
|
||||
if ($existing !== '') {
|
||||
$record = $data['users'][$existing] ?? null;
|
||||
if (!$record || ($record['status'] ?? '') !== 'invited') {
|
||||
throw new \RuntimeException('Only a pending invitation can be re-issued');
|
||||
}
|
||||
$this->assertEmailFree($data, $email, $existing);
|
||||
$username = $existing;
|
||||
$data['users'][$username] = array_merge($record, ['name' => $name, 'email' => $email, 'role' => $role, 'invite' => $invite, 'updated' => $now]);
|
||||
return $data;
|
||||
}
|
||||
$this->assertEmailFree($data, $email, '');
|
||||
$username = $this->generateUsername($data, $email);
|
||||
$data['users'][$username] = ['hash' => '', 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'invited', 'invite' => $invite, 'created' => $now, 'updated' => $now];
|
||||
return $data;
|
||||
});
|
||||
return [$username, $token];
|
||||
}
|
||||
|
||||
/** Redeem an invitation: the token is single use and expires. Returns the username. Every failure gives the same message. */
|
||||
public function acceptInvite(string $token, string $password): string
|
||||
{
|
||||
$failure = 'This invitation is invalid or has expired';
|
||||
$wanted = hash('sha256', $token);
|
||||
$accepted = null;
|
||||
$hash = null;
|
||||
$data = $this->data();
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
if (($record['status'] ?? '') === 'invited' && is_string($record['invite']['token'] ?? null)
|
||||
&& hash_equals($record['invite']['token'], $wanted) && (int) ($record['invite']['expires'] ?? 0) >= time()) {
|
||||
$accepted = $username;
|
||||
}
|
||||
}
|
||||
if ($accepted === null || $token === '') {
|
||||
throw new \RuntimeException($failure);
|
||||
}
|
||||
$this->assertPassword($password, $accepted, (string) ($data['users'][$accepted]['email'] ?? ''));
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$this->store->update('users', function (array $data) use ($accepted, $wanted, $hash, $failure): array {
|
||||
$data = $this->shape($data);
|
||||
$record = $data['users'][$accepted] ?? null;
|
||||
if (!$record || ($record['status'] ?? '') !== 'invited' || !hash_equals((string) ($record['invite']['token'] ?? ''), $wanted)) {
|
||||
throw new \RuntimeException($failure);
|
||||
}
|
||||
unset($record['invite']);
|
||||
$record['hash'] = $hash;
|
||||
$record['status'] = 'active';
|
||||
$record['updated'] = date(DATE_ATOM);
|
||||
$data['users'][$accepted] = $record;
|
||||
return $data;
|
||||
});
|
||||
return $accepted;
|
||||
}
|
||||
|
||||
/** Password policy shared by every path that sets a password. */
|
||||
public function assertPassword(string $password, string $username = '', string $email = ''): void
|
||||
{
|
||||
$length = strlen($password);
|
||||
if ($length < self::MIN_PASSWORD) {
|
||||
throw new \RuntimeException('Passwords must be at least ' . self::MIN_PASSWORD . ' characters');
|
||||
}
|
||||
if ($length > self::MAX_PASSWORD) {
|
||||
throw new \RuntimeException('Passwords can be at most ' . self::MAX_PASSWORD . ' bytes long');
|
||||
}
|
||||
$lower = strtolower($password);
|
||||
if (($username !== '' && $lower === strtolower($username)) || ($email !== '' && $lower === strtolower($email))) {
|
||||
throw new \RuntimeException('The password must differ from the login name and email address');
|
||||
}
|
||||
}
|
||||
|
||||
private function configAccounts(): array
|
||||
{
|
||||
$accounts = [];
|
||||
foreach ((array) ($this->config['authentication'] ?? []) as $username => $hash) {
|
||||
if (is_string($username) && is_string($hash) && $hash !== '') {
|
||||
$accounts[$username] = ['hash' => $hash, 'role' => self::normaliseRole(($this->config['roles'] ?? [])[$username] ?? 'editor')];
|
||||
}
|
||||
}
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
private function data(): array
|
||||
{
|
||||
return $this->shape($this->store->read('users'));
|
||||
}
|
||||
|
||||
private function shape(array $data): array
|
||||
{
|
||||
$data['users'] = is_array($data['users'] ?? null) ? $data['users'] : [];
|
||||
$data['profiles'] = is_array($data['profiles'] ?? null) ? $data['profiles'] : [];
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function cleanName(string $name): string
|
||||
{
|
||||
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
|
||||
if ($name === '' || mb_strlen($name) > 80 || preg_match('/[\x00-\x1f\x7f<>]/u', $name)) {
|
||||
throw new \RuntimeException('Enter a display name of up to 80 characters');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
private function cleanEmail(string $email): string
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
if ($email !== '' && (strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL))) {
|
||||
throw new \RuntimeException('Enter a valid email address');
|
||||
}
|
||||
return $email;
|
||||
}
|
||||
|
||||
private function assertRole(string $role): void
|
||||
{
|
||||
if (!in_array($role, self::ROLES, true)) {
|
||||
throw new \RuntimeException('Unknown role');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotManaged(string $username, string $message = 'This account is managed in config.local.php and cannot be changed here'): void
|
||||
{
|
||||
if ($this->isManaged($username)) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertUsernameFree(array $data, string $username): void
|
||||
{
|
||||
if (!preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username)) {
|
||||
throw new \RuntimeException('Login names are 3-32 letters, numbers, dots, dashes or underscores');
|
||||
}
|
||||
foreach ([$data['users'], $this->configAccounts()] as $existing) {
|
||||
foreach (array_keys($existing) as $known) {
|
||||
if (strcasecmp((string) $known, $username) === 0) {
|
||||
throw new \RuntimeException('That login name is already in use');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Emails are unique across config overlays and CMS accounts, ignoring case. */
|
||||
private function assertEmailFree(array $data, string $email, string $exceptUsername): void
|
||||
{
|
||||
if ($email === '') {
|
||||
return;
|
||||
}
|
||||
$taken = [];
|
||||
foreach ($data['profiles'] as $username => $profile) {
|
||||
$taken[$username] = strtolower((string) ($profile['email'] ?? ''));
|
||||
}
|
||||
foreach ($data['users'] as $username => $record) {
|
||||
$taken[$username] = strtolower((string) ($record['email'] ?? ''));
|
||||
}
|
||||
foreach ($taken as $username => $known) {
|
||||
if ($known === $email && (string) $username !== $exceptUsername) {
|
||||
throw new \RuntimeException('That email address is already in use');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateUsername(array $data, string $email): string
|
||||
{
|
||||
$base = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', explode('@', $email)[0]) ?? '');
|
||||
$base = substr(str_pad($base, 3, 'x'), 0, 28);
|
||||
for ($i = 0; $i < 1000; $i++) {
|
||||
$candidate = $i === 0 ? $base : $base . $i;
|
||||
try {
|
||||
$this->assertUsernameFree($data, $candidate);
|
||||
return $candidate;
|
||||
} catch (\RuntimeException) {
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('Unable to generate a login name');
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -16,6 +16,8 @@
|
||||
main { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; margin-top: 22px; }
|
||||
section { padding: 22px; border: 1px solid #d4dde4; border-radius: 10px; background: #fff; }
|
||||
code { padding: 2px 5px; border-radius: 4px; background: #e8edf1; }
|
||||
.important { color: #b3141f; }
|
||||
.important code { color: inherit; background: #fbe4e6; }
|
||||
footer { margin-top: 24px; color: #586673; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
@@ -37,33 +39,35 @@
|
||||
<!-- Content creation and rollback tools. -->
|
||||
<section>
|
||||
<h2>Pages and revisions</h2>
|
||||
<p>Administrators can create, duplicate, rename, and delete pages. Publishers can restore earlier revisions from <strong>Tools</strong>.</p>
|
||||
<p>Deleted pages retain a restorable snapshot, because accidents should be reversible rather than memorable.</p>
|
||||
<p>Administrators can create, duplicate, rename, and delete pages. The <strong>New page</strong> section under the list in <strong>Pages</strong> is a three-step wizard: choose a template, name the page (the filename is generated for you), then choose a navigation group. The new page stays a private draft until it is published; publishing creates the file and adds it to the chosen menu.</p>
|
||||
<p>Use <strong>Create Template</strong> on any page in the list to offer it as a template, and Delete in the template list to stop offering one. Editors can restore earlier revisions from <strong>Tools</strong>.</p>
|
||||
<p>Deleted pages retain a restorable snapshot: editors can bring one back with <strong>Recover</strong> in the <strong>Dashboard</strong>'s Deleted pages list, because accidents should be reversible rather than memorable.</p>
|
||||
</section>
|
||||
|
||||
<!-- Asset and metadata management. -->
|
||||
<section>
|
||||
<h2>Media and SEO</h2>
|
||||
<p>The media library reuses uploaded images, records alternative text, and reports usage counts.</p>
|
||||
<p>The media library uploads images, documents, video and audio, sorts them by type, records alternative text or link text, and reports usage counts. In the editor, the <strong>Media library</strong> button inserts an uploaded file at the cursor. Executables and files containing code are refused.</p>
|
||||
<p>The SEO dialogue manages titles, descriptions, canonical URLs, Open Graph images, and indexing instructions.</p>
|
||||
</section>
|
||||
|
||||
<!-- Site-wide content management. -->
|
||||
<section>
|
||||
<h2>Shared content and menus</h2>
|
||||
<p>Regions marked with <code>data-neo-shared</code> can be updated across every page. Named menus may be nested and propagated in the same fashion.</p>
|
||||
<p>Regions marked with <code>data-neo-shared</code> can be updated across every page. Open <strong>Navigation Menus</strong> to edit, rename, or delete menus; changes propagate to every page. Menus are created by your web developer, not the CMS: <strong>Site Scan</strong> and the menu dialogue's scan button find navigation in the site files (primary and secondary are detected automatically).</p>
|
||||
</section>
|
||||
|
||||
<!-- Publication timing and preview controls. -->
|
||||
<section>
|
||||
<h2>Schedule and preview</h2>
|
||||
<p>Publishers may queue a future release. Desktop, tablet, and mobile controls resize the preview without altering page markup.</p>
|
||||
<p>Editors may queue a future release. The desktop, tablet, and mobile icon buttons resize the preview without altering page markup.</p>
|
||||
</section>
|
||||
|
||||
<!-- Quality and operational tools. -->
|
||||
<section>
|
||||
<h2>Checks and activity</h2>
|
||||
<p>The accessibility checker catches common authoring problems before publication. The dashboard lists drafts, schedules, recent activity, and writable-directory warnings.</p>
|
||||
<h2>Users, checks and activity</h2>
|
||||
<p>The accessibility checker catches common authoring problems before publication. The dashboard lists drafts, schedules, deleted pages you can recover, recent activity, writable-directory warnings, and recommendations.</p>
|
||||
<p>Everyone can update their display name, email address and password from <strong>Tools > Users</strong>. An <strong>Editor</strong> edits and publishes content; an <strong>Administrator</strong> can also manage pages, menus and shared content, and add, invite, edit, block or delete users. Accounts in <code>config.local.php</code> stay managed there. <strong class="important">Important: CMS-created accounts are stored in the data folder, so on a live site set <code>dataDirectory</code> in <code>config.local.php</code> to an absolute path outside the web root; administrators see a Dashboard recommendation until it is.</strong></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
+115
-15
@@ -17,11 +17,30 @@ behaviour to matching regions, and submits complete serialised documents to `cms
|
||||
The preview iframe uses `sandbox="allow-same-origin"` without `allow-scripts`. Parent-side JavaScript may still inspect and
|
||||
edit the DOM, while scripts belonging to public pages cannot reach the administration window, session, or CSRF token.
|
||||
|
||||
Every entry point loads `cms/bootstrap.php`, which reads `config.php` and the untracked `config.local.php`, works out the URL prefix
|
||||
(`basePath`) and site root (`siteRoot`) for subfolder installs, and registers the class autoloader.
|
||||
|
||||
`CMSController` is the single API dispatcher. It authenticates every request and delegates mutating actions through
|
||||
`requirePost()`, which enforces POST, CSRF validation, and the requested role capability.
|
||||
|
||||
Image uploads use the separate `cms/image_upload.php` endpoint because TinyMCE sends multipart file data. It applies the
|
||||
same authentication, role, and CSRF requirements before validating image bytes.
|
||||
Media uploads (images, documents, video, audio) use the separate `cms/image_upload.php` endpoint because TinyMCE and the Media
|
||||
library send multipart file data. It applies the same authentication, role, and CSRF requirements before `MediaTypes::inspect()`
|
||||
validates the file (see Media below).
|
||||
|
||||
## Components
|
||||
|
||||
| Class | Responsibility |
|
||||
| --- | --- |
|
||||
| `CMSController` | Action dispatcher and workflows (pages, drafts, publishing, menus, templates, users). |
|
||||
| `Authentication` | Sessions, role capabilities, CSRF tokens, session revocation. |
|
||||
| `UserStore` | Account registry: config accounts plus CMS accounts, invitations, password policy. |
|
||||
| `PagePaths` | URI and path safety (containment, symlinks, existing and new page paths). |
|
||||
| `ContentDom` | HTML transforms: menus, shared regions, link resolution, image and SEO edits. |
|
||||
| `MediaTypes` | The allow-list of media extensions and categories, and the upload safety checks. |
|
||||
| `SiteAnalyser` | Byte-preserving tokenizer used by the site scan and menu tagging. |
|
||||
| `FileStore` | Locked, atomic JSON metadata storage. |
|
||||
| `Activity`, `Logger` | Dashboard activity feed and daily audit log. |
|
||||
| `LoginRateLimiter` | Hashed address and identity throttling, also used for password confirmation and invites. |
|
||||
|
||||
## Storage Model
|
||||
|
||||
@@ -29,15 +48,18 @@ Published content stays in the web document root. NeoCMS stores supporting data
|
||||
|
||||
| Path or document | Contents |
|
||||
| --- | --- |
|
||||
| `drafts/` | Complete unpublished HTML documents, named by a hash of their URI. |
|
||||
| `drafts/` | Complete unpublished HTML documents, named by a hash of their URI. New pages live only here until first published. |
|
||||
| `revisions/` | Immutable snapshots of earlier published documents. |
|
||||
| `scheduled/` | Complete HTML staged for future publication. |
|
||||
| `drafts.json` | Human-readable draft ownership and timestamp metadata. |
|
||||
| `revisions.json` | Revision URI, author, reason, and timestamp index. |
|
||||
| `schedules.json` | Publication times and staged-document identifiers. |
|
||||
| `shared.json` | Named global HTML fragments. |
|
||||
| `menus.json` | Named flat menu structures with optional parent labels. |
|
||||
| `media.json` | Reusable image alternative text. |
|
||||
| `menus.json` | Named menus: `{key: {items: [{label, url, parent}], updated, title?}}`; seeded by the site scan, never created by the CMS. |
|
||||
| `newpages.json` | Pending (unpublished) pages: title, chosen menu, template, author, created. |
|
||||
| `pagetemplates.json` | Pages offered as templates in the New page section of Pages. |
|
||||
| `users.json` | CMS-created accounts and profile overlays for config accounts (`{users: {...}, profiles: {...}}`). |
|
||||
| `media.json` | Per-file description (alt text or link text), original filename, and uploader. |
|
||||
| `activity.json` | The latest 250 dashboard activity entries. |
|
||||
| `login-attempts.json` | Hashed address and identity buckets used for login throttling. |
|
||||
|
||||
@@ -53,26 +75,103 @@ it back to the iframe only. Drafting, scheduling, and publishing serialise a clo
|
||||
removing temporary CMS controls.
|
||||
|
||||
`neo-dupe` marks a repeatable block. `data-neo-shared="key"` marks a globally managed fragment. `data-neo-menu="name"`
|
||||
marks generated navigation that should be replaced whenever its named menu changes.
|
||||
marks a `<nav>` whose list is replaced whenever its named menu changes. `data-neo-image` marks an image outside an editable
|
||||
region that can be replaced through the image dialogue.
|
||||
|
||||
The bundled templates use the default `editable` class. Sites that override `editableClass` must update their templates
|
||||
to match; otherwise the resulting page is valid HTML but, quite reasonably, is not treated as editable by NeoCMS.
|
||||
|
||||
## Roles
|
||||
## Site Scan
|
||||
|
||||
`SiteAnalyser` tokenises each page (skipping comments, scripts, styles, and templates) to locate element offsets, then
|
||||
`apply()` splices markers into the original bytes. It deliberately avoids DOM re-serialisation so an automated pass over
|
||||
every page cannot rewrite unrelated markup. The browser drives the scan in small batches so it can show a progress bar and page count: `listSitePages` returns the
|
||||
page list, `analyseSitePages` reports a plan for up to 100 pages per request (the client totals the results), and
|
||||
`applySiteTagging` reuses `rewritePages()` to write only the pages it is given, after taking a revision of each,
|
||||
through the same atomic-replace path as publishing. All three need the administrator role. Local asset references are resolved and confined by
|
||||
`PagePaths::refExists()`.
|
||||
|
||||
## Media
|
||||
|
||||
`MediaTypes` is the single source of truth for uploads. Its allow-list maps each extension to one of four categories (imagery,
|
||||
documents, video, audio); the category is derived from the extension whenever the library is listed, and there is no separate
|
||||
category field to drift. `MediaTypes::inspect()` runs for every upload in `cms/image_upload.php` (the endpoint TinyMCE also uses):
|
||||
extension allow-list plus a deny list applied to every dot-separated part of the original name, content sniffing with `finfo`
|
||||
against the extension, refusal of executable signatures, a chunked scan of the whole file for code markers, and per-format
|
||||
checks (image decoding within the size limits, PDF actions, and a small built-in ZIP directory reader for OOXML and
|
||||
OpenDocument packages that refuses macros, embedded objects, scripts, and executables without needing the `zip` extension).
|
||||
Files are stored under `uploads/` as `slug-8hex.ext` (one dot only) or as legacy 32-hex names, both matched by
|
||||
`MediaTypes::nameRegex()`, which the usage counter, the site scan, and `deleteMedia` all share. `uploads/.htaccess` denies
|
||||
executable and markup extensions, sets `nosniff`, and serves documents with `Content-Disposition: attachment`. The editor's
|
||||
**Media library** button opens the same dialog in pick mode and inserts an `<img>`, a document link, or a `<video>`/`<audio>`
|
||||
element by category.
|
||||
|
||||
## New Pages and Templates
|
||||
|
||||
`newPageAction` reads the chosen template (a file in `cms/templates/` or `page:<uri>` for a page marked in `pagetemplates.json`),
|
||||
sets its title, and stores it only as a private draft plus a `newpages.json` record. No file is created, so the page cannot be
|
||||
served. `publishContent` (used by Publish Now and by scheduled publishing) creates the file on first publication, then
|
||||
`finishNewPage` adds the link to the chosen menu, propagates it, and removes the pending record. The preview writes the draft into
|
||||
an `about:blank` frame with an injected `<base>` element so relative assets resolve. Page filenames come from the name via
|
||||
`uniqueUri`, never from user input.
|
||||
|
||||
## Navigation Menus
|
||||
|
||||
`SiteAnalyser::navs()` classifies each `<nav>` by its position and label (header, banner and main navigation are `primary`;
|
||||
footer and legal are `secondary`). The scan tags it with `data-neo-menu` and seeds `menus.json` from the first page that has it
|
||||
(existing menus are never overwritten). `ContentDom::withMenu()` replaces only the inner list of a marked `<nav>` in the page's
|
||||
original bytes, sets `aria-current="page"` for the current page, and returns null when nothing changes so the page is not
|
||||
rewritten. The CMS cannot create a menu: `saveMenu` requires an existing key and may change its items and display `title`.
|
||||
|
||||
## Deleted Page Recovery
|
||||
|
||||
Deleting a page first stores its HTML as a revision with the reason "Before delete". The dashboard's `deleted` list (editors
|
||||
and administrators) contains pages whose newest revision has that reason and whose file no longer exists; **Recover** calls
|
||||
`restoreRevision`, which recreates the file from the snapshot.
|
||||
|
||||
## Subfolder Installs
|
||||
|
||||
`bootstrap.php` derives `basePath` from the script URL and `siteRoot` from the folder containing `cms/`. Stored menu links and
|
||||
page URLs carry the prefix, and the session cookie path is `basePath/cms`. Both values can be overridden in configuration.
|
||||
|
||||
## Roles
|
||||
Capabilities are intentionally small and cumulative:
|
||||
|
||||
| Role | Capabilities |
|
||||
| --- | --- |
|
||||
| `editor` | Save drafts and upload images. |
|
||||
| `publisher` | Editor capabilities plus publish, schedule, and restore revisions. |
|
||||
| `administrator` | Publisher capabilities plus page, media, shared-content, and menu management. |
|
||||
| `editor` | Save drafts, upload images, publish, schedule, restore revisions, and recover deleted pages (the old `publisher` role is an alias). |
|
||||
| `administrator` | Editor capabilities plus page, media, shared-content, menu, template, and user management. |
|
||||
|
||||
The interface hides controls the user cannot invoke, but server-side checks remain authoritative. Hidden buttons are a
|
||||
convenience, not a security boundary wearing a clever disguise.
|
||||
|
||||
Active sessions are bound to the current configured password hash. Removing an account or changing its hash invalidates
|
||||
the session on the next request, while roles are re-read from configuration so demotion is immediate.
|
||||
Active sessions are bound to the account's current password hash. Removing, blocking, or re-passwording an account invalidates
|
||||
its sessions on the next request, while roles are re-read on every request so a demotion is immediate.
|
||||
|
||||
## Users and Accounts
|
||||
|
||||
`UserStore` merges two sources. Accounts in `config.local.php` are authoritative and locked: they are always active, cannot be
|
||||
edited, blocked, or deleted from the UI, and cannot be shadowed by a CMS account (only a display name and email overlay is
|
||||
stored for them). Accounts created in the UI live in `data/users.json` as `{hash, role, name, email, status, invite?}` with status
|
||||
`active`, `blocked`, or `invited`. `UserStore::authArgs()` supplies `Authentication` with the config accounts plus **active** CMS
|
||||
accounts only, which is why blocked or deleted users lose their sessions without any extra code. Sign-in uses the login name only; the
|
||||
email address is contact information (unique, but not a login identifier).
|
||||
|
||||
Actions: `users` (GET; administrators also receive the account list, never hashes or tokens), `saveProfile` and `changePassword`
|
||||
(any signed-in user, acting only on their own account taken from the session), and `saveUser`, `inviteUser`, `blockUser`, and
|
||||
`deleteUser` (administrators). Sensitive actions and email or password changes require the actor's current password, checked
|
||||
by `confirmPassword()` and throttled with `LoginRateLimiter`. Invitations are 32 random bytes; only the SHA-256 is stored, the link
|
||||
works once and expires after 7 days, and acceptance goes through `cms/login/?invite=TOKEN`. Passwords are 12 to 72 bytes
|
||||
(bcrypt truncates beyond 72), must differ from the username and email, and are stored with `password_hash`. Guard rails in the
|
||||
store and controller refuse self-block, self-delete, self-demotion, duplicate usernames and emails, and any change to a config
|
||||
account.
|
||||
|
||||
## Interface
|
||||
|
||||
The administration shell is a full-height grid: logo and page path on the left, the signed-in user and Log out at the top right,
|
||||
and the toolbar buttons plus the desktop/tablet/mobile icons beneath them (wrapping to their own row below 1100px). Dialogues are
|
||||
jQuery UI dialogues built from shared helpers (`dataTable`, `rowButton`), with a snackbar for results. Row buttons are small
|
||||
secondary buttons, action buttons (Save, Next, Apply, and the editor buttons) are a step larger, and the primary action is blue.
|
||||
|
||||
## Scheduled Publishing
|
||||
|
||||
@@ -83,9 +182,10 @@ Every API request opportunistically processes due jobs. Reliable unattended oper
|
||||
|
||||
NeoCMS relies on PHP sessions with strict mode, HTTP-only cookies, SameSite=Lax, and Secure cookies under HTTPS. Mutating
|
||||
requests require a constant-time-checked CSRF token. Page paths are canonicalised and constrained to HTML files beneath the
|
||||
document root, excluding the CMS directory and symbolic links. Uploads are constrained by byte size, detected MIME type,
|
||||
image decoding, dimensions, pixel count, file count, and total storage. Login attempts are throttled by hashed address and
|
||||
address/username buckets.
|
||||
document root, excluding the CMS directory and symbolic links. Uploads are constrained by an extension allow-list, byte size, detected MIME type,
|
||||
code scanning, image decoding, dimensions, pixel count, file count, and total storage. Login attempts are throttled by hashed address and
|
||||
address/username buckets. Account data is protected by hashing only, withholding hashes and tokens from every response, requiring
|
||||
the actor's password for sensitive account actions, rate limiting those checks, and auditing every account event without secrets.
|
||||
|
||||
HTML and JSON responses receive Content Security Policy, anti-framing, no-sniffing, referrer, permissions, cross-origin,
|
||||
cache-control, and conditional HSTS headers. CDN scripts and styles are fixed to explicit versions and protected by SRI.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes made to NeoCMS in this contribution: the `cms/` directory, `uploads/.htaccess`, `README.md`, `docs/`, and the updated `tests/run.php`.
|
||||
|
||||
This round builds on pull request #39 (site scan, editor workflow, subfolder support), which is already merged upstream and is summarised at the end.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **Navigation menus from the scan**: the site scan detects each page's header `<nav>` (primary) and footer `<nav>` (secondary), marks it `data-neo-menu` in place, and pre-fills Navigation Menus from it (a saved menu is never overwritten). Menu saves now keep the nav's own attributes, leave other bytes alone, skip unchanged pages and set `aria-current` per page. The menu form only appears after choosing Edit on an existing menu, and the CMS cannot create menus (they come from the site's pages via the scan); the display name can be changed without affecting the menu's key. Menus can be deleted (pages keep their current navigation). Navigation Menus has a "Scan site for new navigation" button that finds navigation blocks not yet attached to a menu (including ones a developer adds later) and exposes them. The Navigation Menus button moved to the main toolbar.
|
||||
- **New page wizard**: New Page is now three steps (template, page name, navigation group). The filename is generated from the name (`about-us.html`, numbered if taken). The page is saved only as a private draft, so it is not visible on the site until first published; publishing creates the file and adds a link to the chosen menu on every page. Unpublished pages show as "New - unpublished" in Pages and can be discarded. Scheduling works for them too.
|
||||
- **Recover deleted pages**: the Dashboard lists deleted pages (those whose newest revision is "Before delete" and whose file is gone) with a Recover button for editors, which recreates the page from its snapshot. New `deleted` field in the `dashboard` response.
|
||||
- **Consistent buttons**: row buttons (Edit, Delete, ...) and other dialogue buttons use one small size (12px, 4px 9px padding, 4px radius); action buttons (editor Save Draft/Schedule/Publish/Cancel, dialogue Save/Next/Back/Apply) are a step larger (13px, 6px 14px). Secondary buttons are white with a border, destructive ones have red text, and primary actions (Save, Next, Apply) are filled blue. Editor buttons keep their Save Draft blue text and green Publish Now.
|
||||
- **Top bar**: a full-width grid. The logo and page path fill the left; the user name and Log out are always at the top right, with the menu buttons and device icons directly beneath them. Below 1100px the buttons take their own row; below 760px everything stacks but the user block stays top right. The logo URL is now relative so it shows in a subfolder install.
|
||||
- **Users** (Tools > Users): every user can update their display name and email address and change their password; administrators can add, edit, invite (one-time link, 7 days), block, unblock and delete accounts. Sign-in uses the login name only; the email address is contact information. Accounts in `config.local.php` stay controlled by config (only display name and email can be set for them); CMS accounts live in `data/users.json`. Roles are now **Editor** (draft, upload, publish, schedule, restore) and **Administrator**; `publisher` remains an alias of editor. Security: password hashes only (12 to 72 characters), no secrets in responses, invite tokens stored hashed and single use, administrator password confirmation for sensitive actions, rate-limited failures, self-lockout guards, sessions of blocked/deleted/re-passworded users end on their next request, every account event audited. New `UserStore` class; actions `users`, `saveProfile`, `changePassword`, `saveUser`, `inviteUser`, `blockUser`, `deleteUser`.
|
||||
- **Documentation**: README.md and docs/ARCHITECTURE.md are now kept in step with the changes (roles and Users, navigation menus, New Page wizard and templates, deleted-page recovery, subfolder installs, interface) and are tracked in the local history.
|
||||
- **Data outside the web root**: `dataDirectory` is honoured everywhere (verified live: accounts, rate limiting, activity and the cron worker all use it; the dashboard writable check now checks it too). Administrators see a Dashboard recommendation while the data folder is inside the web root, and the README install steps and `config.local.php.example` describe the setting.
|
||||
- **Login name wording**: the Users screen and login page call the username a "login name" (shown read-only in My account and in the account list); the email field is labelled as contact details, not a sign-in identifier.
|
||||
- **Media library for images, documents, video and audio**: Media now uploads files (multiple at once) into four categories chosen by extension (Imagery, Documents, Video, Audio), with category tabs and counts, per-file description, Copy link and Delete. The editor has a **Media library** button that inserts an image, a document link, a video or an audio player at the cursor. Safety: an extension allow-list (no `.exe`, scripts, HTML/SVG, archives, legacy or macro Office files), filenames such as `report.exe.pdf` refused, content sniffing, refusal of executable signatures and of any file containing `<?php`, `<?=`, `<script` or `<%`, PDFs with JavaScript/launch/embedded files refused, and Office/OpenDocument packages inspected for macros, embedded objects and scripts (built-in ZIP reader, no `zip` extension needed). New `MediaTypes` class; `image_upload.php` now handles all media and returns `name` and `category`; `mediaAction` adds `category`, `ext` and `original`; new stored names are `slug-8hex.ext` (legacy names still work). `uploads/.htaccess` (outside `cms/`) denies more executable extensions and serves documents as downloads. TinyMCE toolbar now wraps so all buttons are visible. The code scan only looks for the long markers (`<?php`, `<script`) in binary files and adds the short ones (`<?=`, `<%@`, `<%=`) for text files only, because compressed video and audio contain short byte sequences by chance; audio and video whose type libmagic cannot name are accepted when their container signature matches; oversize files report the limit.
|
||||
- **New page merged into Pages**: the New Page toolbar button and its separate dialogue are gone. Pages is now one dialogue with the page list on top and, for administrators, a **New page** section beneath it holding the same three-step wizard (template, page name, navigation group). Creating a page closes the dialogue and opens the private draft for editing. The dialogue scrolls inside the window when it is tall.
|
||||
- **Welcome page**: updated the built-in welcome sections for the New Page wizard, templates, navigation menus, site scan and device icons.
|
||||
- **Pages as templates**: each row in the page list has a "Create Template" action (administrators). The page is then offered in New Page beside the files in `cms/templates/`; the new page is a private copy with its title replaced. Renaming or deleting a template page keeps the list in step.
|
||||
- **Template list**: the New Page template and navigation-group lists use the page-table styling; each template has a Delete action (a template file is deleted; a page used as a template is only unmarked).
|
||||
- **Device preview**: the Desktop/Tablet/Mobile buttons are icons (with tooltips and accessible names) and the active one is highlighted.
|
||||
|
||||
### Migration notes
|
||||
- New Page is now a section of the Pages dialogue (there is no New Page toolbar button).
|
||||
- Roles: `publisher` is merged into `editor` (editors can now publish and schedule). Existing `publisher` assignments keep working as an alias; review any account that was a plain `editor`, because it can now publish.
|
||||
- Accounts: config accounts are unchanged. CMS-created accounts are stored in `cms/data/users.json`. Sign-in is by login name only.
|
||||
- Media: new uploads are named `slug-8hex.ext` and accept documents, video and audio; existing 32-hex image names keep working. Copy the updated `uploads/.htaccess` (outside `cms/`).
|
||||
- New optional setting `dataDirectory`: set it to a folder outside the web root on live sites. The default `cms/data/` keeps working.
|
||||
- Navigation menus can no longer be created from the CMS; they are discovered by the site scan (existing menus are kept). New pages are private drafts until published.
|
||||
- New API actions: `users`, `saveProfile`, `changePassword`, `saveUser`, `inviteUser`, `blockUser`, `deleteUser`, `newPage` (changed), `discardNewPage`, `setPageTemplate`, `deleteTemplate`, `deleteMenu`; the dashboard response gains `deleted` and `notices`; `media` items gain `category`, `ext` and `original`.
|
||||
|
||||
### Verification
|
||||
- The updated integration suite (`tests/run.php`, included in this contribution) passes, covering concurrency, site scan and tagging, menus, new pages
|
||||
and templates, deleted-page recovery, users and roles, invitations, media type checks, and audit-log cases. Upstream's earlier `tests/run.php` expects
|
||||
behaviour that has intentionally changed (editors cannot publish, menus can be created, New Page copies immediately), so the updated suite replaces it.
|
||||
On Windows the Linux-only permission assertions are skipped.
|
||||
|
||||
## Merged in pull request #39
|
||||
|
||||
### Added
|
||||
- **Site scan** (Tools > Site scan, administrators): analyses every managed page and the media library, then makes pages editable
|
||||
in one step.
|
||||
- Reports page and folder counts, pages that can be made editable, images without alt text, broken local image/CSS/script
|
||||
references, unused uploads, and missing title, description and Open Graph tags.
|
||||
- Tags content containers (`<main>`, else top-level `<article>`/text-bearing `<section>`; header, nav, footer, aside and
|
||||
forms are skipped, `<body>` is never tagged), images outside editable regions (`data-neo-image`) and photo placeholders
|
||||
(`role="img"` boxes), and inserts only missing SEO tags. Existing tags are never overwritten.
|
||||
- Edits page source byte-for-byte (a tokenizer splices markers in; no DOM re-serialisation), keeps a revision of every changed
|
||||
page, and after the first run lists only pages that still have something to change.
|
||||
- Runs in small batches from the browser with a progress bar and "N of M pages" count, for both analysing and updating.
|
||||
- New administrator-only actions: `listSitePages`, `analyseSitePages`, `applySiteTagging`.
|
||||
- **Click-to-edit images and photo placeholders**: clicking an image (or a photo-placeholder box) opens an image dialog to pick from
|
||||
the Media Library or change the address and alt text. Clicking other text still opens the content editor.
|
||||
- **Subfolder installs**: the site can live in a subfolder (for example `http://localhost/neocms/`). The URL prefix is detected
|
||||
automatically (`basePath`), the site root is the folder containing `cms/` (`siteRoot`), and the session cookie path follows.
|
||||
Root installs behave exactly as before.
|
||||
- **Audit log coverage**: cancelling a schedule, uploading an image, changing alt text and failed scheduled publications are now
|
||||
recorded in the Dashboard activity feed and the daily audit log (new shared `Activity` class).
|
||||
- **New interface**: bottom-centre snackbar with success/error states and a close button; table-style dialogs (page picker, revision
|
||||
history, dashboard, shared content, navigation menus, site scan) with icons, draft/status badges and per-row actions.
|
||||
|
||||
### Changed
|
||||
- **Editing workflow**: Save Draft, Schedule Publish and Publish Now moved from the main toolbar into the content editor, which now
|
||||
offers `Save Draft | Schedule Publish | Publish Now | Cancel`. Saving in the editor (and applying image/SEO changes) stores a
|
||||
private draft; the header shows "Draft:" when one exists. Publish Now keeps the accessibility confirmation and the editor stays
|
||||
open if it is cancelled.
|
||||
- **Structure**: `CMSController` split into `PagePaths` (URI/path safety), `ContentDom` (HTML transforms) and `Activity`; entry
|
||||
points share `cms/bootstrap.php` (config + autoloader) instead of four copies.
|
||||
- Restoring a deleted page now uses a `PageNotFoundException` instead of matching an error message.
|
||||
|
||||
### Fixed
|
||||
- **Lost updates**: read-modify-write of JSON metadata is now serialised with `FileStore::update()` (file locking).
|
||||
- **Non-atomic site-wide writes**: shared-content and menu propagation now replace pages atomically, and non-ASCII text survives.
|
||||
- **Scheduled publishing** no longer contends for a lock on every request and cannot fail unrelated API calls.
|
||||
- **Revision pruning** could drop the newest revision when several were created in the same second; revisions now carry a
|
||||
precise `ts`.
|
||||
- **Stale preview**: pages replaced on disk could show an old cached copy in the editor preview; each newly opened page is revalidated.
|
||||
- **Draft reload**: a saved draft loaded into the preview lost its stylesheets and page path (relative URLs resolved inside `/cms/`),
|
||||
and could make Publish target the wrong address.
|
||||
- Page titles containing HTML entities are decoded in the page list; PHP `dirname()` backslashes no longer leak into folder names.
|
||||
- Draft prompts are offered again after a draft is saved within the same session (previously Publish could discard the draft).
|
||||
|
||||
### Migration notes
|
||||
- No manual steps. New optional config keys `basePath` and `siteRoot` are auto-detected (`bootstrap.php`); set `basePath` only
|
||||
behind a path-rewriting proxy.
|
||||
- `revisions.json` entries gain a `ts` field (older entries continue to work).
|
||||
- The `analyseSite` action was replaced by `listSitePages` and `analyseSitePages`.
|
||||
- The toolbar no longer has Save Draft, Schedule or Publish buttons; they are in the content editor.
|
||||
+42
-3
@@ -5,20 +5,29 @@
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `cms/src/NeoCMS/Authentication.php` | Sessions, credentials, roles, capabilities, and CSRF tokens. |
|
||||
| `cms/src/NeoCMS/CMSController.php` | API dispatch and all page, draft, revision, schedule, media, menu, and shared-content operations. |
|
||||
| `cms/src/NeoCMS/FileStore.php` | Atomic JSON metadata storage and managed directories. |
|
||||
| `cms/src/NeoCMS/CMSController.php` | API dispatch and the page, draft, revision, schedule, media, menu, and shared-content actions. |
|
||||
| `cms/src/NeoCMS/FileStore.php` | Atomic JSON metadata storage, locked `update()` read-modify-write, and managed directories. |
|
||||
| `cms/src/NeoCMS/UserStore.php` | Accounts: locked config accounts plus CMS accounts in `data/users.json`, invitations, and the password policy. |
|
||||
| `cms/src/NeoCMS/MediaTypes.php` | Allowed media extensions and categories, and the upload safety checks (`inspect()`). |
|
||||
| `cms/src/NeoCMS/Activity.php` | Records an event in both the dashboard feed and the audit log. |
|
||||
| `cms/src/NeoCMS/PagePaths.php` | URI normalisation and containment of page paths inside the document root. |
|
||||
| `cms/src/NeoCMS/SiteAnalyser.php` | Byte-exact analysis and tagging of content regions, images, and SEO tags for the Site scan. |
|
||||
| `cms/src/NeoCMS/ContentDom.php` | Pure HTML transforms for shared regions, menus, and editable-class detection. |
|
||||
| `cms/bootstrap.php` | Shared entry-point setup: configuration and autoloader. |
|
||||
| `cms/src/NeoCMS/LoginRateLimiter.php` | Locked, filesystem-backed login throttling. |
|
||||
| `cms/src/NeoCMS/Logger.php` | Daily audit logging with field normalisation, size rotation, and retention. |
|
||||
| `cms/src/NeoCMS/SecurityHeaders.php` | CSP, anti-framing, cache, transport, and browser-policy headers. |
|
||||
| `cms/js/cms.js` | Iframe editing, dialogues, workflow state, and API client. |
|
||||
| `cms/index.php` | Authenticated administration shell. |
|
||||
| `cms/image_upload.php` | Multipart image upload endpoint. |
|
||||
| `cms/image_upload.php` | Multipart media upload endpoint (images, documents, video, audio); also used by TinyMCE for pasted images. |
|
||||
| `cms/publish_scheduled.php` | CLI scheduled-publication worker. |
|
||||
| `tests/run.php` | Isolated integration suite. |
|
||||
|
||||
TinyMCE 8.6.0 beneath `cms/tinymce/` is vendored third-party code and should not be reformatted or documented as first-party
|
||||
source. Its package metadata, changelog, and licence information remain in that directory.
|
||||
|
||||
Every read-modify-write of a JSON document must go through `FileStore::update()`; plain `read()` then `write()` loses concurrent updates.
|
||||
|
||||
## Documentation Style
|
||||
|
||||
Comments use British English and explain intent, constraints, side effects, and security decisions. They should not narrate
|
||||
@@ -74,3 +83,33 @@ ceilings. New HTML entry points should use `SecurityHeaders` before emitting out
|
||||
The integration suite creates unique public and metadata directories under the operating system's temporary directory. It
|
||||
seeds an authenticated administrator session, invokes controller actions directly, and cleans up in a `finally` block.
|
||||
Tests must never rely on or modify the repository's public HTML, uploads, logs, or data files.
|
||||
|
||||
## Local Testing on XAMPP (Windows)
|
||||
|
||||
Place the project in XAMPP's `htdocs` folder (for example `htdocs/neocms/`) and browse to `http://localhost/neocms/cms/`. No virtual host is
|
||||
needed: NeoCMS detects the `/neocms` URL prefix from the request and uses the folder containing `cms/` as the site root. On a
|
||||
live site at the domain root the prefix is empty and behaviour is unchanged. If a proxy hides the real path, set `'basePath'`
|
||||
in `cms/config.local.php`.
|
||||
|
||||
1. Copy `cms/config.local.php.example` to `cms/config.local.php`.
|
||||
2. Generate a hash (use XAMPP's `php.exe` if `php` is not on your PATH): `php -r "echo password_hash('choose-a-password', PASSWORD_DEFAULT);"`.
|
||||
3. Add the hash under `authentication` and a role under `roles`, then browse to `/cms/`. This config account is the main administrator; further accounts are added in Tools > Users.
|
||||
|
||||
Windows ignores `chmod`, so private-file permissions are not enforced there; the permission assertions in `tests/run.php`
|
||||
run only on Linux. Keep `.htaccess` enabled (`AllowOverride All`) so `cms/data/` and `cms/logs/` stay private.
|
||||
|
||||
## Production Checklist (Linux + Apache)
|
||||
|
||||
- Serve over HTTPS only. Behind a TLS-terminating proxy set `'security' => ['cookieSecure' => true]` in `config.local.php`.
|
||||
- Create `cms/config.local.php` with password hashes for the main administrator(s) (`editor` or `administrator` roles); never commit it. Other accounts are created in Tools > Users.
|
||||
- Set `'dataDirectory'` in `config.local.php` to an absolute folder **outside the web root** (for example `/var/neocms-data`, writable by PHP). Accounts, drafts and revisions then can never be served. Administrators see a Dashboard recommendation until this is done.
|
||||
- Copy `uploads/.htaccess` with the site: it denies executable and markup extensions and serves documents as downloads. On Nginx add the `/uploads/` rule from the README.
|
||||
- Enable `AllowOverride All` for the site so the shipped `.htaccess` files apply, and confirm `mod_headers` is loaded.
|
||||
- Own the writable paths (`cms/data/`, `cms/logs/`, `uploads/`, and managed HTML) by the PHP user, for example `www-data`.
|
||||
- Set PHP `post_max_size` and `upload_max_filesize` to at least `uploads.maxFileBytes` (10 MiB by default; raise both together for video). The PHP `fileinfo` extension is required for upload checks; the `zip` extension is not.
|
||||
- Add the cron entry from the README for `cms/publish_scheduled.php`.
|
||||
- The admin loads jQuery from `code.jquery.com`; self-host it and update `cms/index.php` and the CSP if the server must work offline.
|
||||
- Back up `cms/data/` together with the public HTML.
|
||||
- Verify after deploying: `/cms/data/`, `/cms/logs/`, `/cms/config.php`, and any `.php` or `.exe` placed in `/uploads/` return 403; the session cookie is `Secure`;
|
||||
five wrong passwords trigger throttling; `php tests/run.php` passes on the server.
|
||||
- Upload `cms/`, `uploads/`, and the HTML to the site root. Nothing needs changing from the XAMPP setup. Links written as root-relative (`/about.html`) resolve under `/neocms/` only on the live site, so use relative links or test them after deployment.
|
||||
+414
-10
@@ -197,12 +197,6 @@ try {
|
||||
if (function_exists('symlink') && @symlink($outside . '/secret.html', $root . '/leak.html')) {
|
||||
assertTrue(count($request('getPages')) === 1, 'Page discovery followed an external file symlink');
|
||||
}
|
||||
if (function_exists('symlink') && @symlink($outside, $root . '/linked')) {
|
||||
assertRejected(
|
||||
fn() => $request('newPage', ['template' => 'example-template.html', 'filename' => '/linked/escaped.html'], 'POST'),
|
||||
'New-page creation followed an external directory symlink'
|
||||
);
|
||||
}
|
||||
|
||||
// Drafts must remain private and leave the public HTML untouched.
|
||||
$draftContent = str_replace('Original', 'Draft', $page);
|
||||
@@ -229,20 +223,30 @@ try {
|
||||
assertTrue(is_file($root . '/renamed.html'), 'Duplicate or rename failed');
|
||||
$request('page', ['operation' => 'delete', 'uri' => '/renamed.html'], 'POST');
|
||||
assertTrue(!is_file($root . '/renamed.html'), 'Page delete failed');
|
||||
assertTrue(in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Deleted page was not listed on the dashboard');
|
||||
$deletedRevisions = $request('revisions', ['uri' => '/renamed.html']);
|
||||
$request('restoreRevision', ['id' => $deletedRevisions[0]['id']], 'POST');
|
||||
assertTrue(is_file($root . '/renamed.html'), 'Deleted page revision was not restored');
|
||||
assertTrue(!in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Recovered page is still listed as deleted');
|
||||
|
||||
// Shared content must propagate ordinary forgiving HTML fragments to marked pages.
|
||||
$request('saveShared', ['key' => 'footer', 'content' => '<strong>Shared<br>footer</strong>'], 'POST');
|
||||
assertTrue(str_contains((string) file_get_contents($root . '/index.html'), '<strong>Shared<br>footer</strong>'), 'Shared HTML was not propagated');
|
||||
|
||||
// Non-ASCII text must survive propagation unchanged (libxml assumes Latin-1 without a hint).
|
||||
$request('saveShared', ['key' => 'footer', 'content' => '<em>Café – 日本語</em>'], 'POST');
|
||||
// libxml may write entities (é); they must decode back to the original text, not to Latin-1 mojibake.
|
||||
assertTrue(str_contains(html_entity_decode((string) file_get_contents($root . '/index.html'), ENT_QUOTES, 'UTF-8'), '<em>Café – 日本語</em>'), 'Non-ASCII shared content was mangled');
|
||||
|
||||
// Nested menus must render and replace existing generated navigation regions.
|
||||
$items = json_encode([
|
||||
['label' => 'Home', 'url' => '/', 'parent' => ''],
|
||||
['label' => 'Team', 'url' => '/team.html', 'parent' => 'Home'],
|
||||
]);
|
||||
$request('saveMenu', ['name' => 'main', 'items' => $items], 'POST');
|
||||
(new NeoCMS\FileStore($data))->write('menus', ['main' => ['items' => [], 'updated' => date(DATE_ATOM)]]);
|
||||
assertRejected(fn() => $request('saveMenu', ['name' => 'brand-new', 'items' => $items], 'POST'), 'The CMS created a menu that does not exist');
|
||||
$request('saveMenu', ['name' => 'main', 'title' => 'Main navigation', 'items' => $items], 'POST');
|
||||
assertTrue(($request('menus')['main']['title'] ?? '') === 'Main navigation' && !isset($request('menus')['brand-new']), 'Menu was not renamed in place');
|
||||
$menuPage = (string) file_get_contents($root . '/index.html');
|
||||
assertTrue(str_contains($menuPage, 'href="/team.html"') && str_contains($menuPage, 'data-neo-menu="main"'), 'Menu was not propagated');
|
||||
|
||||
@@ -255,7 +259,20 @@ try {
|
||||
$dashboard = $request('dashboard');
|
||||
assertTrue(isset($dashboard['schedules'][$schedule['id']]), 'Schedule was not visible on the dashboard');
|
||||
$request('cancelSchedule', ['id' => $schedule['id']], 'POST');
|
||||
assertTrue(!isset($request('dashboard')['schedules'][$schedule['id']]), 'Schedule was not cancelled');
|
||||
$afterCancel = $request('dashboard');
|
||||
assertTrue(!isset($afterCancel['schedules'][$schedule['id']]), 'Schedule was not cancelled');
|
||||
assertTrue(in_array('Cancelled scheduled publication', array_column($afterCancel['activity'], 'action'), true), 'Cancelling a schedule was not recorded in the activity log');
|
||||
$request('cancelSchedule', ['id' => $schedule['id']], 'POST'); // cancelling twice must not log a second event
|
||||
assertTrue(count(array_filter($request('dashboard')['activity'], fn($e) => $e['action'] === 'Cancelled scheduled publication')) === 1, 'A schedule that no longer exists was logged as cancelled');
|
||||
|
||||
// Alt-text edits and uploads are recorded too.
|
||||
$request('updateMedia', ['name' => str_repeat('a', 32) . '.png', 'alt' => 'A test image'], 'POST');
|
||||
assertTrue(in_array('Updated image alt text', array_column($request('dashboard')['activity'], 'action'), true), 'Alt text change was not recorded');
|
||||
$uploadActivityDir = sys_get_temp_dir() . '/neocms-activity-' . bin2hex(random_bytes(4));
|
||||
(new NeoCMS\Activity(new NeoCMS\FileStore($uploadActivityDir), new NeoCMS\Logger(false, [])))->record('tester', 'Uploaded image', 'x.png (5 KB)');
|
||||
$recorded = (new NeoCMS\FileStore($uploadActivityDir))->read('activity');
|
||||
assertTrue(count($recorded) === 1 && $recorded[0]['action'] === 'Uploaded image' && $recorded[0]['user'] === 'tester', 'Upload activity entry was not written');
|
||||
removeTree($uploadActivityDir);
|
||||
assertRejected(
|
||||
fn() => $request('schedule', ['uri' => '/missing.html', 'content' => $published, 'publish_at' => date(DATE_ATOM, time() + 3600)], 'POST'),
|
||||
'A schedule targeting a missing page was accepted'
|
||||
@@ -292,6 +309,7 @@ try {
|
||||
assertTrue((new NeoCMS\CMSController($config))->publishScheduled() === 0, 'Invalid scheduled target was reported as published');
|
||||
$dashboardAfterFailure = $request('dashboard');
|
||||
assertTrue(($dashboardAfterFailure['schedules'][$failed['id']]['status'] ?? '') === 'failed', 'Failed schedule was not isolated');
|
||||
assertTrue(in_array('Scheduled publication failed', array_column($dashboardAfterFailure['activity'], 'action'), true), 'A failed scheduled publication was not shown in the activity feed');
|
||||
|
||||
// Revision retention limits must cap both per-page and total private storage metadata.
|
||||
$revisionIndex = json_decode((string) file_get_contents($data . '/revisions.json'), true);
|
||||
@@ -300,8 +318,394 @@ try {
|
||||
assertTrue($indexRevisionCount <= 2, 'Per-page revision retention limit was exceeded');
|
||||
|
||||
// Private CMS state should not be readable by unrelated operating-system users.
|
||||
assertTrue((fileperms($data) & 0777) === 0700, 'CMS data directory permissions are too broad');
|
||||
assertTrue((fileperms($schedulePath) & 0777) === 0600, 'CMS metadata file permissions are too broad');
|
||||
// ---- Site scan: byte-exact tagging of content containers, images, and SEO tags ----
|
||||
$all = ['content' => true, 'images' => true, 'seo' => true];
|
||||
$scanPage = "<!DOCTYPE html>\r\n<html><head>\r\n <meta charset=\"utf-8\">\r\n</head>\r\n<body>\r\n"
|
||||
. "<header class='top'><nav><a href=\"/\">Home</a></nav></header>\r\n"
|
||||
. "<script>var s = '<main>';</script>\r\n"
|
||||
. "<main class=\"wide\">\r\n<h1>Café – Über uns</h1>\r\n<p>Wir sind ein kleines Team mit grossen Ideen.</p>\r\n<img src=\"/photo.jpg\" alt=\"\">\r\n</main>\r\n"
|
||||
. "<footer><p>Footer text that is long enough to count as text.</p></footer>\r\n"
|
||||
. "<img src=\"/logo.png\">\r\n<img src=\"x.gif\" width=\"1\" height=\"1\">\r\n</body></html>";
|
||||
$tagged = NeoCMS\SiteAnalyser::apply($scanPage, 'cms-content', $all);
|
||||
assertTrue($tagged !== null, 'Site scan found nothing to tag');
|
||||
$out = $tagged['html'];
|
||||
assertTrue(str_contains($out, '<main class="wide cms-content">'), 'Existing class attribute was not merged');
|
||||
assertTrue(!str_contains($out, "<header class='top cms-content'"), 'Header was tagged as content');
|
||||
assertTrue(str_contains($out, "var s = '<main>';"), 'Script content was altered');
|
||||
assertTrue(substr_count($out, 'data-neo-image') === 1 && str_contains($out, '<img data-neo-image src="/logo.png">'), 'Only the image outside the region should be tagged');
|
||||
assertTrue(str_contains($out, '<title>Café – Über uns</title>') && str_contains($out, 'name="description"'), 'Missing SEO tags were not inserted');
|
||||
assertTrue(substr_count($out, "\n") === substr_count($out, "\r\n"), 'Line endings became mixed');
|
||||
assertTrue(NeoCMS\SiteAnalyser::apply($out, 'cms-content', $all) === null, 'Second site scan was not idempotent');
|
||||
// Removing exactly what was inserted must give back the original bytes.
|
||||
$restored = str_replace([' cms-content"', ' data-neo-image'], ['"', ''], $out);
|
||||
$restored = preg_replace('#^ {4}<(title|meta (name="description"|property="og:[a-z]+")).*\r\n#m', '', $restored);
|
||||
assertTrue($restored === $scanPage, 'Site scan changed bytes other than the inserted markers');
|
||||
// Existing SEO tags are never overwritten; a page with no safe container is reported, not guessed.
|
||||
$seoPage = '<html><head><title>Mine</title><meta name="description" content="Keep me"></head><body><main><h1>H</h1></main></body></html>';
|
||||
$seoOut = NeoCMS\SiteAnalyser::apply($seoPage, 'cms-content', ['seo' => true]);
|
||||
assertTrue($seoOut === null || (str_contains($seoOut['html'], '<title>Mine</title>') && str_contains($seoOut['html'], 'content="Keep me"') && substr_count($seoOut['html'], 'name="description"') === 1), 'Existing SEO tags were overwritten');
|
||||
assertTrue(NeoCMS\SiteAnalyser::apply('<body><div>' . str_repeat('text ', 30) . '</div></body>', 'cms-content', ['content' => true]) === null, 'A page without a safe container was tagged');
|
||||
|
||||
// Photo placeholders (role="img" boxes) outside a region are marked; icons, real images, and in-region boxes are not.
|
||||
$placeholderPage = '<body><div class="portrait" role="img" aria-label="Photo placeholder"><svg role="img"></svg><span>Your photo here</span></div>'
|
||||
. '<div role="img" aria-label="has one"><img src="/a.jpg" alt=""></div><svg role="img"></svg><span role="img" aria-hidden="true"></span>'
|
||||
. '<main><div role="img" aria-label="inside"></div></main></body>';
|
||||
$placeholderOut = NeoCMS\SiteAnalyser::apply($placeholderPage, 'cms-content', ['images' => true]);
|
||||
assertTrue($placeholderOut !== null && substr_count($placeholderOut['html'], 'data-neo-image') === 3, 'Images-only pass should mark the portrait, the real image, and the placeholder in <main>');
|
||||
assertTrue(str_contains($placeholderOut['html'], '<div data-neo-image class="portrait" role="img"'), 'Photo placeholder was not marked');
|
||||
assertTrue(str_contains($placeholderOut['html'], '<img data-neo-image src="/a.jpg"'), 'Real image was not marked');
|
||||
$regionOut = NeoCMS\SiteAnalyser::apply($placeholderPage, 'cms-content', ['content' => true, 'images' => true]);
|
||||
assertTrue(substr_count($regionOut['html'], 'data-neo-image') === 2 && !str_contains($regionOut['html'], '<div data-neo-image role="img" aria-label="inside"'), 'A placeholder inside a newly editable region was marked');
|
||||
|
||||
// Controller: analyse reports pages; apply writes them, keeps a revision, and needs the manage capability.
|
||||
file_put_contents($root . '/scan-a.html', $scanPage);
|
||||
file_put_contents($root . '/scan-b.html', $scanPage);
|
||||
$listing = $request('listSitePages');
|
||||
assertTrue(in_array('/scan-a.html', $listing['pages'], true) && in_array('/scan-b.html', $listing['pages'], true), 'Page listing missed the sample pages');
|
||||
$analysis = $request('analyseSitePages', ['uris' => json_encode(['/scan-a.html', '/scan-b.html', '/no-such-page.html'])]);
|
||||
$byUri = array_column($analysis['pages'], null, 'uri');
|
||||
assertTrue(count($byUri) === 2, 'A page that does not exist should be skipped, not fail the batch');
|
||||
assertTrue(isset($byUri['/scan-a.html']) && $byUri['/scan-a.html']['proposed'] === ['main'], 'Analysis did not propose the main region');
|
||||
assertTrue(in_array('/logo.png', $byUri['/scan-a.html']['broken'], true), 'Missing local image was not reported as broken');
|
||||
assertRejected(fn() => $request('analyseSitePages', ['uris' => json_encode(array_map(fn($i) => "/p{$i}.html", range(1, 101)))]), 'An oversized batch was accepted');
|
||||
$asRole = function (string $role, string $action, array $parameters) use ($config): array {
|
||||
$cfg = $config;
|
||||
$cfg['roles']['tester'] = $role;
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = array_merge(['action' => $action, 'csrf_token' => 'test-token'], $parameters);
|
||||
$_GET = [];
|
||||
$_REQUEST = $_POST;
|
||||
ob_start();
|
||||
(new NeoCMS\CMSController($cfg))->handleRequest();
|
||||
return json_decode((string) ob_get_clean(), true) ?? [];
|
||||
};
|
||||
foreach (['listSitePages' => [], 'analyseSitePages' => ['uris' => json_encode(['/scan-a.html'])]] as $readAction => $readParams) {
|
||||
assertTrue(isset($asRole('publisher', $readAction, $readParams)['error']), "A non-administrator could run {$readAction}");
|
||||
}
|
||||
$before = file_get_contents($root . '/scan-b.html');
|
||||
$denied = $asRole('publisher', 'applySiteTagging', ['uris' => json_encode(['/scan-b.html']), 'options' => json_encode($all)]);
|
||||
assertTrue(isset($denied['error']) && file_get_contents($root . '/scan-b.html') === $before, 'A non-administrator could auto-tag pages');
|
||||
$traversal = $asRole('administrator', 'applySiteTagging', ['uris' => json_encode(['/scan-a.html', '../etc/passwd']), 'options' => json_encode($all)]);
|
||||
assertTrue(isset($traversal['error']) && !str_contains((string) file_get_contents($root . '/scan-a.html'), 'cms-content'), 'A traversal URI was not rejected before any page changed');
|
||||
$result = $request('applySiteTagging', ['uris' => json_encode(['/scan-a.html']), 'options' => json_encode($all)], 'POST');
|
||||
assertTrue($result['updated_pages'] === 1, 'Only the selected page should have been updated');
|
||||
assertTrue(str_contains((string) file_get_contents($root . '/scan-a.html'), 'cms-content') && file_get_contents($root . '/scan-b.html') === $before, 'Unselected page changed or selected page was not tagged');
|
||||
$revisions = $request('revisions', ['uri' => '/scan-a.html']);
|
||||
assertTrue(count($revisions) >= 1 && $revisions[0]['reason'] === 'Before auto-tagging', 'No revision was kept before auto-tagging: ' . json_encode($revisions));
|
||||
unlink($root . '/scan-a.html');
|
||||
unlink($root . '/scan-b.html');
|
||||
|
||||
// Navigation detection seeds the menus store, tags the nav in place, and later saves keep its attributes.
|
||||
$navPage = fn(string $cur) => '<!doctype html><html><head><title>T</title></head><body><header><nav class="site-nav" aria-label="Main"><ul><li><a href="a.html"' . ($cur === 'a' ? ' aria-current="page"' : '') . '>A</a></li><li><a href="b.html"' . ($cur === 'b' ? ' aria-current="page"' : '') . '>B</a></li></ul></nav></header><main><p>' . str_repeat('word ', 20) . '</p></main></body></html>';
|
||||
file_put_contents($root . '/nav-a.html', $navPage('a'));
|
||||
file_put_contents($root . '/nav-b.html', $navPage('b'));
|
||||
$navUris = json_encode(['/nav-a.html', '/nav-b.html']);
|
||||
$seen = $request('analyseSitePages', ['uris' => $navUris])['pages'];
|
||||
assertTrue($seen[0]['navs'][0]['menu'] === 'primary' && $seen[0]['navs'][0]['links'] === 2, 'Primary navigation was not detected');
|
||||
$request('applySiteTagging', ['uris' => $navUris, 'options' => json_encode(['menus' => true])], 'POST');
|
||||
$menus = $request('menus');
|
||||
assertTrue(isset($menus['primary']) && count($menus['primary']['items']) === 2 && $menus['primary']['items'][0]['url'] === '/a.html', 'Detected menu was not seeded: ' . json_encode($menus));
|
||||
assertTrue(str_contains((string) file_get_contents($root . '/nav-b.html'), '<nav data-neo-menu="primary" class="site-nav" aria-label="Main">'), 'Nav was not marked in place');
|
||||
assertTrue(!str_contains(json_encode(array_column($request('analyseSitePages', ['uris' => $navUris])['pages'], 'changes')), 'navigation'), 'Menu detection is not settled on the second run');
|
||||
$request('saveMenu', ['name' => 'primary', 'items' => json_encode([['label' => 'A', 'url' => '/a.html'], ['label' => 'B', 'url' => '/b.html'], ['label' => 'C', 'url' => '/c.html']])], 'POST');
|
||||
$navB = (string) file_get_contents($root . '/nav-b.html');
|
||||
assertTrue(str_contains($navB, 'class="site-nav" aria-label="Main"') && str_contains($navB, '<a href="/c.html">C</a>') && substr_count($navB, 'aria-current') === 0, 'Saved menu lost the nav attributes or wrongly marked the current page');
|
||||
$request('deleteMenu', ['name' => 'primary'], 'POST');
|
||||
assertTrue(!isset($request('menus')['primary']) && str_contains((string) file_get_contents($root . '/nav-b.html'), '/c.html'), 'Menu was not deleted, or pages lost their navigation');
|
||||
assertTrue(isset($asRole('publisher', 'deleteMenu', ['name' => 'secondary'])['error']), 'A non-administrator could delete a menu');
|
||||
// Scan-for-navigation mode also exposes navs that are not header/footer menus.
|
||||
file_put_contents($root . '/nav-c.html', '<html><head><title>T</title></head><body><nav aria-label="Side bar"><ul><li><a href="/x.html">X</a></li></ul></nav><p>hi</p></body></html>');
|
||||
$plain = $request('analyseSitePages', ['uris' => json_encode(['/nav-c.html'])])['pages'][0];
|
||||
$wide = $request('analyseSitePages', ['uris' => json_encode(['/nav-c.html']), 'allNavs' => 1])['pages'][0];
|
||||
assertTrue($plain['navs'] === [] && $wide['navs'][0]['menu'] === 'side-bar' && !$wide['navs'][0]['tagged'], 'allNavs did not expose an unclassified nav');
|
||||
$request('applySiteTagging', ['uris' => json_encode(['/nav-c.html']), 'options' => json_encode(['menus' => true, 'allNavs' => true])], 'POST');
|
||||
assertTrue(isset($request('menus')['side-bar']) && str_contains((string) file_get_contents($root . '/nav-c.html'), 'data-neo-menu="side-bar"'), 'Unclassified nav was not exposed as a menu');
|
||||
unlink($root . '/nav-c.html');
|
||||
unlink($root . '/nav-a.html');
|
||||
unlink($root . '/nav-b.html');
|
||||
|
||||
// A new page stays private (no file, not in its menu) until it is first published.
|
||||
$mainBefore = count($request('menus')['main']['items']);
|
||||
$created = $request('newPage', ['template' => 'example-template.html', 'name' => 'About Us!', 'menu' => 'main'], 'POST');
|
||||
assertTrue($created['url'] === '/about-us.html' && !file_exists($root . '/about-us.html'), 'New page was made public before publication');
|
||||
assertTrue($request('newPage', ['template' => 'example-template.html', 'name' => 'About us', 'menu' => ''], 'POST')['url'] === '/about-us-2.html', 'Duplicate name did not get a numbered filename');
|
||||
assertTrue($request('newPage', ['template' => 'example-template.html', 'name' => '???', 'menu' => ''], 'POST')['url'] === '/page.html', 'Symbol-only name did not fall back to page.html');
|
||||
assertRejected(fn() => $request('newPage', ['template' => 'example-template.html', 'name' => 'X', 'menu' => 'nope'], 'POST'), 'Unknown navigation group was accepted');
|
||||
$listed = array_values(array_filter($request('getPages'), fn($p) => $p['url'] === '/about-us.html'));
|
||||
assertTrue(count($listed) === 1 && !empty($listed[0]['pending']), 'Pending page was not listed as unpublished');
|
||||
$newDraft = $request('getDraft', ['uri' => '/about-us.html']);
|
||||
assertTrue($newDraft['exists'] && str_contains($newDraft['content'], '<title>About Us!</title>'), 'New page draft is missing or untitled');
|
||||
assertTrue(count($request('menus')['main']['items']) === $mainBefore && !str_contains((string) file_get_contents($root . '/index.html'), 'about-us.html'), 'Link appeared in the menu before publication');
|
||||
$request('save', ['uri' => '/about-us.html', 'content' => $newDraft['content']], 'POST');
|
||||
assertTrue(file_exists($root . '/about-us.html'), 'First publication did not create the page');
|
||||
assertTrue(in_array('/about-us.html', array_column($request('menus')['main']['items'], 'url'), true) && str_contains((string) file_get_contents($root . '/index.html'), 'href="/about-us.html"'), 'Published page was not added to its menu');
|
||||
$published = array_values(array_filter($request('getPages'), fn($p) => $p['url'] === '/about-us.html'));
|
||||
assertTrue(!array_filter($published, fn($p) => !empty($p['pending'])), 'Published page is still marked unpublished');
|
||||
$request('schedule', ['uri' => '/about-us-2.html', 'content' => $newDraft['content'], 'publish_at' => (new DateTimeImmutable('+1 day'))->format(DATE_ATOM)], 'POST');
|
||||
$request('discardNewPage', ['uri' => '/about-us-2.html'], 'POST');
|
||||
$request('discardNewPage', ['uri' => '/page.html'], 'POST');
|
||||
assertTrue(!file_exists($root . '/about-us-2.html') && !in_array('/about-us-2.html', array_column($request('dashboard')['schedules'], 'uri'), true) && !array_filter($request('getPages'), fn($p) => !empty($p['pending'])), 'Discard left a schedule or pending page behind');
|
||||
unlink($root . '/about-us.html');
|
||||
|
||||
// A page marked as a template is offered by New Page and copied (title replaced) into a private draft.
|
||||
assertRejected(fn() => $request('newPage', ['template' => 'page:/index.html', 'name' => 'Nope', 'menu' => ''], 'POST'), 'An unmarked page was usable as a template');
|
||||
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '1'], 'POST');
|
||||
assertTrue(in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true), 'Marked page is not offered as a template');
|
||||
$fromPage = $request('newPage', ['template' => 'page:/index.html', 'name' => 'From Page', 'menu' => ''], 'POST');
|
||||
$copied = $request('getDraft', ['uri' => $fromPage['url']])['content'];
|
||||
assertTrue(str_contains($copied, 'cms-content') && str_contains($copied, '<title>From Page</title>') && !file_exists($root . $fromPage['url']), 'Page template was not copied into a private draft');
|
||||
$request('discardNewPage', ['uri' => $fromPage['url']], 'POST');
|
||||
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '0'], 'POST');
|
||||
assertTrue(!in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true), 'Unmarked page is still offered as a template');
|
||||
$request('setPageTemplate', ['uri' => '/index.html', 'value' => '1'], 'POST');
|
||||
$request('deleteTemplate', ['template' => 'page:/index.html'], 'POST');
|
||||
assertTrue(!in_array('page:/index.html', array_column($request('getTemplates'), 'id'), true) && file_exists($root . '/index.html'), 'Deleting a page template removed the page or left it listed');
|
||||
assertRejected(fn() => $request('deleteTemplate', ['template' => '../config.php'], 'POST'), 'A non-template file could be deleted');
|
||||
assertRejected(fn() => $request('deleteTemplate', ['template' => 'missing.html'], 'POST'), 'A missing template file was reported deleted');
|
||||
|
||||
// Parallel read-modify-write must not lose updates: 6 processes x 20 increments = 120.
|
||||
$counterDir = sys_get_temp_dir() . '/neocms-counter-' . bin2hex(random_bytes(4));
|
||||
$worker = $counterDir . '.php';
|
||||
file_put_contents($worker, '<?php require ' . var_export(__DIR__ . '/../cms/src/NeoCMS/FileStore.php', true)
|
||||
. '; $s = new NeoCMS\FileStore($argv[1]); for ($i = 0; $i < 20; $i++) { $s->update("counter", fn($d) => ["n" => ($d["n"] ?? 0) + 1]); }');
|
||||
$procs = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$procs[] = proc_open([PHP_BINARY, $worker, $counterDir], [], $pipes);
|
||||
}
|
||||
foreach ($procs as $proc) {
|
||||
proc_close($proc);
|
||||
}
|
||||
$counted = (new NeoCMS\FileStore($counterDir))->read('counter')['n'] ?? 0;
|
||||
unlink($worker);
|
||||
removeTree($counterDir);
|
||||
assertTrue($counted === 120, "FileStore::update lost updates under concurrency ({$counted}/120)");
|
||||
|
||||
// ---- Users: roles, self-service, administrator management, invitations, and account protection ----
|
||||
$as = function (string $user, string $action, array $params = [], string $method = 'POST') use ($config): array {
|
||||
$saved = $_SESSION;
|
||||
$_SESSION = ['loggedIn' => true, 'loggedInUser' => $user, 'csrfToken' => 'test-token', 'authenticatedAt' => time(), 'lastActivityAt' => time(), 'lastRegeneratedAt' => time()];
|
||||
$_SERVER['REQUEST_METHOD'] = $method;
|
||||
$_POST = $method === 'POST' ? array_merge(['action' => $action, 'csrf_token' => 'test-token'], $params) : [];
|
||||
$_GET = $method === 'GET' ? array_merge(['action' => $action], $params) : [];
|
||||
$_REQUEST = array_merge($_GET, $_POST);
|
||||
ob_start();
|
||||
(new NeoCMS\CMSController($config))->handleRequest();
|
||||
$body = (string) ob_get_clean();
|
||||
$_SESSION = $saved;
|
||||
return (json_decode($body, true) ?? []) + ['_raw' => $body];
|
||||
};
|
||||
$ok = fn(array $r, string $why) => assertTrue(!isset($r['error']), $why . ' (' . ($r['error'] ?? '') . ')');
|
||||
$err = fn(array $r, string $why) => assertTrue(isset($r['error']), $why);
|
||||
$store = new NeoCMS\UserStore(new NeoCMS\FileStore($data), $config);
|
||||
$pw = 'unused';
|
||||
|
||||
// Two roles: the old publisher role is an editor (can publish, cannot manage).
|
||||
$alias = new NeoCMS\Authentication(['x' => $limitedHash], ['x' => 'publisher']);
|
||||
$alias->login('x', 'secret');
|
||||
assertTrue($alias->getRole() === 'editor' && $alias->can('publish') && $alias->can('schedule') && !$alias->can('manage'), 'Publisher did not map to a publishing editor');
|
||||
|
||||
$list = $as('tester', 'users', [], 'GET');
|
||||
$ok($list, 'users list failed');
|
||||
assertTrue($list['me']['managed'] === true && $list['me']['role'] === 'administrator' && array_keys($list['roles']) === ['editor', 'administrator'], 'Users response is missing the config account or roles');
|
||||
assertTrue(!str_contains($list['_raw'], 'hash') && !str_contains($list['_raw'], '$2y$'), 'A password hash leaked into the users response');
|
||||
|
||||
// Sensitive admin actions need the administrator's own password.
|
||||
$newUser = ['name' => 'Ed Example', 'email' => 'Ed@Example.com', 'role' => 'editor', 'username' => 'ed1', 'password' => 'correct horse 1'];
|
||||
$err($as('tester', 'saveUser', $newUser), 'Adding a user worked without password confirmation');
|
||||
$err($as('tester', 'saveUser', $newUser + ['confirm_password' => 'wrong']), 'Adding a user worked with a wrong confirmation password');
|
||||
$ok($as('tester', 'saveUser', $newUser + ['confirm_password' => $pw]), 'Adding a user failed');
|
||||
$accounts = array_column($as('tester', 'users', [], 'GET')['users'], null, 'username');
|
||||
assertTrue(($accounts['ed1']['email'] ?? '') === 'ed@example.com' && $accounts['ed1']['status'] === 'active' && !isset($accounts['ed1']['hash']), 'New account was not listed correctly');
|
||||
assertTrue(str_starts_with((string) json_decode((string) file_get_contents($data . '/users.json'), true)['users']['ed1']['hash'], '$2y$'), 'Password was not stored as a hash');
|
||||
|
||||
// Validation: duplicates, weak or over-long passwords, password equal to the username.
|
||||
$err($as('tester', 'saveUser', ['username' => 'ED1', 'name' => 'Dup', 'email' => 'other@example.com', 'role' => 'editor', 'password' => 'correct horse 2', 'confirm_password' => $pw]), 'A case-variant duplicate username was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Dup', 'email' => 'ED@example.com', 'role' => 'editor', 'password' => 'correct horse 2', 'confirm_password' => $pw]), 'A duplicate email was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Short', 'email' => '', 'role' => 'editor', 'password' => 'elevenchars', 'confirm_password' => $pw]), 'An 11-character password was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed2', 'name' => 'Long', 'email' => '', 'role' => 'editor', 'password' => str_repeat('a', 73), 'confirm_password' => $pw]), 'A 73-byte password was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'samepassword1', 'name' => 'Same', 'email' => '', 'role' => 'editor', 'password' => 'samepassword1', 'confirm_password' => $pw]), 'A password equal to the username was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'ed3', 'name' => 'Bad role', 'email' => '', 'role' => 'root', 'password' => 'correct horse 3', 'confirm_password' => $pw]), 'An unknown role was accepted');
|
||||
$err($as('tester', 'saveUser', ['username' => 'tester', 'name' => 'Shadow', 'email' => '', 'role' => 'editor', 'password' => 'correct horse 4', 'confirm_password' => $pw]), 'A CMS account could shadow a config username');
|
||||
|
||||
// An editor can publish but cannot manage users, and sees only their own account.
|
||||
$perms = $as('ed1', 'dashboard', [], 'GET')['permissions'];
|
||||
assertTrue($perms['publish'] === true && $perms['schedule'] === true && $perms['manage'] === false, 'Editor permissions are wrong');
|
||||
$mine = $as('ed1', 'users', [], 'GET');
|
||||
assertTrue($mine['users'] === [] && $mine['me']['username'] === 'ed1' && $mine['me']['role'] === 'editor', 'An editor could list other accounts');
|
||||
foreach (['saveUser' => $newUser + ['username' => 'ed9', 'confirm_password' => 'correct horse 1'], 'inviteUser' => ['name' => 'X', 'email' => 'x@example.com', 'role' => 'editor', 'confirm_password' => 'correct horse 1'], 'blockUser' => ['username' => 'tester', 'blocked' => '1', 'confirm_password' => 'correct horse 1'], 'deleteUser' => ['username' => 'tester', 'confirm_password' => 'correct horse 1']] as $action => $params) {
|
||||
assertTrue(($as('ed1', $action, $params)['error'] ?? '') === 'Your role cannot perform this action', "An editor could run {$action}");
|
||||
}
|
||||
|
||||
// Self-service: display name freely, email and password need the current password.
|
||||
$ok($as('ed1', 'saveProfile', ['name' => 'Edward Example', 'email' => 'ed@example.com']), 'Editor could not change their display name');
|
||||
$err($as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com']), 'Email changed without the current password');
|
||||
$ok($as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com', 'current_password' => 'correct horse 1']), 'Email change with the current password failed');
|
||||
$as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com', 'username' => 'tester']);
|
||||
assertTrue($store->profile('tester')['name'] === '' && $store->profile('ed1')['name'] === 'Edward', 'A posted username redirected a self-service profile edit');
|
||||
$err($as('ed1', 'changePassword', ['current_password' => 'correct horse 1', 'new_password' => 'brand new pass 2', 'confirm_password' => 'different pass 2']), 'Mismatched confirmation was accepted');
|
||||
$err($as('ed1', 'changePassword', ['current_password' => 'wrong', 'new_password' => 'brand new pass 2', 'confirm_password' => 'brand new pass 2']), 'Password changed with a wrong current password');
|
||||
$ok($as('ed1', 'changePassword', ['current_password' => 'correct horse 1', 'new_password' => 'brand new pass 2', 'confirm_password' => 'brand new pass 2']), 'Password change failed');
|
||||
[$credentials] = $store->forAuth();
|
||||
assertTrue(password_verify('brand new pass 2', $credentials['ed1']) && !password_verify('correct horse 1', $credentials['ed1']), 'New password did not replace the old one');
|
||||
|
||||
// Config accounts are locked: profile overlay only, never credentials.
|
||||
$ok($as('tester', 'saveProfile', ['name' => 'Root Admin', 'email' => '']), 'Config admin could not set a display name');
|
||||
assertTrue($store->profile('tester')['name'] === 'Root Admin', 'Config account profile overlay was not stored');
|
||||
assertTrue(($as('tester', 'changePassword', ['current_password' => $pw, 'new_password' => 'brand new pass 2', 'confirm_password' => 'brand new pass 2'])['error'] ?? '') === 'This password is managed in config.local.php', 'A config account password was changed in the UI');
|
||||
$two = new NeoCMS\UserStore(new NeoCMS\FileStore($data . '-two'), ['authentication' => ['a' => $limitedHash, 'b' => $limitedHash], 'roles' => ['a' => 'administrator', 'b' => 'administrator']]);
|
||||
foreach ([fn() => $two->update('b', 'B', '', 'editor'), fn() => $two->setBlocked('b', true), fn() => $two->delete('b'), fn() => $two->create('B', 'B', '', 'editor', 'correct horse 5')] as $attempt) {
|
||||
assertRejected($attempt, 'A config account was modified through the store');
|
||||
}
|
||||
removeTree($data . '-two');
|
||||
|
||||
// Administrators cannot lock themselves out.
|
||||
foreach (['blockUser' => ['username' => 'tester', 'blocked' => '1'], 'deleteUser' => ['username' => 'tester'], 'saveUser' => ['existing' => 'tester', 'name' => 'T', 'email' => '', 'role' => 'editor']] as $action => $params) {
|
||||
$err($as('tester', $action, $params + ['confirm_password' => $pw]), "An administrator could run {$action} on themselves");
|
||||
}
|
||||
|
||||
// Edit, role change, block, unblock, delete; a blocked or deleted user loses access on the next request.
|
||||
$ok($as('tester', 'saveUser', ['existing' => 'ed1', 'name' => 'Edward', 'email' => 'new@example.com', 'role' => 'administrator', 'password' => '', 'confirm_password' => $pw]), 'Editing a user failed');
|
||||
assertTrue($as('ed1', 'dashboard', [], 'GET')['permissions']['manage'] === true, 'Role change did not apply to the next request');
|
||||
$ok($as('tester', 'saveUser', ['existing' => 'ed1', 'name' => 'Edward', 'email' => 'new@example.com', 'role' => 'editor', 'password' => '', 'confirm_password' => $pw]), 'Demoting a user failed');
|
||||
$ok($as('tester', 'blockUser', ['username' => 'ed1', 'blocked' => '1', 'confirm_password' => $pw]), 'Blocking failed');
|
||||
assertTrue(isset($as('ed1', 'dashboard', [], 'GET')['error']), 'A blocked user kept access');
|
||||
$ok($as('tester', 'blockUser', ['username' => 'ed1', 'blocked' => '0', 'confirm_password' => $pw]), 'Unblocking failed');
|
||||
assertTrue(isset($as('ed1', 'dashboard', [], 'GET')['permissions']), 'An unblocked user could not use the CMS');
|
||||
$ok($as('tester', 'deleteUser', ['username' => 'ed1', 'confirm_password' => $pw]), 'Deleting failed');
|
||||
assertTrue(isset($as('ed1', 'dashboard', [], 'GET')['error']), 'A deleted user kept access');
|
||||
|
||||
// Invitations: single use, expiring, re-issuable, stored only as a hash.
|
||||
$invite = $as('tester', 'inviteUser', ['name' => 'New Person', 'email' => 'New.Person@example.com', 'role' => 'editor', 'confirm_password' => $pw]);
|
||||
$ok($invite, 'Inviting failed');
|
||||
preg_match('/invite=([a-f0-9]{64})$/', $invite['link'], $m);
|
||||
$token = $m[1] ?? '';
|
||||
assertTrue($token !== '' && $invite['username'] === 'new.person', 'Invite link or generated username is wrong');
|
||||
assertTrue(!str_contains((string) file_get_contents($data . '/users.json'), $token), 'The invite token was stored in plain text');
|
||||
$pending = array_column($as('tester', 'users', [], 'GET')['users'], null, 'username')['new.person'];
|
||||
assertTrue($pending['status'] === 'invited' && !str_contains(json_encode($pending), $token), 'Pending invitation is listed wrongly');
|
||||
assertTrue(!isset($store->forAuth()[0]['new.person']), 'An invited account could sign in before accepting');
|
||||
assertRejected(fn() => $store->acceptInvite(str_repeat('0', 64), 'correct horse 6'), 'A wrong invite token was accepted');
|
||||
assertRejected(fn() => $store->acceptInvite($token, 'short'), 'A weak password was accepted on an invite');
|
||||
$reissued = $as('tester', 'inviteUser', ['existing' => 'new.person', 'name' => 'New Person', 'email' => 'new.person@example.com', 'role' => 'editor', 'confirm_password' => $pw]);
|
||||
preg_match('/invite=([a-f0-9]{64})$/', $reissued['link'], $m2);
|
||||
assertRejected(fn() => $store->acceptInvite($token, 'correct horse 6'), 'A superseded invite token still worked');
|
||||
assertTrue($store->acceptInvite($m2[1], 'correct horse 6') === 'new.person', 'A valid invitation was not accepted');
|
||||
assertRejected(fn() => $store->acceptInvite($m2[1], 'correct horse 7'), 'An invite token could be used twice');
|
||||
assertTrue(password_verify('correct horse 6', $store->forAuth()[0]['new.person']), 'The invitee password was not set');
|
||||
$expired = $as('tester', 'inviteUser', ['name' => 'Late', 'email' => 'late@example.com', 'role' => 'editor', 'confirm_password' => $pw]);
|
||||
preg_match('/invite=([a-f0-9]{64})$/', $expired['link'], $m3);
|
||||
(new NeoCMS\FileStore($data))->update('users', function (array $d) { $d['users']['late']['invite']['expires'] = time() - 5; return $d; });
|
||||
assertRejected(fn() => $store->acceptInvite($m3[1], 'correct horse 8'), 'An expired invitation was accepted');
|
||||
foreach (['new.person', 'late'] as $cleanup) {
|
||||
$as('tester', 'deleteUser', ['username' => $cleanup, 'confirm_password' => $pw]);
|
||||
}
|
||||
|
||||
// Wrong confirmation passwords are rate limited, and every account event is audited without secrets.
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$locked = $as('tester', 'deleteUser', ['username' => 'nobody', 'confirm_password' => 'wrong-' . $i]);
|
||||
}
|
||||
assertTrue(str_contains($locked['error'] ?? '', 'Too many'), 'Repeated wrong confirmation passwords were not rate limited');
|
||||
@unlink($data . '/login-attempts.json');
|
||||
$activity = json_encode((new NeoCMS\FileStore($data))->read('activity'));
|
||||
foreach (['Added user', 'Blocked user', 'Deleted user', 'Invited user', 'Changed own password', 'Password confirmation failed', 'Changed user role'] as $event) {
|
||||
assertTrue(str_contains($activity, $event), "Account event not audited: {$event}");
|
||||
}
|
||||
assertTrue(!str_contains($activity, 'correct horse') && !str_contains($activity, $token), 'A secret reached the activity log');
|
||||
|
||||
// Administrators are told when account data sits inside the web root.
|
||||
assertTrue($as('tester', 'dashboard', [], 'GET')['notices'] === [], 'A data directory outside the web root raised a recommendation');
|
||||
$insideConfig = $config;
|
||||
$insideConfig['dataDirectory'] = $root . '/zz-inside-data';
|
||||
mkdir($insideConfig['dataDirectory'], 0755, true);
|
||||
$insideConfig['siteRoot'] = $root;
|
||||
$_SESSION = ['loggedIn' => true, 'loggedInUser' => 'tester', 'csrfToken' => 'test-token', 'authenticatedAt' => time(), 'lastActivityAt' => time(), 'lastRegeneratedAt' => time()];
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_GET = ['action' => 'dashboard'];
|
||||
$_POST = [];
|
||||
$_REQUEST = $_GET;
|
||||
ob_start();
|
||||
(new NeoCMS\CMSController($insideConfig))->handleRequest();
|
||||
$insideDashboard = json_decode((string) ob_get_clean(), true);
|
||||
assertTrue(count($insideDashboard['notices'] ?? []) === 1 && str_contains($insideDashboard['notices'][0], 'outside the web root'), 'No recommendation for a data directory inside the web root');
|
||||
removeTree($insideConfig['dataDirectory']);
|
||||
|
||||
// ---- Media: categories by extension, and refusal of executables and files that contain code ----
|
||||
$mediaDir = sys_get_temp_dir() . '/neocms-media-' . bin2hex(random_bytes(4));
|
||||
mkdir($mediaDir, 0755, true);
|
||||
$zip = function (array $entries): string {
|
||||
$out = '';
|
||||
$directory = '';
|
||||
foreach ($entries as $name => $data) {
|
||||
$offset = strlen($out);
|
||||
$crc = crc32($data);
|
||||
$out .= "PK\x03\x04" . pack('vvvvvVVVvv', 20, 0, 0, 0, 0, $crc, strlen($data), strlen($data), strlen($name), 0) . $name . $data;
|
||||
$directory .= "PK\x01\x02" . pack('vvvvvvVVVvvvvvVV', 20, 20, 0, 0, 0, 0, $crc, strlen($data), strlen($data), strlen($name), 0, 0, 0, 0, 0, $offset) . $name;
|
||||
}
|
||||
return $out . $directory . "PK\x05\x06" . pack('vvvvVVv', 0, 0, count($entries), count($entries), strlen($directory), strlen($out), 0);
|
||||
};
|
||||
$write = function (string $name, string $data) use ($mediaDir): string {
|
||||
$path = $mediaDir . '/' . bin2hex(random_bytes(3));
|
||||
file_put_contents($path, $data);
|
||||
return $path;
|
||||
};
|
||||
$img = imagecreatetruecolor(8, 8);
|
||||
ob_start(); imagepng($img); $png = (string) ob_get_clean();
|
||||
ob_start(); imagejpeg($img); $jpg = (string) ob_get_clean();
|
||||
ob_start(); imagegif($img); $gif = (string) ob_get_clean();
|
||||
$wav = 'RIFF' . pack('V', 36 + 8) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 8000, 1, 8) . 'data' . pack('V', 8) . str_repeat("\x80", 8);
|
||||
$mp4 = "\0\0\0\x18ftypisom\0\0\2\0isomiso2" . "\0\0\0\x10mdat" . str_repeat("\0", 8);
|
||||
$mp3 = "ID3\x03\0\0\0\0\0\0" . str_repeat("\xFF\xFB\x90\x00" . str_repeat("\0", 413), 4);
|
||||
$pdf = "%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
|
||||
$docx = $zip(['[Content_Types].xml' => '<Types/>', 'word/document.xml' => '<w:document/>']);
|
||||
$odt = $zip(['mimetype' => 'application/vnd.oasis.opendocument.text', 'content.xml' => '<office:document-content/>']);
|
||||
|
||||
// Accepted, and categorised by extension.
|
||||
foreach ([
|
||||
['photo.png', $png, 'imagery', 'png'], ['photo.JPG', $jpg, 'imagery', 'jpg'], ['photo.jpeg', $jpg, 'imagery', 'jpg'], ['anim.gif', $gif, 'imagery', 'gif'],
|
||||
['guide.pdf', $pdf, 'documents', 'pdf'], ['letter.docx', $docx, 'documents', 'docx'], ['letter.odt', $odt, 'documents', 'odt'],
|
||||
['notes.txt', "Plain notes\nline two\n", 'documents', 'txt'], ['table.csv', "a,b\n1,2\n", 'documents', 'csv'],
|
||||
['clip.mp4', $mp4, 'video', 'mp4'], ['tone.wav', $wav, 'audio', 'wav'], ['song.mp3', $mp3, 'audio', 'mp3'],
|
||||
] as [$name, $bytes, $category, $extension]) {
|
||||
$result = NeoCMS\MediaTypes::inspect($write($name, $bytes), $name);
|
||||
assertTrue($result['category'] === $category && $result['ext'] === $extension, "{$name} was not accepted as {$category}");
|
||||
}
|
||||
|
||||
// Compressed media contains arbitrary bytes: short sequences such as <%= or <?= must not cause a false refusal.
|
||||
$noise = $mp4 . str_repeat('<%=<?=<%@', 50000) . random_bytes(200000);
|
||||
assertTrue(NeoCMS\MediaTypes::inspect($write('noise.mp4', $noise), 'noise.mp4')['category'] === 'video', 'Video containing chance byte sequences was refused');
|
||||
assertTrue(NeoCMS\MediaTypes::inspect($write('noise.mp3', $mp3 . '<%=<?=' . random_bytes(100000)), 'noise.mp3')['category'] === 'audio', 'Audio containing chance byte sequences was refused');
|
||||
|
||||
// Refused: executables in any disguise, code inside otherwise valid files, macros, scripts, and unlisted types.
|
||||
$exe = "MZ\x90\x00\x03\x00\x00\x00" . str_repeat("\0", 200);
|
||||
foreach ([
|
||||
'setup.exe' => $exe, 'notes.pdf' => $exe, 'photo.jpg' => $exe, 'a.php.jpg' => $png, 'report.exe.pdf' => $pdf, 'shell.php' => '<?php echo 1;', 'run.sh' => "#!/bin/sh\necho hi\n",
|
||||
'evil.jpg' => $jpg . '<?php system($_GET["c"]); ?>', 'clip.mp4' => $mp4 . '<?php echo 1; ?>', 'tone.mp3' => $mp3 . '<script>alert(1)</script>',
|
||||
'page.txt' => '<script>alert(1)</script>', 'data.csv' => "a,b\n<?= 1 ?>,2\n", 'nul.txt' => "abc\0def",
|
||||
'js.pdf' => $pdf . "\n/JavaScript (app.alert(1))", 'launch.pdf' => $pdf . "\n/Launch /Win", 'embedded.pdf' => $pdf . "\n/EmbeddedFile",
|
||||
'macro.docx' => $zip(['[Content_Types].xml' => '<Types/>', 'word/document.xml' => '<w:document/>', 'word/vbaProject.bin' => 'x']),
|
||||
'ole.docx' => $zip(['[Content_Types].xml' => '<Types/>', 'word/document.xml' => '<w:document/>', 'word/embeddings/oleObject1.bin' => 'x']),
|
||||
'fake.docx' => $zip(['readme.txt' => 'hello']), 'basic.odt' => $zip(['mimetype' => 'application/vnd.oasis.opendocument.text', 'Basic/Module1.xba' => 'Sub Main']),
|
||||
'exec.odt' => $zip(['mimetype' => 'application/vnd.oasis.opendocument.text', 'content.xml' => '<x/>', 'tool.exe' => 'MZ']),
|
||||
'macro.docm' => $docx, 'vector.svg' => '<svg xmlns="http://www.w3.org/2000/svg"/>', 'page.html' => '<html></html>', 'old.doc' => $pdf, 'old.xls' => $pdf,
|
||||
'archive.zip' => $docx, 'noext' => $png, 'empty.png' => '', 'wrong.jpg' => $png,
|
||||
] as $name => $bytes) {
|
||||
assertRejected(fn() => NeoCMS\MediaTypes::inspect($write($name, $bytes), $name), "{$name} was accepted");
|
||||
}
|
||||
assertRejected(fn() => NeoCMS\MediaTypes::inspect($write('big.png', $png), 'big.png', ['maxBytes' => 10]), 'An oversize file was accepted');
|
||||
assertRejected(fn() => NeoCMS\MediaTypes::inspect($write('wide.png', $png), 'wide.png', ['maxWidth' => 4]), 'An oversize image was accepted');
|
||||
|
||||
// Stored names carry one dot, are safe, and are recognised as managed media; extensions map to the four categories.
|
||||
$stored = NeoCMS\MediaTypes::storedName('My Report (Final v2).PDF', 'pdf');
|
||||
assertTrue(preg_match('/^my-report-final-v2-[a-f0-9]{8}\.pdf$/', $stored) === 1 && substr_count($stored, '.') === 1 && NeoCMS\MediaTypes::isManagedName($stored), 'Stored media name is wrong: ' . $stored);
|
||||
assertTrue(NeoCMS\MediaTypes::storedName('../../etc/passwd.jpg', 'jpg') !== '' && !str_contains(NeoCMS\MediaTypes::storedName('../../a b.jpg', 'jpg'), '/'), 'A path leaked into a stored name');
|
||||
assertTrue(NeoCMS\MediaTypes::isManagedName(str_repeat('a', 32) . '.png') && !NeoCMS\MediaTypes::isManagedName('evil.php') && !NeoCMS\MediaTypes::isManagedName('x-12345678.exe') && !NeoCMS\MediaTypes::isManagedName('../x-12345678.pdf'), 'Managed-name check is too permissive');
|
||||
assertTrue(!in_array('exe', NeoCMS\MediaTypes::extensions(), true) && array_unique(array_map([NeoCMS\MediaTypes::class, 'categoryForExtension'], NeoCMS\MediaTypes::extensions())) === ['imagery', 'documents', 'video', 'audio'] || count(array_unique(array_map([NeoCMS\MediaTypes::class, 'categoryForExtension'], NeoCMS\MediaTypes::extensions()))) === 4, 'Extension map is wrong');
|
||||
removeTree($mediaDir);
|
||||
|
||||
if (DIRECTORY_SEPARATOR === '/') { // chmod is a no-op on Windows
|
||||
assertTrue((fileperms($data) & 0777) === 0700, 'CMS data directory permissions are too broad');
|
||||
assertTrue((fileperms($schedulePath) & 0777) === 0600, 'CMS metadata file permissions are too broad');
|
||||
}
|
||||
|
||||
echo "All NeoCMS integration tests passed.\n";
|
||||
} finally {
|
||||
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
# Uploaded files are images only; deny executable extensions even if one appears by other means.
|
||||
Options -Indexes
|
||||
# Uploaded media: images, documents, video and audio only. Deny executable and markup extensions even if one appears by other means.
|
||||
Options -Indexes -ExecCGI
|
||||
|
||||
<FilesMatch "\.(?:php[0-9]?|phtml|phar|cgi|pl|py|sh)$">
|
||||
<FilesMatch "\.(?:php[0-9]?|phtml|phar|cgi|pl|py|sh|exe|dll|bat|cmd|com|msi|asp|aspx|jsp|jspx|html?|xhtml|shtml|svg|xml|js|jar|swf)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
|
||||
@@ -9,4 +9,8 @@ Options -Indexes
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Content-Security-Policy "default-src 'none'; sandbox"
|
||||
Header always set Cross-Origin-Resource-Policy "same-origin"
|
||||
# Documents download instead of rendering in the browser.
|
||||
<FilesMatch "\.(?:pdf|docx|xlsx|pptx|odt|ods|odp|txt|csv)$">
|
||||
Header always set Content-Disposition "attachment"
|
||||
</FilesMatch>
|
||||
</IfModule>
|
||||
|
||||
Reference in New Issue
Block a user