Add Users, media library, navigation menus from the scan, new-page wizard and deleted-page recovery
Users (Tools > Users) - Everyone can update their display name, email and password; administrators can add, edit, invite (one-time link), block and delete accounts. - Accounts in config.local.php stay locked to config (the main administrator); CMS accounts live in data/users.json. Sign-in is by login name. - Roles are now editor and administrator (publisher is an alias of editor). - Account protection: password hashes only (12-72 characters), administrator password confirmation for sensitive actions, rate-limited failures, hashed single-use invite tokens, self-lockout guards, sessions of blocked/deleted users end on the next request, every account event audited without secrets. - dataDirectory can move data outside the web root; the dashboard recommends it. Media library - Upload images, documents, video and audio, sorted into four categories by extension. - Extension allow-list; executables, scripts, HTML/SVG, archives, legacy and macro Office files refused; content sniffing; files containing code refused; PDFs with scripts refused; Office/OpenDocument packages inspected for macros and embedded objects. - Media library button in the editor inserts an image, document link, video or audio. Navigation menus, pages and interface - The site scan detects primary/secondary navigation and seeds Navigation Menus; menus can be renamed, edited and deleted but not created by the CMS; a scan button finds new navigation. - New page wizard (template, name, navigation group) in the Pages dialogue; new pages stay private drafts until published; pages can be used as templates. - Deleted pages can be recovered from the Dashboard. - Full-width top bar with the user at the top right, device icons, consistent buttons. Tests and docs updated (tests/run.php, README, ARCHITECTURE, DEVELOPMENT, CHANGELOG). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
89d64418de
commit
8946915bfe
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user