Merge pull request #42 from GeoffMoore1979/feature/media-users-pages
Lock pages against editor changes
This commit was merged in pull request #42.
This commit is contained in:
@@ -29,7 +29,8 @@ Further technical documentation:
|
||||
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.
|
||||
be offered as a template with Create Template. Administrators can Lock a page to stop editors publishing, saving a draft, or
|
||||
scheduling changes to it; administrators are never restricted by a lock.
|
||||
* Media Library: Upload images, documents, video, and audio; they are sorted into Imagery, Documents, Video, and Audio by file
|
||||
extension. Reuse them from the editor's Media library button, maintain alternative text or link text, see usage counts, and
|
||||
remove files. Executables and files containing code are refused.
|
||||
@@ -137,7 +138,7 @@ There are two roles:
|
||||
| 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. |
|
||||
| `administrator` | Everything an editor can, plus creating new pages, duplicate, rename, delete and lock pages, templates, navigation menus, shared content, site scan, deleting media, and managing users. |
|
||||
|
||||
The former `publisher` role is now part of `editor`; existing `publisher` assignments are treated as `editor`. Users without an
|
||||
explicit valid role receive `editor`, the least powerful role.
|
||||
@@ -286,6 +287,13 @@ Templates are the files in `cms/templates/` plus any page you choose **Create Te
|
||||
is used live (later edits to it show in new pages), and the **Delete** action in the template list deletes a template file or
|
||||
stops using a page as a template (the page is kept).
|
||||
|
||||
### Locking Pages
|
||||
|
||||
Administrators can **Lock** a page from the Pages list to stop editors publishing, saving a draft, or scheduling a change to
|
||||
it; a **Locked** badge shows next to the page name for everyone. Administrators are never restricted by a lock, and locking
|
||||
is independent of Create Template, Duplicate, Rename, and Delete, which were already administrator-only. A lock follows a
|
||||
page when it is renamed, is cleared when the page is deleted, and is never copied onto a duplicate.
|
||||
|
||||
### Subfolder Installs
|
||||
|
||||
The site can live in a subfolder such as `http://localhost/neocms/`. The URL prefix (`basePath`) and the folder that contains
|
||||
|
||||
@@ -435,6 +435,7 @@ body {
|
||||
margin-left: 10px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600;
|
||||
background: #fff3d6; color: #8a5a00; border: 1px solid #f0d58a;
|
||||
}
|
||||
.badge-locked { background: #fde2e1; color: #8a1f1f; border-color: #f3b4b2; }
|
||||
@media (max-width: 700px) { #fileList .page-title, #fileList th:nth-child(2), #fileList .page-date, #fileList th:nth-child(3) { display: none; } }
|
||||
|
||||
/* Site scan progress bar and file count. */
|
||||
|
||||
@@ -447,6 +447,7 @@
|
||||
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(page.pending ? 'New - unpublished' : 'Draft').appendTo(name);
|
||||
if (page.locked) $('<span class="badge-draft badge-locked">').text('Locked').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 && page.pending) {
|
||||
@@ -456,6 +457,8 @@
|
||||
$('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
|
||||
.on('click', function (event) { event.stopPropagation(); action[1] === 'template' ? setPageTemplate(page.url, true) : managePage(action[1], page.url); }).appendTo(actions);
|
||||
});
|
||||
$('<button type="button">').text(page.locked ? 'Unlock' : 'Lock')
|
||||
.on('click', function (event) { event.stopPropagation(); lockPage(page.url, !page.locked); }).appendTo(actions);
|
||||
}
|
||||
body.append(row);
|
||||
});
|
||||
@@ -638,6 +641,13 @@
|
||||
} catch (error) { showMessage(error.message, 'error'); }
|
||||
}
|
||||
|
||||
async function lockPage(uri, on) {
|
||||
try {
|
||||
showMessage((await api('lockPage', {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 {
|
||||
|
||||
@@ -139,6 +139,7 @@ final class CMSController
|
||||
{
|
||||
$this->requirePost('publish');
|
||||
$uri = $this->requiredPost('uri');
|
||||
$this->assertNotLockedForEditor($uri);
|
||||
$content = $this->requiredContentPost('content');
|
||||
$this->publishContent($uri, $content, 'Published page');
|
||||
$this->deleteDraft($uri);
|
||||
@@ -150,6 +151,7 @@ final class CMSController
|
||||
{
|
||||
$this->requirePost('draft');
|
||||
$uri = $this->requiredPost('uri');
|
||||
$this->assertNotLockedForEditor($uri);
|
||||
$content = $this->requiredContentPost('content');
|
||||
$this->storeDraft($uri, $content);
|
||||
$this->activity('Saved draft', $uri);
|
||||
@@ -188,6 +190,7 @@ final class CMSController
|
||||
{
|
||||
$this->requirePost('schedule');
|
||||
$uri = $this->requiredPost('uri');
|
||||
$this->assertNotLockedForEditor($uri);
|
||||
$content = $this->requiredContentPost('content');
|
||||
$this->assertPublishable($uri);
|
||||
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
|
||||
@@ -290,6 +293,25 @@ final class CMSController
|
||||
$this->respond(['message' => $on ? 'Template created. It is now available in New Page.' : 'Page is no longer a template']);
|
||||
}
|
||||
|
||||
/** Lock a page against editor changes, or unlock it; administrators only. */
|
||||
private function lockPageAction(): void
|
||||
{
|
||||
$this->requirePost('manage');
|
||||
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
|
||||
$this->paths->existing($uri);
|
||||
$on = ($_POST['value'] ?? '') === '1';
|
||||
$this->store->update('pagelocks', function (array $locked) use ($uri, $on) {
|
||||
if ($on) {
|
||||
$locked[$uri] = ['user' => $this->user(), 'created' => date(DATE_ATOM)];
|
||||
} else {
|
||||
unset($locked[$uri]);
|
||||
}
|
||||
return $locked;
|
||||
});
|
||||
$this->activity($on ? 'Locked page' : 'Unlocked page', $uri);
|
||||
$this->respond(['message' => $on ? 'Page locked. Editors can no longer edit it.' : 'Page unlocked']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -458,6 +480,10 @@ final class CMSController
|
||||
unset($marked[$sourceUri]);
|
||||
return $marked;
|
||||
});
|
||||
$this->store->update('pagelocks', function (array $locked) use ($sourceUri) {
|
||||
unset($locked[$sourceUri]);
|
||||
return $locked;
|
||||
});
|
||||
$this->activity('Deleted page', $sourceUri);
|
||||
$this->respond(['message' => 'Page deleted']);
|
||||
return;
|
||||
@@ -482,6 +508,13 @@ final class CMSController
|
||||
}
|
||||
return $marked;
|
||||
});
|
||||
$this->store->update('pagelocks', function (array $locked) use ($sourceUri, $targetUri) {
|
||||
if (isset($locked[$sourceUri])) {
|
||||
$locked[$targetUri] = $locked[$sourceUri];
|
||||
unset($locked[$sourceUri]);
|
||||
}
|
||||
return $locked;
|
||||
});
|
||||
}
|
||||
$this->activity(ucfirst($operation) . 'd page', $sourceUri . ' -> ' . $targetUri);
|
||||
$this->respond(['message' => 'Page ' . $operation . 'd', 'url' => $targetUri]);
|
||||
@@ -491,6 +524,7 @@ final class CMSController
|
||||
private function getPagesAction(): void
|
||||
{
|
||||
$drafts = $this->store->read('drafts');
|
||||
$locked = $this->store->read('pagelocks');
|
||||
$pages = [];
|
||||
foreach ($this->paths->files() as $path) {
|
||||
$uri = $this->paths->uriFor($path);
|
||||
@@ -504,10 +538,11 @@ final class CMSController
|
||||
'title' => $this->extractTitle($html),
|
||||
'modified' => date(DATE_ATOM, filemtime($path)),
|
||||
'draft' => isset($drafts[$uri]),
|
||||
'locked' => isset($locked[$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];
|
||||
$pages[] = ['name' => $uri, 'url' => $uri, 'title' => (string) ($entry['title'] ?? ''), 'modified' => $entry['created'] ?? date(DATE_ATOM), 'draft' => true, 'pending' => true, 'locked' => isset($locked[$uri])];
|
||||
}
|
||||
usort($pages, fn(array $a, array $b) => strcmp($a['name'], $b['name']));
|
||||
$this->respond($pages);
|
||||
@@ -1352,6 +1387,17 @@ final class CMSController
|
||||
}
|
||||
}
|
||||
|
||||
/** Refuse editors (never administrators) write access to a page an administrator has locked. */
|
||||
private function assertNotLockedForEditor(string $uri): void
|
||||
{
|
||||
if ($this->authentication->can('manage')) {
|
||||
return;
|
||||
}
|
||||
if (isset($this->store->read('pagelocks')[$this->paths->normaliseUri($uri)])) {
|
||||
throw new \RuntimeException('This page is locked by an administrator and cannot be edited.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a required string POST field or raise a client-facing validation error. */
|
||||
private function requiredPost(string $key): string
|
||||
{
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<h2>Pages and revisions</h2>
|
||||
<p>Administrators can create, duplicate, rename, and delete pages. The <strong>New page</strong> section under the list in <strong>Pages</strong> is a three-step wizard: choose a template, name the page (the filename is generated for you), then choose a navigation group. The new page stays a private draft until it is published; publishing creates the file and adds it to the chosen menu.</p>
|
||||
<p>Use <strong>Create Template</strong> on any page in the list to offer it as a template, and Delete in the template list to stop offering one. Editors can restore earlier revisions from <strong>Tools</strong>.</p>
|
||||
<p>Administrators can also <strong>Lock</strong> a page to stop editors publishing, saving a draft, or scheduling changes to it; administrators are never restricted by a lock.</p>
|
||||
<p>Deleted pages retain a restorable snapshot: editors can bring one back with <strong>Recover</strong> in the <strong>Dashboard</strong>'s Deleted pages list, because accidents should be reversible rather than memorable.</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ Published content stays in the web document root. NeoCMS stores supporting data
|
||||
| `menus.json` | Named menus: `{key: {items: [{label, url, parent}], updated, title?}}`; seeded by the site scan, never created by the CMS. |
|
||||
| `newpages.json` | Pending (unpublished) pages: title, chosen menu, template, author, created. |
|
||||
| `pagetemplates.json` | Pages offered as templates in the New page section of Pages. |
|
||||
| `pagelocks.json` | Pages an administrator has locked against editor changes. |
|
||||
| `users.json` | CMS-created accounts and profile overlays for config accounts (`{users: {...}, profiles: {...}}`). |
|
||||
| `media.json` | Per-file description (alt text or link text), original filename, and uploader. |
|
||||
| `activity.json` | The latest 250 dashboard activity entries. |
|
||||
|
||||
+12
-4
@@ -22,6 +22,12 @@ This round builds on pull request #39 (site scan, editor workflow, subfolder sup
|
||||
- **Pages as templates**: each row in the page list has a "Create Template" action (administrators). The page is then offered in New Page beside the files in `cms/templates/`; the new page is a private copy with its title replaced. Renaming or deleting a template page keeps the list in step.
|
||||
- **Template list**: the New Page template and navigation-group lists use the page-table styling; each template has a Delete action (a template file is deleted; a page used as a template is only unmarked).
|
||||
- **Device preview**: the Desktop/Tablet/Mobile buttons are icons (with tooltips and accessible names) and the active one is highlighted.
|
||||
- **Lock pages**: administrators can lock a page from the Pages list to stop editors publishing, saving a draft, or scheduling a change to it, without restricting administrators themselves. A "Locked" badge shows on the page for everyone; the Lock/Unlock action sits beside Create Template, Duplicate, Rename and Delete. State is stored in `cms/data/pagelocks.json`, keyed by page URI, and follows a page through rename (cleared on delete, never copied by Duplicate). New action `lockPage`; `getPages` items gain `locked`.
|
||||
|
||||
### Fixed
|
||||
- **Verbose PHP errors**: `display_errors` and `display_startup_errors` are now disabled in `bootstrap.php`, so an uncaught engine error can never leak a stack trace or filesystem path to a client; every error still reaches PHP's own error log.
|
||||
- **Unbounded login-throttling state**: `LoginRateLimiter` now caps tracked address/identity buckets at 20,000, evicting the least recently active first, so a distributed flood of distinct addresses or login names cannot grow `login-attempts.json` without bound.
|
||||
- **Audit-log pruning cost**: `Logger` now prunes expired audit logs probabilistically (1-in-100 constructions) instead of scanning the whole logs directory on every single request.
|
||||
|
||||
### Migration notes
|
||||
- New Page is now a section of the Pages dialogue (there is no New Page toolbar button).
|
||||
@@ -30,13 +36,15 @@ This round builds on pull request #39 (site scan, editor workflow, subfolder sup
|
||||
- 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`.
|
||||
- New API actions: `users`, `saveProfile`, `changePassword`, `saveUser`, `inviteUser`, `blockUser`, `deleteUser`, `newPage` (changed), `discardNewPage`, `setPageTemplate`, `deleteTemplate`, `deleteMenu`, `lockPage`; the dashboard response gains `deleted` and `notices`; `media` items gain `category`, `ext` and `original`; `getPages` items gain `locked`.
|
||||
|
||||
### Verification
|
||||
- The updated integration suite (`tests/run.php`, included in this contribution) passes, covering concurrency, site scan and tagging, menus, new pages
|
||||
and templates, deleted-page recovery, users and roles, invitations, media type checks, 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.
|
||||
and templates, deleted-page recovery, users and roles, invitations, media type checks, page locking (lock/unlock, rename moves the lock, delete clears
|
||||
it, duplicate never copies it), and audit-log cases. Upstream's earlier `tests/run.php` expects behaviour that has intentionally changed (editors
|
||||
cannot publish, menus can be created, New Page copies immediately), so the updated suite replaces it. On Windows the Linux-only permission assertions
|
||||
are skipped.
|
||||
- A self-review security audit found no critical or high-severity issues; the three fixes above address its medium/low findings.
|
||||
|
||||
## Merged in pull request #39
|
||||
|
||||
|
||||
@@ -219,10 +219,15 @@ try {
|
||||
|
||||
// Administrator page operations must support duplicate, rename, delete, and restoration.
|
||||
$request('page', ['operation' => 'duplicate', 'uri' => '/index.html', 'target' => '/copy.html'], 'POST');
|
||||
assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/copy.html']), 'Duplicating a page copied its lock');
|
||||
$request('lockPage', ['uri' => '/copy.html', 'value' => '1'], 'POST');
|
||||
$request('page', ['operation' => 'rename', 'uri' => '/copy.html', 'target' => '/renamed.html'], 'POST');
|
||||
assertTrue(is_file($root . '/renamed.html'), 'Duplicate or rename failed');
|
||||
$locksAfterRename = (new NeoCMS\FileStore($data))->read('pagelocks');
|
||||
assertTrue(!isset($locksAfterRename['/copy.html']) && isset($locksAfterRename['/renamed.html']), 'Renaming a page did not move its lock entry');
|
||||
$request('page', ['operation' => 'delete', 'uri' => '/renamed.html'], 'POST');
|
||||
assertTrue(!is_file($root . '/renamed.html'), 'Page delete failed');
|
||||
assertTrue(!isset((new NeoCMS\FileStore($data))->read('pagelocks')['/renamed.html']), 'Deleting a page left a stale lock entry');
|
||||
assertTrue(in_array('/renamed.html', array_column($request('dashboard')['deleted'], 'uri'), true), 'Deleted page was not listed on the dashboard');
|
||||
$deletedRevisions = $request('revisions', ['uri' => '/renamed.html']);
|
||||
$request('restoreRevision', ['id' => $deletedRevisions[0]['id']], 'POST');
|
||||
@@ -536,6 +541,15 @@ try {
|
||||
assertTrue(($as('ed1', $action, $params)['error'] ?? '') === 'Your role cannot perform this action', "An editor could run {$action}");
|
||||
}
|
||||
|
||||
// ---- Page locks: administrators toggle, only editors are blocked from editing ----
|
||||
$ok($as('tester', 'lockPage', ['uri' => '/index.html', 'value' => '1']), 'Admin could not lock a page');
|
||||
$pages = $as('tester', 'getPages', [], 'GET');
|
||||
assertTrue((array_column($pages, 'locked', 'url'))['/index.html'] === true, 'Locked page was not reported as locked');
|
||||
$err($as('ed1', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Editor saved a draft on a locked page');
|
||||
$ok($as('tester', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Administrator was blocked by their own lock');
|
||||
$ok($as('tester', 'lockPage', ['uri' => '/index.html', 'value' => '0']), 'Admin could not unlock a page');
|
||||
$ok($as('ed1', 'saveDraft', ['uri' => '/index.html', 'content' => '<main class="cms-content">x</main>']), 'Editor still blocked after unlock');
|
||||
|
||||
// Self-service: display name freely, email and password need the current password.
|
||||
$ok($as('ed1', 'saveProfile', ['name' => 'Edward Example', 'email' => 'ed@example.com']), 'Editor could not change their display name');
|
||||
$err($as('ed1', 'saveProfile', ['name' => 'Edward', 'email' => 'new@example.com']), 'Email changed without the current password');
|
||||
|
||||
Reference in New Issue
Block a user