config = $config; $this->authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []); $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); $templatesDir = realpath(__DIR__ . '/../../templates'); $documentRoot = realpath((string) ($_SERVER['DOCUMENT_ROOT'] ?? '')); if ($templatesDir === false || $documentRoot === false) { throw new \RuntimeException('CMS filesystem paths are unavailable'); } $this->templatesDir = rtrim($templatesDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; $this->documentRoot = rtrim($documentRoot, 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->maxScannedEntries = 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 { $this->publishDueJobs(); // 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'); $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'); $content = $this->requiredContentPost('content'); $draftName = hash('sha256', $this->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. $drafts = $this->store->read('drafts'); $drafts[$this->normaliseUri($uri)] = [ 'updated' => date(DATE_ATOM), 'user' => $this->user(), ]; $this->store->write('drafts', $drafts); $this->activity('Saved draft', $uri); $this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]); } /** 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'); $content = $this->requiredContentPost('content'); $this->existingPagePath($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)); $jobs = $this->store->read('schedules'); 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->normaliseUri($uri), 'publish_at' => $publishAt->format(DATE_ATOM), 'user' => $this->user()]; $this->store->write('schedules', $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'); $jobs = $this->store->read('schedules'); unset($jobs[$id]); @unlink($this->store->directory('scheduled') . basename($id) . '.html'); $this->store->write('schedules', $jobs); $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)]; } } $this->respond($templates); } /** Create a new page from a selected template; administrators only. */ private function newPageAction(): void { $this->requirePost('manage'); $uri = $this->normaliseNewPageUri($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'); } if (count($this->pageFiles()) >= $this->maxManagedPages) { throw new \RuntimeException('Managed page limit has been reached'); } $destination = $this->newPagePath($uri); if (file_exists($destination)) { throw new \RuntimeException('Page already exists'); } $this->ensureParentDirectory($destination); $this->assertNoSymlinkComponents($destination); if (!copy($source, $destination)) { throw new \RuntimeException('Template copy failed'); } $this->activity('Created page', $uri); $this->respond(['message' => 'Page created', 'url' => $uri]); } /** Rename, duplicate, or revision-then-delete an existing managed page. */ private function pageAction(): void { $this->requirePost('manage'); $operation = $this->requiredPost('operation'); $sourceUri = $this->normaliseUri($this->requiredPost('uri')); $source = $this->existingPagePath($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->activity('Deleted page', $sourceUri); $this->respond(['message' => 'Page deleted']); return; } $targetUri = $this->normaliseNewPageUri($this->requiredPost('target')); $target = $this->newPagePath($targetUri); if (file_exists($target)) { throw new \RuntimeException('Target page already exists'); } $this->ensureParentDirectory($target); $this->assertNoSymlinkComponents($target); $ok = $operation === 'rename' ? rename($source, $target) : ($operation === 'duplicate' && copy($source, $target)); if (!$ok) { throw new \RuntimeException('Page operation failed'); } $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'); $pages = []; foreach ($this->pageFiles() as $path) { $uri = '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot))); $html = (string) file_get_contents($path); if (!$this->hasEditableClass($html)) { continue; } $pages[] = [ 'name' => $uri, 'url' => $uri, 'title' => $this->extractTitle($html), 'modified' => date(DATE_ATOM, filemtime($path)), 'draft' => isset($drafts[$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->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) => strcmp($b['created'], $a['created'])); $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 (\RuntimeException $exception) { // A missing target is expected when restoring the revision of a deleted page. if ($exception->getMessage() !== 'Invalid page path') { throw $exception; } $destination = $this->newPagePath($this->normaliseNewPageUri($revision['uri'])); $this->ensureParentDirectory($destination); $this->assertNoSymlinkComponents($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']]); } /** 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'); } $shared = $this->store->read('shared'); $shared[$key] = ['content' => $content, 'updated' => date(DATE_ATOM), 'user' => $this->user()]; $this->store->write('shared', $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')); } /** 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->normaliseLink((string) $item['url']), 'parent' => trim((string) ($item['parent'] ?? ''))]; } $menus = $this->store->read('menus'); $menus[$name] = ['items' => $clean, 'updated' => date(DATE_ATOM)]; $this->store->write('menus', $menus); $html = $this->renderMenu($name, $clean); $updated = $this->propagateMenu($name, $html); $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' => $url, 'size' => filesize($file), 'modified' => date(DATE_ATOM, filemtime($file)), 'alt' => $metadata[basename($file)]['alt'] ?? '', 'uses' => $usage[$url] ?? 0, ]; } 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')); $metadata = $this->store->read('media'); $alt = is_string($_POST['alt'] ?? null) ? trim($_POST['alt']) : ''; $metadata[$name] = ['alt' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $alt) ?? '', 0, 500)]; $this->store->write('media', $metadata); $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'); } $metadata = $this->store->read('media'); unset($metadata[$name]); $this->store->write('media', $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 ([__DIR__ . '/../../data', dirname(__DIR__, 3) . '/uploads'] as $directory) { if (!is_dir($directory) || !is_writable($directory)) { $problems[] = $directory . ' is not writable'; } } $this->respond([ '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, ]); } /** 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 { $path = $this->existingPagePath($uri); $old = (string) file_get_contents($path); if ($old !== $content) { $this->createRevision($uri, $old, $reason); } // Write beside the destination and rename to avoid serving a partially written document. $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'); } $this->captureSharedBlocks($content); $this->activity($reason, $uri); } /** 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); $index = $this->store->read('revisions'); $index[$id] = ['id' => $id, 'uri' => $this->normaliseUri($uri), 'created' => date(DATE_ATOM), 'user' => $this->user(), 'reason' => $reason]; $remove = $this->pruneRevisions($index); $this->store->write('revisions', $index); $directory = $this->store->directory('revisions'); foreach ($remove as $removeId) { @unlink($directory . basename($removeId) . '.html'); } } /** Apply per-page, global-count, and aggregate-byte retention limits to revisions. */ private function pruneRevisions(array &$index): array { uasort($index, static fn(array $a, array $b): int => strcmp((string) ($b['created'] ?? ''), (string) ($a['created'] ?? ''))); $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 { $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 { $jobs = $this->store->read('schedules'); $changed = false; $published = 0; foreach ($jobs as $id => &$job) { if (($job['status'] ?? 'pending') !== 'pending' || strtotime($job['publish_at'] ?? '') > time()) { continue; } $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); unset($jobs[$id]); $published++; } catch (\Throwable $exception) { $job['status'] = 'failed'; $job['failed_at'] = date(DATE_ATOM); $job['error'] = 'Scheduled publication failed'; $this->logger->write("Scheduled publication {$id} failed: {$exception->getMessage()}", (string) ($job['user'] ?? 'scheduler')); } $changed = true; } unset($job); // The index is written once after the loop, avoiding needless churn for several due jobs. if ($changed) { $this->store->write('schedules', $jobs); } return $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 { $dom = new \DOMDocument(); @$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $xpath = new \DOMXPath($dom); $shared = $this->store->read('shared'); foreach ($xpath->query('//*[@data-neo-shared]') as $node) { $key = $node->getAttribute('data-neo-shared'); if ($key !== '') { $shared[$key] = ['content' => $this->innerHtml($node), 'updated' => date(DATE_ATOM), 'user' => $this->user()]; } } $this->store->write('shared', $shared); } /** * 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 { $count = 0; foreach ($this->pageFiles() as $path) { $html = (string) file_get_contents($path); if (!str_contains($html, 'data-neo-shared')) { continue; } $dom = new \DOMDocument(); @$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $xpath = new \DOMXPath($dom); $nodes = $xpath->query('//*[@data-neo-shared="' . $key . '"]'); if ($nodes->length === 0) { continue; } $this->createRevision($this->uriForPath($path), $html, 'Before shared content update'); foreach ($nodes as $node) { $this->replaceInnerHtml($dom, $node, $content); } file_put_contents($path, $dom->saveHTML(), LOCK_EX); $count++; } return $count; } /** Replace matching generated menus across public pages, revisioning each page first. */ private function propagateMenu(string $name, string $menuHtml): int { $count = 0; foreach ($this->pageFiles() as $path) { $html = (string) file_get_contents($path); if (!str_contains($html, 'data-neo-menu')) { continue; } $dom = new \DOMDocument(); @$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $xpath = new \DOMXPath($dom); $nodes = $xpath->query('//*[@data-neo-menu="' . $name . '"]'); if ($nodes->length === 0) { continue; } // Parse the rendered menu once, then import a fresh deep copy for each destination node. $menuDom = new \DOMDocument(); @$menuDom->loadHTML($menuHtml, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $sourceNav = $menuDom->getElementsByTagName('nav')->item(0); if (!$sourceNav) { continue; } $this->createRevision($this->uriForPath($path), $html, 'Before menu update'); foreach (iterator_to_array($nodes) as $node) { $replacement = $dom->importNode($sourceNav, true); $node->parentNode->replaceChild($replacement, $node); } file_put_contents($path, $dom->saveHTML(), LOCK_EX); $count++; } return $count; } /** * Replace a DOM node's children with an HTML fragment. * * DOMDocument parses forgiving HTML here rather than strict XML, allowing ordinary authoring * fragments such as
without demanding that editors suddenly become XML librarians. */ private function replaceInnerHtml(\DOMDocument $dom, \DOMNode $node, string $html): void { while ($node->firstChild) { $node->removeChild($node->firstChild); } $temporary = new \DOMDocument(); @$temporary->loadHTML('
' . $html . '
', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); $wrapper = $temporary->getElementById('neo-fragment'); if (!$wrapper) { $node->appendChild($dom->createTextNode($html)); return; } foreach (iterator_to_array($wrapper->childNodes) as $child) { $node->appendChild($dom->importNode($child, true)); } } /** Return every public .html or .htm file while excluding the CMS application itself. */ private function pageFiles(): array { $files = []; $scanned = 0; $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->documentRoot, \FilesystemIterator::SKIP_DOTS)); foreach ($iterator as $file) { $scanned++; if ($scanned > $this->maxScannedEntries) { throw new \RuntimeException('Document root scan limit exceeded'); } if (count($files) >= $this->maxManagedPages) { throw new \RuntimeException('Managed page limit exceeded'); } if ($file->isLink()) { continue; } $path = $file->getRealPath(); if (!$file->isFile() || $path === false || !str_starts_with($path, $this->documentRoot) || !preg_match('/\.html?$/i', $path)) { continue; } if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) { continue; } $files[] = $path; } return $files; } /** Resolve an existing public URI to a canonical, in-root HTML file path. */ private function existingPagePath(string $uri): string { $uri = $this->normaliseUri($uri); $candidate = $this->documentRoot . ltrim($uri, '/'); if (is_dir($candidate)) { $candidate = rtrim($candidate, '/\\') . '/index.html'; } elseif (!preg_match('/\.html?$/i', $candidate)) { $candidate .= '.html'; } $real = realpath($candidate); if (!$real || !str_starts_with($real, $this->documentRoot) || str_starts_with($real, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR) || !preg_match('/\.html?$/i', $real)) { throw new \RuntimeException('Invalid page path'); } return $real; } /** Build a safe path for a page that may not exist yet. */ private function newPagePath(string $uri): string { $path = $this->documentRoot . ltrim($uri, '/'); if (!str_starts_with($path, $this->documentRoot)) { throw new \RuntimeException('Invalid page path'); } $this->assertNoSymlinkComponents($path); $parent = realpath(dirname($path)); if ($parent && !str_starts_with($parent . DIRECTORY_SEPARATOR, $this->documentRoot)) { throw new \RuntimeException('Invalid page path'); } if (str_starts_with($path, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR)) { throw new \RuntimeException('CMS files cannot be managed as pages'); } return $path; } /** Reject page targets whose existing path components contain symbolic links. */ private function assertNoSymlinkComponents(string $path): void { $relative = substr($path, strlen($this->documentRoot)); $current = rtrim($this->documentRoot, DIRECTORY_SEPARATOR); foreach (explode('/', str_replace('\\', '/', $relative)) as $component) { if ($component === '') { continue; } $current .= DIRECTORY_SEPARATOR . $component; if (is_link($current)) { throw new \RuntimeException('Symbolic links are not permitted in managed page paths'); } if (file_exists($current)) { $real = realpath($current); if ($real === false || !str_starts_with($real . (is_dir($real) ? DIRECTORY_SEPARATOR : ''), $this->documentRoot)) { throw new \RuntimeException('Invalid page path'); } } } } /** Canonicalise a request URI and reject traversal or null-byte input. */ private function normaliseUri(string $uri): string { $path = parse_url($uri, PHP_URL_PATH) ?: '/'; $path = '/' . ltrim(str_replace('\\', '/', $path), '/'); if (str_contains($path, '..') || str_contains($path, "\0")) { throw new \RuntimeException('Invalid URI'); } return $path; } /** Canonicalise a new page URI, append .html when absent, and enforce safe characters. */ private function normaliseNewPageUri(string $uri): string { $uri = $this->normaliseUri($uri); if (!preg_match('/\.html?$/i', $uri)) { $uri .= '.html'; } if (!preg_match('#^/[a-zA-Z0-9_./-]+\.html?$#', $uri)) { throw new \RuntimeException('Invalid page name'); } return $uri; } /** Accept an absolute HTTP(S) link or normalise a local site path. */ private function normaliseLink(string $url): string { if (preg_match('#^https?://#i', $url)) { return filter_var($url, FILTER_SANITIZE_URL); } return $this->normaliseUri($url); } /** 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->normaliseUri($uri)) . '.html'; } /** Remove a page's draft HTML and its dashboard metadata entry. */ private function deleteDraft(string $uri): void { @unlink($this->draftPath($uri)); $drafts = $this->store->read('drafts'); unset($drafts[$this->normaliseUri($uri)]); $this->store->write('drafts', $drafts); } /** Count all managed upload references in one pass across the public pages. */ private function mediaUsageCounts(): array { $uses = []; foreach ($this->pageFiles() as $path) { $html = (string) file_get_contents($path); if (preg_match_all('#/uploads/[a-f0-9]{32}\.(?:jpg|png|gif|webp)#', $html, $matches)) { foreach ($matches[0] as $url) { $uses[$url] = ($uses[$url] ?? 0) + 1; } } } return $uses; } /** Validate that a filename belongs to the randomised image 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 random NeoCMS image name with an allowed extension. */ private function isManagedMediaName(string $name): bool { return preg_match('/^[a-f0-9]{32}\.(?:jpg|png|gif|webp)$/', $name) === 1; } /** * Render a nested, escaped navigation list from flat parent-labelled items. * Circular parent references are stopped by the ancestor list rather than pursued forever. */ private function renderMenu(string $name, array $items): string { $children = []; foreach ($items as $item) { $children[$item['parent'] ?? ''][] = $item; } $render = function (string $parent, array $ancestors = []) use (&$render, $children): string { if (empty($children[$parent])) { return ''; } $html = ''; }; return ''; } /** Extract a plain-text document title for the page picker. */ private function extractTitle(string $html): string { return preg_match('/]*>(.*?)<\/title>/is', $html, $match) ? trim(strip_tags($match[1])) : 'Untitled'; } /** Convert an absolute page path back into its public, slash-separated URI. */ private function uriForPath(string $path): string { return '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot))); } /** Determine whether a document contains at least one exactly matching editable class. */ private function hasEditableClass(string $html): bool { $dom = new \DOMDocument(); @$dom->loadHTML($html); $xpath = new \DOMXPath($dom); $class = $this->editableClass; // Class-token matching avoids treating "editable-extra" as though it were "editable". $query = '//*[contains(concat(" ", normalize-space(@class), " "), " ' . $class . ' ")]'; return $xpath->query($query)->length > 0; } /** Serialise only the children of a DOM node, excluding the node's own wrapper tag. */ private function innerHtml(\DOMNode $node): string { $html = ''; foreach ($node->childNodes as $child) { $html .= $node->ownerDocument->saveHTML($child); } return $html; } /** 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->authentication->can($capability)) { throw new \RuntimeException('Your role cannot perform this action'); } } /** 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): void { $entries = $this->store->read('activity'); $entries[] = ['created' => date(DATE_ATOM), 'user' => $this->user(), 'action' => $action, 'target' => $target]; // Retaining the latest 250 entries keeps the dashboard useful without growing forever. $this->store->write('activity', array_slice($entries, -250)); $this->logger->write($action . ': ' . $target, $this->user()); } /** 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); } }