feat: lock pages against editor changes

Administrators can lock a page from the Pages list to stop editors
publishing, saving a draft, or scheduling a change to it. Administrators
are never restricted by a lock. State is stored per-page URI in the new
cms/data/pagelocks.json, following the existing pagetemplates.json
pattern: it moves with the page on rename, is cleared on delete, and is
never copied onto a duplicate.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Geoff Moore
2026-10-02 12:11:06 +01:00
co-authored by Claude Sonnet 5
parent 43200d7dfe
commit 83ae3a08ff
5 changed files with 73 additions and 1 deletions
+47 -1
View File
@@ -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
{