config = $config;
$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'] ?? ''));
if ($templatesDir === false || $documentRoot === false) {
throw new \RuntimeException('CMS filesystem paths are unavailable');
}
$this->templatesDir = rtrim($templatesDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$security = is_array($config['security'] ?? null) ? $config['security'] : [];
$this->maxContentBytes = max(1024, (int) ($security['maxContentBytes'] ?? 5 * 1024 * 1024));
$this->maxRequestBytes = max($this->maxContentBytes, (int) ($security['maxRequestBytes'] ?? 6 * 1024 * 1024));
$this->maxManagedPages = max(1, (int) ($security['maxManagedPages'] ?? 5000));
$this->paths = new PagePaths(
rtrim($documentRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR,
$this->maxManagedPages,
max($this->maxManagedPages, (int) ($security['maxScannedEntries'] ?? 20000))
);
$this->maxDraftBytes = max($this->maxContentBytes, (int) ($security['maxDraftBytes'] ?? 250 * 1024 * 1024));
$this->maxSchedules = max(1, (int) ($security['maxSchedules'] ?? 100));
$this->maxScheduledBytes = max($this->maxContentBytes, (int) ($security['maxScheduledBytes'] ?? 250 * 1024 * 1024));
$this->maxRevisionsPerPage = max(1, (int) ($security['maxRevisionsPerPage'] ?? 50));
$this->maxRevisionsTotal = max($this->maxRevisionsPerPage, (int) ($security['maxRevisionsTotal'] ?? 2000));
$this->maxRevisionBytes = max($this->maxContentBytes, (int) ($security['maxRevisionBytes'] ?? 500 * 1024 * 1024));
// Reject selectors that could escape the intended single-class query.
$editableClass = $config['editableClass'] ?? 'editable';
$this->editableClass = is_string($editableClass) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $editableClass)
? $editableClass
: 'editable';
}
/**
* Authenticate, dispatch, and serialise one HTTP API request.
*
* Due scheduled jobs are processed opportunistically on every API request as a useful backup
* to cron. The CLI worker remains the reliable option for quiet sites enjoying a day off.
*/
public function handleRequest(): void
{
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $this->maxRequestBytes) {
$this->respond(['error' => 'Request body is too large'], 413);
return;
}
$action = $_REQUEST['action'] ?? '';
if (!$this->authentication->isLoggedIn()) {
$this->respond(['error' => 'Not authenticated'], 401);
return;
}
try {
// A failure here must not break unrelated requests; the cron worker will retry.
try {
$this->publishDueJobs();
} catch (\Throwable $exception) {
$this->logger->write("Opportunistic schedule run failed: {$exception->getMessage()}", $this->user());
}
// Action names map to private methods such as saveDraftAction().
$method = $action . 'Action';
if ($action === '' || !method_exists($this, $method)) {
$this->respond(['error' => 'Unknown action'], 404);
return;
}
$this->$method();
} catch (\RuntimeException | \InvalidArgumentException $exception) {
$this->logger->write("Controller action {$action} failed: {$exception->getMessage()}", $this->user());
$this->respond(['error' => $exception->getMessage()], 400);
} catch (\Throwable $exception) {
$this->logger->write("Controller action {$action} failed unexpectedly: {$exception->getMessage()}", $this->user());
$this->respond(['error' => 'Request failed'], 500);
}
}
/** Process all due scheduled jobs from the command-line worker. */
public function publishScheduled(): int
{
return $this->publishDueJobs();
}
/** Publish the submitted page, retain its previous revision, and clear its draft. */
private function saveAction(): void
{
$this->requirePost('publish');
$uri = $this->requiredPost('uri');
$this->assertNotLockedForEditor($uri);
$content = $this->requiredContentPost('content');
$this->publishContent($uri, $content, 'Published page');
$this->deleteDraft($uri);
$this->respond(['message' => 'Page has been published', 'destination' => $uri]);
}
/** Save private draft HTML without changing the publicly served page. */
private function saveDraftAction(): void
{
$this->requirePost('draft');
$uri = $this->requiredPost('uri');
$this->assertNotLockedForEditor($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);
if ($draftBytes + strlen($content) > $this->maxDraftBytes) {
throw new \RuntimeException('Draft storage quota has been reached');
}
$this->store->writePrivateFile('drafts', $draftName, $content);
// Draft filenames are URI hashes, while this index keeps their human-readable metadata.
$draftUri = $this->paths->normaliseUri($uri);
$entry = ['updated' => date(DATE_ATOM), 'user' => $this->user()];
$this->store->update('drafts', function (array $drafts) use ($draftUri, $entry) {
$drafts[$draftUri] = $entry;
return $drafts;
});
}
/** Return the saved draft for one URI, if present. */
private function getDraftAction(): void
{
$uri = $this->requiredRequest('uri');
$path = $this->draftPath($uri);
$this->respond(['exists' => is_file($path), 'content' => is_file($path) ? file_get_contents($path) : null]);
}
/** Queue submitted HTML for publication at a future absolute timestamp. */
private function scheduleAction(): void
{
$this->requirePost('schedule');
$uri = $this->requiredPost('uri');
$this->assertNotLockedForEditor($uri);
$content = $this->requiredContentPost('content');
$this->assertPublishable($uri);
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
if ($publishAt <= new \DateTimeImmutable()) {
throw new \RuntimeException('Publish time must be in the future');
}
// Store bulky HTML separately so the JSON schedule index remains easy to inspect.
$id = bin2hex(random_bytes(10));
$this->store->update('schedules', function (array $jobs) use ($id, $uri, $content, $publishAt) {
if (count($jobs) >= $this->maxSchedules || $this->directoryBytes('scheduled') + strlen($content) > $this->maxScheduledBytes) {
throw new \RuntimeException('Scheduled publication quota has been reached');
}
$this->store->writePrivateFile('scheduled', $id . '.html', $content);
$jobs[$id] = ['uri' => $this->paths->normaliseUri($uri), 'publish_at' => $publishAt->format(DATE_ATOM), 'user' => $this->user()];
return $jobs;
});
$this->activity('Scheduled publication', $uri);
$this->respond(['message' => 'Page scheduled', 'id' => $id, 'publish_at' => $publishAt->format(DATE_ATOM)]);
}
/** Cancel one scheduled publication and remove its staged HTML file. */
private function cancelScheduleAction(): void
{
$this->requirePost('schedule');
$id = $this->requiredPost('id');
$removed = null;
$this->store->update('schedules', function (array $jobs) use ($id, &$removed) {
$removed = $jobs[$id] ?? null;
unset($jobs[$id]);
return $jobs;
});
@unlink($this->store->directory('scheduled') . basename($id) . '.html');
if ($removed) {
$this->activity('Cancelled scheduled publication', ($removed['uri'] ?? '') . ' (was due ' . ($removed['publish_at'] ?? '?') . ', scheduled by ' . ($removed['user'] ?? 'unknown') . ')');
}
$this->respond(['message' => 'Schedule cancelled']);
}
/** List usable HTML templates for the new-page dialogue. */
private function getTemplatesAction(): void
{
$templates = [];
foreach (array_diff(scandir($this->templatesDir), ['.', '..']) as $file) {
if (preg_match('/\.html?$/i', $file)) {
$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);
}
/** 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']);
}
/** 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.
*/
private function newPageAction(): void
{
$this->requirePost('manage');
$name = mb_substr(trim((string) preg_replace('/\s+/u', ' ', $this->requiredPost('name'))), 0, 80);
if ($name === '') {
throw new \RuntimeException('A page name is required');
}
$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');
}
$uri = $this->uniqueUri($name);
$html = (string) file_get_contents($source);
$safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html = (string) preg_replace_callback('#(
]*>).*?()#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
{
$this->requirePost('manage');
$operation = $this->requiredPost('operation');
$sourceUri = $this->paths->normaliseUri($this->requiredPost('uri'));
$source = $this->paths->existing($sourceUri);
// Deletion gets its own branch because it has no target URI.
if ($operation === 'delete') {
$this->createRevision($sourceUri, (string) file_get_contents($source), 'Before delete');
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->store->update('pagelocks', function (array $locked) use ($sourceUri) {
unset($locked[$sourceUri]);
return $locked;
});
$this->activity('Deleted page', $sourceUri);
$this->respond(['message' => 'Page deleted']);
return;
}
$targetUri = $this->paths->normaliseNewUri($this->requiredPost('target'));
$target = $this->paths->newPath($targetUri);
if (file_exists($target)) {
throw new \RuntimeException('Target page already exists');
}
$this->ensureParentDirectory($target);
$this->paths->assertNoSymlinks($target);
$ok = $operation === 'rename' ? rename($source, $target) : ($operation === 'duplicate' && copy($source, $target));
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->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]);
}
/** Discover HTML pages containing the configured editable class. */
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);
$html = (string) file_get_contents($path);
if (!ContentDom::hasClass($html, $this->editableClass)) {
continue;
}
$pages[] = [
'name' => $uri,
'url' => $uri,
'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, 'locked' => isset($locked[$uri])];
}
usort($pages, fn(array $a, array $b) => strcmp($a['name'], $b['name']));
$this->respond($pages);
}
/** List newest-first revisions belonging to one page URI. */
private function revisionsAction(): void
{
$uri = $this->paths->normaliseUri($this->requiredRequest('uri'));
$items = array_values(array_filter($this->store->read('revisions'), fn(array $item) => $item['uri'] === $uri));
usort($items, fn(array $a, array $b) => self::revisionTime($b) <=> self::revisionTime($a));
$this->respond($items);
}
/** Restore a revision, recreating a deleted page when necessary. */
private function restoreRevisionAction(): void
{
$this->requirePost('publish');
$id = basename($this->requiredPost('id'));
$index = $this->store->read('revisions');
$revision = $index[$id] ?? null;
$path = $this->store->directory('revisions') . $id . '.html';
if (!$revision || !is_file($path)) {
throw new \RuntimeException('Revision not found');
}
$content = (string) file_get_contents($path);
try {
$this->publishContent($revision['uri'], $content, 'Restored revision');
} catch (PageNotFoundException) {
// A missing target is expected when restoring the revision of a deleted page.
$destination = $this->paths->newPath($this->paths->normaliseNewUri($revision['uri']));
$this->ensureParentDirectory($destination);
$this->paths->assertNoSymlinks($destination);
if (file_put_contents($destination, $content, LOCK_EX) === false) {
throw new \RuntimeException('Unable to restore deleted page');
}
$this->activity('Restored deleted page', $revision['uri']);
}
$this->respond(['message' => 'Revision restored', 'url' => $revision['uri']]);
}
/** List every managed page and uploaded image so the browser can analyse them in small, countable batches. */
private function listSitePagesAction(): void
{
$this->requireCapability('manage');
$uploads = [];
foreach (glob(dirname(__DIR__, 3) . '/uploads/*') ?: [] as $file) {
if (is_file($file) && $this->isManagedMediaName(basename($file))) {
$uploads[] = basename($file);
}
}
$pages = array_map(fn(string $path) => $this->paths->uriFor($path), $this->paths->files());
sort($pages);
$this->respond(['pages' => $pages, 'uploads' => $uploads]);
}
/** Report what a site scan would change for one batch of pages: regions, images, references, and SEO gaps. */
private function analyseSitePagesAction(): void
{
$this->requireCapability('manage');
$uris = $this->uriBatch($this->requiredRequest('uris'));
$basePath = (string) ($this->config['basePath'] ?? '');
$allNavs = !empty($_REQUEST['allNavs']);
$all = ['content' => true, 'images' => true, 'seo' => true, 'menus' => true, 'allNavs' => $allNavs];
$pages = [];
$usedUploads = [];
foreach ($uris as $uri) {
try {
$path = $this->paths->existing($uri);
} catch (PageNotFoundException) {
continue;
}
$html = (string) file_get_contents($path);
$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/(' . 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'])),
'navs' => $a['navs'], 'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
'changes' => $plan['changes'] ?? [],
];
}
$this->respond(['pages' => $pages, 'usedUploads' => array_values(array_unique($usedUploads))]);
}
/** Decode a JSON list of page URIs, normalising each and bounding the batch size. */
private function uriBatch(string $json): array
{
$uris = json_decode($json, true);
if (!is_array($uris) || !$uris) {
throw new \RuntimeException('Select at least one page');
}
if (count($uris) > 100) {
throw new \RuntimeException('Too many pages in one request');
}
return array_values(array_unique(array_map(fn($uri) => $this->paths->normaliseUri((string) $uri), $uris)));
}
/** Add editing markers (content regions, image markers, missing SEO tags) to the selected pages. */
private function applySiteTaggingAction(): void
{
$this->requirePost('manage');
$uris = $this->uriBatch($this->limitedPost('uris', 1024 * 1024));
$options = json_decode($this->limitedPost('options', 4096), true);
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']), 'menus' => !empty($options['menus']), 'allNavs' => !empty($options['allNavs'])];
if (!array_filter($flags)) {
throw new \RuntimeException('Choose at least one kind of change');
}
$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]);
}
/** Return the complete shared-content registry. */
private function sharedAction(): void
{
$this->respond($this->store->read('shared'));
}
/** Save a shared block and propagate it to every marked public page. */
private function saveSharedAction(): void
{
$this->requirePost('manage');
$key = preg_replace('/[^a-zA-Z0-9_-]/', '', $this->requiredPost('key'));
$content = $this->limitedPost('content', $this->maxContentBytes);
if ($key === '') {
throw new \RuntimeException('Shared block name is required');
}
$entry = ['content' => $content, 'updated' => date(DATE_ATOM), 'user' => $this->user()];
$this->store->update('shared', function (array $shared) use ($key, $entry) {
$shared[$key] = $entry;
return $shared;
});
$updated = $this->propagateSharedBlock($key, $content);
$this->activity('Updated shared block', $key);
$this->respond(['message' => "Shared block updated on {$updated} page(s)", 'updated_pages' => $updated]);
}
/** Return all configured navigation menus. */
private function menusAction(): void
{
$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
{
$this->requirePost('manage');
$name = preg_replace('/[^a-zA-Z0-9_-]/', '', $this->requiredPost('name'));
$items = json_decode($this->limitedPost('items', 1024 * 1024), true);
if ($name === '' || !is_array($items)) {
throw new \RuntimeException('A menu name and items are required');
}
$clean = [];
// Ignore malformed rows rather than storing menu entries that cannot produce a link.
foreach ($items as $item) {
if (!is_array($item) || empty($item['url'])) {
continue;
}
$clean[] = ['label' => trim((string) ($item['label'] ?? $item['url'])), 'url' => $this->paths->normaliseLink((string) $item['url']), 'parent' => trim((string) ($item['parent'] ?? ''))];
}
// 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, $clean);
$this->activity('Updated menu', $name);
$this->respond(['message' => "Menu saved and updated on {$updated} page(s)", 'html' => $html, 'updated_pages' => $updated]);
}
/** List uploaded files with metadata, byte size, modification time, and page-use count. */
private function mediaAction(): void
{
$metadata = $this->store->read('media');
$usage = $this->mediaUsageCounts();
$uploadDir = dirname(__DIR__, 3) . '/uploads/';
$items = [];
// index.php guards the directory and is infrastructure rather than editorial media.
foreach (glob($uploadDir . '*') ?: [] as $file) {
if (!is_file($file) || !$this->isManagedMediaName(basename($file))) {
continue;
}
$url = '/uploads/' . basename($file);
$items[] = [
'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']));
$this->respond($items);
}
/** Update the reusable alternative-text metadata for one uploaded image. */
private function updateMediaAction(): void
{
$this->requirePost('upload');
$name = $this->managedMediaName($this->requiredPost('name'));
$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] ?? []);
return $metadata;
});
$this->activity(MediaTypes::categoryForName($name) === 'imagery' ? 'Updated image alt text' : 'Updated media description', $name);
$this->respond(['message' => 'Media details saved']);
}
/** Delete an uploaded file after an administrator confirms the operation in the UI. */
private function deleteMediaAction(): void
{
$this->requirePost('manage');
$name = $this->managedMediaName($this->requiredPost('name'));
$path = dirname(__DIR__, 3) . '/uploads/' . $name;
if (!is_file($path) || !unlink($path)) {
throw new \RuntimeException('Unable to delete media');
}
$this->store->update('media', function (array $metadata) use ($name) {
unset($metadata[$name]);
return $metadata;
});
$this->activity('Deleted media', $name);
$this->respond(['message' => 'Media deleted']);
}
/** Return permissions, pending work, recent activity, and writable-directory warnings. */
private function dashboardAction(): void
{
$drafts = $this->store->read('drafts');
$schedules = $this->store->read('schedules');
$activity = array_slice(array_reverse($this->store->read('activity')), 0, 20);
$problems = [];
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
{
$this->requirePost();
$this->authentication->logout();
$this->respond(['message' => 'Logged out']);
}
/**
* Atomically replace a public page, preserving its previous contents as a revision.
*
* @param string $uri Public page URI.
* @param string $content Complete replacement HTML document.
* @param string $reason Human-readable activity and revision reason.
*/
private function publishContent(string $uri, string $content, string $reason): void
{
$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. */
private function atomicWrite(string $path, string $content): void
{
$temporary = $path . '.neo-' . bin2hex(random_bytes(5));
if (file_put_contents($temporary, $content, LOCK_EX) === false || !rename($temporary, $path)) {
@unlink($temporary);
throw new \RuntimeException('Failed to publish page');
}
}
/** Save an immutable HTML snapshot and add its searchable metadata to the revision index. */
private function createRevision(string $uri, string $content, string $reason): void
{
$id = date('YmdHis') . '-' . bin2hex(random_bytes(5));
$this->store->writePrivateFile('revisions', $id . '.html', $content);
$entry = ['id' => $id, 'uri' => $this->paths->normaliseUri($uri), 'created' => date(DATE_ATOM), 'ts' => microtime(true), 'user' => $this->user(), 'reason' => $reason];
$remove = [];
$this->store->update('revisions', function (array $index) use ($id, $entry, &$remove) {
$index[$id] = $entry;
$remove = $this->pruneRevisions($index);
return $index;
});
$directory = $this->store->directory('revisions');
foreach ($remove as $removeId) {
@unlink($directory . basename($removeId) . '.html');
}
}
/** Sortable creation time of a revision index entry. */
private static function revisionTime(array $revision): float
{
return (float) ($revision['ts'] ?? strtotime((string) ($revision['created'] ?? '')));
}
/** Apply per-page, global-count, and aggregate-byte retention limits to revisions. */
private function pruneRevisions(array &$index): array
{
// Newest first by microsecond 'ts'; 'created' has one-second resolution, so sorting on it alone could prune
// a revision made in the same second as older ones. Entries written before 'ts' existed fall back to 'created'.
uasort($index, static fn(array $a, array $b): int => self::revisionTime($b) <=> self::revisionTime($a));
$perPage = [];
$kept = 0;
$bytes = 0;
$remove = [];
$directory = $this->store->directory('revisions');
foreach ($index as $id => $revision) {
$uri = (string) ($revision['uri'] ?? '');
$perPage[$uri] = ($perPage[$uri] ?? 0) + 1;
$path = $directory . basename((string) $id) . '.html';
$size = is_file($path) ? (int) filesize($path) : 0;
$overPage = $perPage[$uri] > $this->maxRevisionsPerPage;
$overCount = $kept >= $this->maxRevisionsTotal;
$overBytes = $bytes + $size > $this->maxRevisionBytes;
if ($overPage || $overCount || $overBytes || !is_file($path)) {
$remove[] = (string) $id;
continue;
}
$kept++;
$bytes += $size;
}
foreach ($remove as $id) {
unset($index[$id]);
}
return $remove;
}
/** Sum regular file sizes within one private data subdirectory. */
private function directoryBytes(string $name): int
{
$bytes = 0;
foreach (glob($this->store->directory($name) . '*') ?: [] as $path) {
if (is_file($path)) {
$bytes += (int) filesize($path);
}
}
return $bytes;
}
/**
* Publish every job whose timestamp is due and remove completed schedule artefacts.
*
* @return int Number of pages successfully published during this pass.
*/
private function publishDueJobs(): int
{
$isDue = static fn(array $job): bool => ($job['status'] ?? 'pending') === 'pending' && strtotime($job['publish_at'] ?? '') <= time();
// Cheap unlocked check first: most requests have nothing due, so they should not contend for the lock.
if (!array_filter($this->store->read('schedules'), $isDue)) {
return 0;
}
$lockPath = $this->store->directory('locks') . 'scheduled.lock';
$lock = fopen($lockPath, 'c+');
if ($lock === false || !flock($lock, LOCK_EX)) {
if (is_resource($lock)) {
fclose($lock);
}
throw new \RuntimeException('Unable to lock scheduled publications');
}
@chmod($lockPath, 0600);
try {
$published = [];
$failed = [];
foreach (array_filter($this->store->read('schedules'), $isDue) as $id => $job) {
$path = $this->store->directory('scheduled') . basename((string) $id) . '.html';
try {
if (!is_file($path)) {
throw new \RuntimeException('Staged publication content is missing');
}
$this->publishContent((string) ($job['uri'] ?? ''), (string) file_get_contents($path), 'Scheduled publication');
unlink($path);
$published[] = $id;
} catch (\Throwable $exception) {
$failed[] = $id;
$this->activity('Scheduled publication failed', ($job['uri'] ?? '') . ': ' . $exception->getMessage(), (string) ($job['user'] ?? 'scheduler'));
}
}
// Apply outcomes to a fresh read so jobs scheduled or cancelled meanwhile are not lost.
$this->store->update('schedules', function (array $jobs) use ($published, $failed) {
foreach ($published as $id) {
unset($jobs[$id]);
}
foreach ($failed as $id) {
if (isset($jobs[$id])) {
$jobs[$id]['status'] = 'failed';
$jobs[$id]['failed_at'] = date(DATE_ATOM);
$jobs[$id]['error'] = 'Scheduled publication failed';
}
}
return $jobs;
});
return count($published);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Capture shared-region values from a newly published page into the central registry. */
private function captureSharedBlocks(string $html): void
{
$found = [];
foreach (ContentDom::sharedBlocks($html) as $key => $content) {
$found[$key] = ['content' => $content, 'updated' => date(DATE_ATOM), 'user' => $this->user()];
}
if ($found) {
$this->store->update('shared', fn(array $shared) => array_replace($shared, $found));
}
}
/**
* Replace matching shared regions across public pages.
*
* Every affected page receives a revision first, because global edits should remain globally
* reversible rather than becoming an exciting lesson in backups.
*
* @return int Number of changed pages.
*/
private function propagateSharedBlock(string $key, string $content): int
{
return $this->rewritePages(
fn(string $html) => ContentDom::withShared($html, $key, $content),
'Before shared content update'
);
}
/** Replace matching generated menus across public pages, revisioning each page first. */
private function propagateMenu(string $name, array $items): int
{
$basePath = (string) ($this->config['basePath'] ?? '');
return $this->rewritePages(
fn(string $html, string $uri) => ContentDom::withMenu($html, $name, $items, $uri, $basePath),
'Before menu update'
);
}
/**
* Apply a page transform site-wide: revision each changed page, then replace it atomically.
*
* @param callable $transform Receives page HTML and URI; returns new HTML, or null to leave the page alone.
* @param array|null $onlyUris Restrict the pass to these page URIs; null means every page.
* @return int Number of changed pages.
*/
private function rewritePages(callable $transform, string $reason, ?array $onlyUris = null): int
{
$count = 0;
// A URI list is resolved one page at a time, so a small batch does not rescan the whole document root.
if ($onlyUris !== null) {
$paths = [];
foreach ($onlyUris as $listed) {
try {
$paths[] = $this->paths->existing($listed);
} catch (PageNotFoundException) {
// A page deleted since it was listed is simply skipped.
}
}
}
foreach ($onlyUris !== null ? $paths : $this->paths->files() as $path) {
$uri = $this->paths->uriFor($path);
$html = (string) file_get_contents($path);
$changed = $transform($html, $uri);
if ($changed === null) {
continue;
}
$this->createRevision($this->paths->uriFor($path), $html, $reason);
$this->atomicWrite($path, $changed);
$count++;
}
return $count;
}
/** Derive the private draft filename from a stable hash of its public URI. */
private function draftPath(string $uri): string
{
return $this->store->directory('drafts') . hash('sha256', $this->paths->normaliseUri($uri)) . '.html';
}
/** Remove a page's draft HTML and its dashboard metadata entry. */
private function deleteDraft(string $uri): void
{
@unlink($this->draftPath($uri));
$draftUri = $this->paths->normaliseUri($uri);
$this->store->update('drafts', function (array $drafts) use ($draftUri) {
unset($drafts[$draftUri]);
return $drafts;
});
}
/** Count all managed upload references in one pass across the public pages. */
private function mediaUsageCounts(): array
{
$uses = [];
foreach ($this->paths->files() as $path) {
$html = (string) file_get_contents($path);
if (preg_match_all('#/uploads/' . MediaTypes::nameRegex() . '#', $html, $matches)) {
foreach ($matches[0] as $url) {
$uses[$url] = ($uses[$url] ?? 0) + 1;
}
}
}
return $uses;
}
/** Validate that a filename belongs to the generated media namespace created by uploads. */
private function managedMediaName(string $name): string
{
$name = basename($name);
if (!$this->isManagedMediaName($name)) {
throw new \RuntimeException('Invalid media filename');
}
return $name;
}
/** Return whether a filename is a NeoCMS-generated media name with an allowed extension. */
private function isManagedMediaName(string $name): bool
{
return MediaTypes::isManagedName($name);
}
/** Extract a plain-text document title for the page picker. */
private function extractTitle(string $html): string
{
return preg_match('/]*>(.*?)<\/title>/is', $html, $match) ? trim(html_entity_decode(strip_tags($match[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : 'Untitled';
}
/** Create a new page's parent directories when they do not already exist. */
private function ensureParentDirectory(string $path): void
{
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
throw new \RuntimeException('Unable to create page directory');
}
}
/**
* Enforce POST, CSRF, and optional role-capability requirements for a mutating action.
*
* Read-only actions still require authentication through handleRequest(), but do not need a
* CSRF token because they cannot alter server state.
*/
private function requirePost(?string $capability = null): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new \RuntimeException('POST required');
}
if (!$this->authentication->isValidCsrfToken($_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null)) {
throw new \RuntimeException('Invalid CSRF token');
}
if ($capability) {
$this->requireCapability($capability);
}
}
/** Refuse the request unless the current role includes the capability. */
private function requireCapability(string $capability): void
{
if (!$this->authentication->can($capability)) {
throw new \RuntimeException('Your role cannot perform this action');
}
}
/** 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
{
$value = $_POST[$key] ?? '';
if (!is_string($value) || $value === '') {
throw new \RuntimeException("{$key} is required");
}
return $value;
}
/** Return a required complete-document field after enforcing the configured byte limit. */
private function requiredContentPost(string $key): string
{
$value = $this->requiredPost($key);
if (strlen($value) > $this->maxContentBytes) {
throw new \RuntimeException('Content exceeds the configured size limit');
}
return $value;
}
/** Return an optional string POST field after enforcing an action-specific byte limit. */
private function limitedPost(string $key, int $maxBytes): string
{
$value = $_POST[$key] ?? '';
if (!is_string($value)) {
throw new \RuntimeException("{$key} must be a string");
}
if (strlen($value) > $maxBytes) {
throw new \RuntimeException("{$key} exceeds the configured size limit");
}
return $value;
}
/** Return a required string field from either query or form input. */
private function requiredRequest(string $key): string
{
$value = $_REQUEST[$key] ?? '';
if (!is_string($value) || $value === '') {
throw new \RuntimeException("{$key} is required");
}
return $value;
}
/** Add a bounded dashboard activity entry and mirror it to the audit log. */
private function activity(string $action, string $target, ?string $user = null): void
{
$this->activity->record($user ?? $this->user(), $action, $target);
}
/** Return the authenticated username used for metadata and audit attribution. */
private function user(): string
{
return $this->authentication->getLoggedInUser();
}
/** Emit one JSON response with defensive content-sniffing protection. */
private function respond($data, int $status = 200): void
{
http_response_code($status);
SecurityHeaders::json(isset($this->config['security']['cookieSecure']) ? (bool) $this->config['security']['cookieSecure'] : null);
echo json_encode($data, JSON_UNESCAPED_SLASHES);
}
}