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.
164 lines
6.5 KiB
PHP
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);
|
|
}
|
|
}
|