Files
neoCMS/cms/src/NeoCMS/PagePaths.php
T
Geoff Moore 2ae3e3c0e6 Add site scan, editor-based publishing workflow and subfolder support
Adds a whole-site "Site scan" that analyses pages, images and SEO tags and
makes pages editable in one step, moves Save Draft / Schedule Publish /
Publish Now into the content editor, supports installs in a subfolder, and
hardens metadata handling. Only the cms/ directory is changed.

Added
- Site scan (Tools > Site scan, administrators): reports pages, folders,
  editable/untagged pages, images without alt text, broken local
  references, unused uploads and missing title/description/Open Graph
  tags; then tags <main> or top-level article/section content areas,
  images outside editable regions and photo placeholders, and inserts only
  missing SEO tags. Page source is edited byte-for-byte (no DOM
  re-serialisation), a revision is kept for every changed page, later scans
  list only pages that still need changes, and work runs in small batches
  with a progress bar and "N of M pages" count. New administrator-only
  actions: listSitePages, analyseSitePages, applySiteTagging.
- Click-to-edit images and photo placeholders (role="img" boxes) with a
  Media Library picker and alt text.
- Subfolder installs (basePath / siteRoot auto-detected); root installs
  behave as before.
- Audit/activity entries for cancelled schedules, image uploads, alt-text
  changes and failed scheduled publications (shared Activity class).
- Snackbar (success/error) and table-style dialogs for the page picker,
  revision history, dashboard, shared content, menus and site scan.

Changed
- Editor actions are now Save Draft | Schedule Publish | Publish Now |
  Cancel; saving in the editor and applying image/SEO changes stores a
  private draft. The toolbar no longer has Save Draft, Schedule or Publish.
- CMSController split into PagePaths, ContentDom and Activity; entry points
  share cms/bootstrap.php. Restoring a deleted page uses
  PageNotFoundException instead of matching an error message.

Fixed
- Lost updates on JSON metadata (locked FileStore::update).
- Non-atomic site-wide shared-content/menu writes; non-ASCII text handling.
- Scheduled publishing contending for a lock on every request.
- Revision pruning dropping the newest revision (revisions gain a "ts").
- Stale cached pages in the editor preview after a site is replaced.
- Saved drafts reloading without stylesheets or the correct page path, and
  Publish discarding a draft that was not re-offered in the same session.
- HTML entities in page titles; backslashes in folder names on Windows.

Migration: no manual steps. basePath/siteRoot are optional and detected
automatically. The analyseSite action was replaced by listSitePages and
analyseSitePages.
2026-09-24 13:17:09 +01:00

164 lines
6.5 KiB
PHP

<?php
namespace NeoCMS;
/** Resolves public page URIs to filesystem paths inside the document root, rejecting anything that escapes it. */
final class PagePaths
{
public function __construct(private string $documentRoot, private int $maxManagedPages, private int $maxScannedEntries)
{
}
/** Resolve an existing public URI to a canonical, in-root HTML file path. */
public function existing(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 PageNotFoundException('Invalid page path');
}
return $real;
}
/** Build a safe path for a page that may not exist yet. */
public function newPath(string $uri): string
{
$path = $this->documentRoot . ltrim($uri, '/');
if (!str_starts_with($path, $this->documentRoot)) {
throw new \RuntimeException('Invalid page path');
}
$this->assertNoSymlinks($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. */
public function assertNoSymlinks(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 new page URI, append .html when absent, and enforce safe characters. */
public function normaliseNewUri(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;
}
/** Canonicalise a request URI and reject traversal or null-byte input. */
public 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;
}
/** Accept an absolute HTTP(S) link or normalise a local site path. */
public function normaliseLink(string $url): string
{
if (preg_match('#^https?://#i', $url)) {
return filter_var($url, FILTER_SANITIZE_URL);
}
return $this->normaliseUri($url);
}
/** Return every public .html or .htm file while excluding the CMS application itself. */
public function files(): 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;
}
/** Convert an absolute page path back into its public, slash-separated URI. */
public function uriFor(string $path): string
{
return '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->documentRoot)));
}
/**
* Whether a page's local asset reference points at a real file inside the site.
*
* @return bool|null null for external or non-file references (http:, //, data:, #anchor), which are not checked.
*/
public function refExists(string $pagePath, string $ref, string $basePath = ''): ?bool
{
$ref = trim($ref);
if ($ref === '' || preg_match('#^([a-z][a-z0-9+.-]*:|//|\#)#i', $ref)) {
return null;
}
$ref = rawurldecode((string) preg_replace('/[?#].*$/s', '', $ref));
if ($ref === '' || str_contains($ref, "\0")) {
return $ref === '' ? null : false;
}
if ($ref[0] === '/') {
// The upload endpoint writes URLs with the site's URL prefix; strip it to get a path inside the site.
if ($basePath !== '' && str_starts_with($ref, $basePath . '/')) {
$ref = substr($ref, strlen($basePath));
}
$candidate = $this->documentRoot . ltrim($ref, '/');
} else {
$candidate = dirname($pagePath) . DIRECTORY_SEPARATOR . $ref;
}
$real = realpath($candidate);
return $real !== false && str_starts_with($real, $this->documentRoot) && is_file($real);
}
}