Add Users, media library, navigation menus from the scan, new-page wizard and deleted-page recovery

Users (Tools > Users)
- Everyone can update their display name, email and password; administrators can add,
  edit, invite (one-time link), block and delete accounts.
- Accounts in config.local.php stay locked to config (the main administrator); CMS
  accounts live in data/users.json. Sign-in is by login name.
- Roles are now editor and administrator (publisher is an alias of editor).
- Account protection: password hashes only (12-72 characters), administrator password
  confirmation for sensitive actions, rate-limited failures, hashed single-use invite
  tokens, self-lockout guards, sessions of blocked/deleted users end on the next request,
  every account event audited without secrets.
- dataDirectory can move data outside the web root; the dashboard recommends it.

Media library
- Upload images, documents, video and audio, sorted into four categories by extension.
- Extension allow-list; executables, scripts, HTML/SVG, archives, legacy and macro Office
  files refused; content sniffing; files containing code refused; PDFs with scripts refused;
  Office/OpenDocument packages inspected for macros and embedded objects.
- Media library button in the editor inserts an image, document link, video or audio.

Navigation menus, pages and interface
- The site scan detects primary/secondary navigation and seeds Navigation Menus; menus can be
  renamed, edited and deleted but not created by the CMS; a scan button finds new navigation.
- New page wizard (template, name, navigation group) in the Pages dialogue; new pages stay
  private drafts until published; pages can be used as templates.
- Deleted pages can be recovered from the Dashboard.
- Full-width top bar with the user at the top right, device icons, consistent buttons.

Tests and docs updated (tests/run.php, README, ARCHITECTURE, DEVELOPMENT, CHANGELOG).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Geoff Moore
2026-09-24 17:47:34 +01:00
co-authored by Claude Sonnet 5
parent 89d64418de
commit 8946915bfe
20 changed files with 3001 additions and 325 deletions
+20 -11
View File
@@ -16,7 +16,7 @@ class Authentication
/** Map of usernames to password hashes. */
private array $credentials;
/** Map of usernames to editor, publisher, or administrator roles. */
/** Map of usernames to editor or administrator roles (the legacy publisher role counts as editor). */
private array $roles;
/** Maximum permitted inactivity for an authenticated session. */
@@ -91,10 +91,7 @@ class Authentication
session_regenerate_id(true);
$_SESSION['loggedIn'] = true;
$_SESSION['loggedInUser'] = $username;
$configuredRole = $this->roles[$username] ?? 'editor';
$_SESSION['role'] = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
? $configuredRole
: 'editor';
$_SESSION['role'] = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
$_SESSION['credentialFingerprint'] = hash('sha256', (string) $this->credentials[$username]);
$_SESSION['csrfToken'] = bin2hex(random_bytes(32));
$_SESSION['authenticatedAt'] = time();
@@ -131,10 +128,7 @@ class Authentication
public function getRole(): string
{
$username = $this->getLoggedInUser();
$configuredRole = $this->roles[$username] ?? 'editor';
$role = in_array($configuredRole, ['editor', 'publisher', 'administrator'], true)
? $configuredRole
: 'editor';
$role = UserStore::normaliseRole($this->roles[$username] ?? 'editor');
$_SESSION['role'] = $role;
return $role;
}
@@ -147,14 +141,29 @@ class Authentication
public function can(string $capability): bool
{
$permissions = [
'editor' => ['draft', 'upload'],
'publisher' => ['draft', 'upload', 'publish', 'schedule'],
'editor' => ['draft', 'upload', 'publish', 'schedule'],
'administrator' => ['draft', 'upload', 'publish', 'schedule', 'manage'],
];
return in_array($capability, $permissions[$this->getRole()] ?? [], true);
}
/** Re-bind the current session to a changed credential so the user stays signed in while their other sessions end. */
public function renewSession(string $credentialHash): void
{
$_SESSION['credentialFingerprint'] = hash('sha256', $credentialHash);
if (session_status() === PHP_SESSION_ACTIVE && !headers_sent()) {
session_regenerate_id(true);
}
$_SESSION['lastRegeneratedAt'] = time();
}
/** Verify the signed-in user's password (for re-authentication before sensitive actions). */
public function verifyCurrentPassword(string $password): bool
{
return $this->credentialsAreValid($this->getLoggedInUser(), $password);
}
/** Return the session's CSRF token, generating a cryptographically random token when absent. */
public function getCsrfToken(): string
{
+498 -43
View File
@@ -45,15 +45,19 @@ final class CMSController
/** Maximum aggregate revision storage. */
private int $maxRevisionBytes;
/** Account registry (config accounts plus CMS-managed accounts). */
private UserStore $users;
/** Build the controller services and normalise all configured filesystem paths. */
public function __construct(array $config)
{
$this->config = $config;
$this->authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$this->authentication = new Authentication(...UserStore::authArgs($config));
$this->logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
// A custom data directory is primarily useful for tests and hardened deployments.
$dataDirectory = $config['dataDirectory'] ?? (__DIR__ . '/../../data');
$this->store = new FileStore((string) $dataDirectory);
$this->users = new UserStore($this->store, $config);
$this->activity = new Activity($this->store, $this->logger);
$templatesDir = realpath(__DIR__ . '/../../templates');
$documentRoot = realpath((string) ($config['siteRoot'] ?? $_SERVER['DOCUMENT_ROOT'] ?? ''));
@@ -147,6 +151,14 @@ final class CMSController
$this->requirePost('draft');
$uri = $this->requiredPost('uri');
$content = $this->requiredContentPost('content');
$this->storeDraft($uri, $content);
$this->activity('Saved draft', $uri);
$this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]);
}
/** Write a private draft (within the draft quota) and record it in the drafts index. */
private function storeDraft(string $uri, string $content): void
{
$draftName = hash('sha256', $this->paths->normaliseUri($uri)) . '.html';
$draftPath = $this->store->directory('drafts') . $draftName;
$draftBytes = $this->directoryBytes('drafts') - (is_file($draftPath) ? (int) filesize($draftPath) : 0);
@@ -161,8 +173,6 @@ final class CMSController
$drafts[$draftUri] = $entry;
return $drafts;
});
$this->activity('Saved draft', $uri);
$this->respond(['message' => 'Draft saved', 'updated' => date(DATE_ATOM)]);
}
/** Return the saved draft for one URI, if present. */
@@ -179,7 +189,7 @@ final class CMSController
$this->requirePost('schedule');
$uri = $this->requiredPost('uri');
$content = $this->requiredContentPost('content');
$this->paths->existing($uri);
$this->assertPublishable($uri);
$publishAt = new \DateTimeImmutable($this->requiredPost('publish_at'));
if ($publishAt <= new \DateTimeImmutable()) {
throw new \RuntimeException('Publish time must be in the future');
@@ -225,35 +235,211 @@ final class CMSController
$templates[] = ['id' => $file, 'name' => pathinfo($file, PATHINFO_FILENAME)];
}
}
// Pages an administrator has marked as templates are offered too.
foreach (array_keys($this->store->read('pagetemplates')) as $uri) {
try {
$html = (string) file_get_contents($this->paths->existing($uri));
} catch (PageNotFoundException) {
continue;
}
$templates[] = ['id' => 'page:' . $uri, 'name' => $this->extractTitle($html) ?: $uri, 'detail' => $uri];
}
$this->respond($templates);
}
/** Create a new page from a selected template; administrators only. */
/** Delete a template file, or stop using a page as a template (the page itself is kept); administrators only. */
private function deleteTemplateAction(): void
{
$this->requirePost('manage');
$template = $this->requiredPost('template');
if (str_starts_with($template, 'page:')) {
$uri = $this->paths->normaliseUri(substr($template, 5));
$this->store->update('pagetemplates', function (array $marked) use ($uri) {
unset($marked[$uri]);
return $marked;
});
$this->activity('Unmarked page as template', $uri);
$this->respond(['message' => 'Page is no longer a template']);
return;
}
$file = basename($template);
$path = realpath($this->templatesDir . $file);
if (!$path || !str_starts_with($path, $this->templatesDir) || !preg_match('/\.html?$/i', $file) || !unlink($path)) {
throw new \RuntimeException('Template not found');
}
$this->activity('Deleted template', $file);
$this->respond(['message' => 'Template deleted']);
}
/** Mark a page as a template for New Page, or remove the mark; administrators only. */
private function setPageTemplateAction(): void
{
$this->requirePost('manage');
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
$this->paths->existing($uri);
$on = ($_POST['value'] ?? '') === '1';
$this->store->update('pagetemplates', function (array $marked) use ($uri, $on) {
if ($on) {
$marked[$uri] = ['user' => $this->user(), 'created' => date(DATE_ATOM)];
} else {
unset($marked[$uri]);
}
return $marked;
});
$this->activity($on ? 'Marked page as template' : 'Unmarked page as template', $uri);
$this->respond(['message' => $on ? 'Template created. It is now available in New Page.' : 'Page is no longer a template']);
}
/**
* Start a new page from a template. Nothing is written to the site: the page exists only as a private draft
* and a pending record until it is first published, so it is not visible on the front end before then.
*/
private function newPageAction(): void
{
$this->requirePost('manage');
$uri = $this->paths->normaliseNewUri($this->requiredPost('filename'));
$template = basename($this->requiredPost('template'));
$source = realpath($this->templatesDir . $template);
if (!$source || !str_starts_with($source, $this->templatesDir)) {
throw new \RuntimeException('Template not found');
$name = mb_substr(trim((string) preg_replace('/\s+/u', ' ', $this->requiredPost('name'))), 0, 80);
if ($name === '') {
throw new \RuntimeException('A page name is required');
}
if (count($this->paths->files()) >= $this->maxManagedPages) {
$template = $this->requiredPost('template');
if (str_starts_with($template, 'page:')) {
// A page marked as a template: it must still be marked, and still exist.
$templateUri = $this->paths->normaliseUri(substr($template, 5));
if (!isset($this->store->read('pagetemplates')[$templateUri])) {
throw new \RuntimeException('Template not found');
}
$source = $this->paths->existing($templateUri);
} else {
$template = basename($template);
$source = realpath($this->templatesDir . $template);
if (!$source || !str_starts_with($source, $this->templatesDir)) {
throw new \RuntimeException('Template not found');
}
}
$menu = trim((string) ($_POST['menu'] ?? ''));
if ($menu !== '' && !isset($this->store->read('menus')[$menu])) {
throw new \RuntimeException('Navigation group not found');
}
if (count($this->paths->files()) + count($this->store->read('newpages')) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit has been reached');
}
$destination = $this->paths->newPath($uri);
if (file_exists($destination)) {
throw new \RuntimeException('Page already exists');
}
$this->ensureParentDirectory($destination);
$this->paths->assertNoSymlinks($destination);
if (!copy($source, $destination)) {
throw new \RuntimeException('Template copy failed');
}
$this->activity('Created page', $uri);
$this->respond(['message' => 'Page created', 'url' => $uri]);
$uri = $this->uniqueUri($name);
$html = (string) file_get_contents($source);
$safeName = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html = (string) preg_replace_callback('#(<title\b[^>]*>).*?(</title>)#is', fn(array $m) => $m[1] . $safeName . $m[2], $html, 1);
$this->storeDraft($uri, $html);
$entry = ['title' => $name, 'menu' => $menu, 'template' => $template, 'user' => $this->user(), 'created' => date(DATE_ATOM)];
$this->store->update('newpages', function (array $pending) use ($uri, $entry) {
$pending[$uri] = $entry;
return $pending;
});
$this->activity('Created page (unpublished)', $uri);
$this->respond(['message' => 'Page created as a draft. It is not visible on the site until you publish it.', 'url' => $uri]);
}
/** A filename derived from the page name (about-us.html), numbered when a page or pending page already uses it. */
private function uniqueUri(string $name): string
{
$ascii = function_exists('iconv') ? (string) @iconv('UTF-8', 'ASCII//TRANSLIT', $name) : $name;
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($ascii)), '-') ?: 'page';
$pending = $this->store->read('newpages');
for ($n = 1; $n < 1000; $n++) {
$uri = $this->paths->normaliseNewUri('/' . $slug . ($n > 1 ? '-' . $n : '') . '.html');
if (!isset($pending[$uri]) && !file_exists($this->paths->newPath($uri))) {
return $uri;
}
}
throw new \RuntimeException('Could not find a free filename for this page');
}
/** The pending record for a page that has been created but never published, or null. */
private function pendingPage(string $uri): ?array
{
return $this->store->read('newpages')[$this->paths->normaliseUri($uri)] ?? null;
}
/** Whether a page can be published or scheduled: it exists, or it is a new page waiting for its first publication. */
private function assertPublishable(string $uri): void
{
try {
$this->paths->existing($uri);
} catch (PageNotFoundException $exception) {
if (!$this->pendingPage($uri)) {
throw $exception;
}
}
}
/** Throw away a new page that was never published: its pending record, draft and any scheduled publication. */
private function discardNewPageAction(): void
{
$this->requirePost('manage');
$uri = $this->paths->normaliseUri($this->requiredPost('uri'));
if (!$this->pendingPage($uri)) {
throw new \RuntimeException('Unpublished page not found');
}
$this->store->update('newpages', function (array $pending) use ($uri) {
unset($pending[$uri]);
return $pending;
});
$this->deleteDraft($uri);
$staged = [];
$this->store->update('schedules', function (array $jobs) use ($uri, &$staged) {
foreach ($jobs as $id => $job) {
if (($job['uri'] ?? '') === $uri) {
$staged[] = (string) $id;
unset($jobs[$id]);
}
}
return $jobs;
});
foreach ($staged as $id) {
@unlink($this->store->directory('scheduled') . basename($id) . '.html');
}
$this->activity('Discarded unpublished page', $uri);
$this->respond(['message' => 'Unpublished page discarded']);
}
/** After a new page's first publication: add its link to the chosen menu on every page, then forget the pending record. */
private function finishNewPage(string $uri): void
{
$uri = $this->paths->normaliseUri($uri);
$pending = $this->pendingPage($uri);
if (!$pending) {
return;
}
try {
$menu = (string) ($pending['menu'] ?? '');
$items = null;
if ($menu !== '') {
$url = (string) ($this->config['basePath'] ?? '') . $uri;
$this->store->update('menus', function (array $menus) use ($menu, $url, $pending, &$items) {
if (!isset($menus[$menu])) {
return $menus;
}
$menus[$menu]['items'] = $menus[$menu]['items'] ?? [];
if (!in_array($url, array_column($menus[$menu]['items'], 'url'), true)) {
$menus[$menu]['items'][] = ['label' => (string) $pending['title'], 'url' => $url, 'parent' => ''];
$menus[$menu]['updated'] = date(DATE_ATOM);
}
$items = $menus[$menu]['items'];
return $menus;
});
if ($items !== null) {
$this->propagateMenu($menu, $items);
$this->activity('Added page to menu', $uri . ' -> ' . $menu);
}
}
} catch (\Throwable $exception) {
// The page is already live; a menu problem is reported in the log rather than failing the publish.
$this->logger->write("Adding {$uri} to its menu failed: {$exception->getMessage()}", $this->user());
}
$this->store->update('newpages', function (array $all) use ($uri) {
unset($all[$uri]);
return $all;
});
$this->deleteDraft($uri);
}
/** Rename, duplicate, or revision-then-delete an existing managed page. */
private function pageAction(): void
{
@@ -268,6 +454,10 @@ final class CMSController
if (!unlink($source)) {
throw new \RuntimeException('Unable to delete page');
}
$this->store->update('pagetemplates', function (array $marked) use ($sourceUri) {
unset($marked[$sourceUri]);
return $marked;
});
$this->activity('Deleted page', $sourceUri);
$this->respond(['message' => 'Page deleted']);
return;
@@ -284,6 +474,15 @@ final class CMSController
if (!$ok) {
throw new \RuntimeException('Page operation failed');
}
if ($operation === 'rename') {
$this->store->update('pagetemplates', function (array $marked) use ($sourceUri, $targetUri) {
if (isset($marked[$sourceUri])) {
$marked[$targetUri] = $marked[$sourceUri];
unset($marked[$sourceUri]);
}
return $marked;
});
}
$this->activity(ucfirst($operation) . 'd page', $sourceUri . ' -> ' . $targetUri);
$this->respond(['message' => 'Page ' . $operation . 'd', 'url' => $targetUri]);
}
@@ -307,6 +506,9 @@ final class CMSController
'draft' => isset($drafts[$uri]),
];
}
foreach ($this->store->read('newpages') as $uri => $entry) {
$pages[] = ['name' => $uri, 'url' => $uri, 'title' => (string) ($entry['title'] ?? ''), 'modified' => $entry['created'] ?? date(DATE_ATOM), 'draft' => true, 'pending' => true];
}
usort($pages, fn(array $a, array $b) => strcmp($a['name'], $b['name']));
$this->respond($pages);
}
@@ -368,7 +570,8 @@ final class CMSController
$this->requireCapability('manage');
$uris = $this->uriBatch($this->requiredRequest('uris'));
$basePath = (string) ($this->config['basePath'] ?? '');
$all = ['content' => true, 'images' => true, 'seo' => true];
$allNavs = !empty($_REQUEST['allNavs']);
$all = ['content' => true, 'images' => true, 'seo' => true, 'menus' => true, 'allNavs' => $allNavs];
$pages = [];
$usedUploads = [];
foreach ($uris as $uri) {
@@ -378,21 +581,21 @@ final class CMSController
continue;
}
$html = (string) file_get_contents($path);
$a = SiteAnalyser::analyse($html, $this->editableClass);
$a = SiteAnalyser::analyse($html, $this->editableClass, $allNavs);
$broken = [];
foreach ($a['refs'] as $ref) {
if ($this->paths->refExists($path, $ref['url'], $basePath) === false) {
$broken[] = $ref['url'];
}
}
if (preg_match_all('#/uploads/([a-f0-9]{32}\.(?:jpg|png|gif|webp))#', $html, $matches)) {
if (preg_match_all('#/uploads/(' . MediaTypes::nameRegex() . ')#', $html, $matches)) {
array_push($usedUploads, ...$matches[1]);
}
$plan = SiteAnalyser::apply($html, $this->editableClass, $all);
$pages[] = [
'uri' => $this->paths->uriFor($path), 'title' => $a['title'], 'editableRegions' => $a['existingRegions'], 'proposed' => $a['proposed'],
'images' => count($a['images']), 'missingAlt' => count(array_filter($a['images'], fn($i) => !$i['hasAlt'])),
'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
'navs' => $a['navs'], 'broken' => $broken, 'seoMissing' => array_keys(array_filter($a['seo'], fn($present) => !$present)),
'changes' => $plan['changes'] ?? [],
];
}
@@ -420,18 +623,37 @@ final class CMSController
if (!is_array($options)) {
throw new \RuntimeException('Choose at least one kind of change');
}
$flags = ['content' => !empty($options['content']), 'images' => !empty($options['images']), 'seo' => !empty($options['seo'])];
$flags = ['content' => !empty($options['content']), 'images' => !empty($options['images']), 'seo' => !empty($options['seo']), 'menus' => !empty($options['menus']), 'allNavs' => !empty($options['allNavs'])];
if (!array_filter($flags)) {
throw new \RuntimeException('Choose at least one kind of change');
}
$pages = [];
$count = $this->rewritePages(function (string $html, string $uri) use ($flags, &$pages) {
$seed = [];
$basePath = (string) ($this->config['basePath'] ?? '');
$count = $this->rewritePages(function (string $html, string $uri) use ($flags, &$pages, &$seed, $basePath) {
$result = SiteAnalyser::apply($html, $this->editableClass, $flags);
if ($result) {
$pages[$uri] = $result['changes'];
// The first page seen supplies a menu's items; links become site paths so they work from any folder.
foreach ($result['menus'] as $name => $links) {
$seed[$name] ??= array_map(function (array $link) use ($uri, $basePath) {
$path = ContentDom::resolveLink($link['url'], $uri);
return ['label' => $link['label'], 'url' => $path === null ? $link['url'] : $basePath . $path, 'parent' => $link['parent']];
}, $links);
}
}
return $result['html'] ?? null;
}, 'Before auto-tagging', $uris);
if ($seed) {
// A menu someone has already saved is never replaced by a scan.
$this->store->update('menus', function (array $menus) use ($seed) {
foreach ($seed as $name => $items) {
$menus[$name] ??= ['items' => $items, 'updated' => date(DATE_ATOM)];
}
return $menus;
});
$this->activity('Detected navigation menus', implode(', ', array_keys($seed)));
}
$this->activity('Auto-tagged pages', $count . ' page(s)');
$this->respond(['message' => "Updated {$count} page(s). Each has a revision to restore.", 'updated_pages' => $count, 'pages' => $pages]);
}
@@ -466,6 +688,24 @@ final class CMSController
$this->respond($this->store->read('menus'));
}
/** Remove a saved menu. Pages keep the navigation they already contain; only the stored definition goes. */
private function deleteMenuAction(): void
{
$this->requirePost('manage');
$name = preg_replace('/[^a-zA-Z0-9_-]/', '', $this->requiredPost('name'));
$found = false;
$this->store->update('menus', function (array $menus) use ($name, &$found) {
$found = isset($menus[$name]);
unset($menus[$name]);
return $menus;
});
if (!$found) {
throw new \RuntimeException('Menu not found');
}
$this->activity('Deleted menu', $name);
$this->respond(['message' => "Menu '{$name}' deleted. Pages keep their current navigation."]);
}
/** Validate, save, render, and propagate a named navigation menu. */
private function saveMenuAction(): void
{
@@ -483,12 +723,23 @@ final class CMSController
}
$clean[] = ['label' => trim((string) ($item['label'] ?? $item['url'])), 'url' => $this->paths->normaliseLink((string) $item['url']), 'parent' => trim((string) ($item['parent'] ?? ''))];
}
$this->store->update('menus', function (array $menus) use ($name, $clean) {
$menus[$name] = ['items' => $clean, 'updated' => date(DATE_ATOM)];
// Menus come from navigation in the site's pages; the CMS edits and renames them but never creates them.
if (!isset($this->store->read('menus')[$name])) {
throw new \RuntimeException("Menu not found. New menus are added to the pages by the web developer, then found with the navigation scan.");
}
$title = mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 60);
$this->store->update('menus', function (array $menus) use ($name, $clean, $title) {
$entry = ['items' => $clean, 'updated' => date(DATE_ATOM)] + $menus[$name];
if ($title !== '') {
$entry['title'] = $title;
} else {
unset($entry['title']);
}
$menus[$name] = $entry;
return $menus;
});
$html = ContentDom::renderMenu($name, $clean);
$updated = $this->propagateMenu($name, $html);
$updated = $this->propagateMenu($name, $clean);
$this->activity('Updated menu', $name);
$this->respond(['message' => "Menu saved and updated on {$updated} page(s)", 'html' => $html, 'updated_pages' => $updated]);
}
@@ -510,6 +761,8 @@ final class CMSController
'name' => basename($file), 'url' => ($this->config['basePath'] ?? '') . $url, 'size' => filesize($file),
'modified' => date(DATE_ATOM, filemtime($file)), 'alt' => $metadata[basename($file)]['alt'] ?? '',
'uses' => $usage[$url] ?? 0,
'category' => MediaTypes::categoryForName(basename($file)), 'ext' => strtolower(pathinfo($file, PATHINFO_EXTENSION)),
'original' => $metadata[basename($file)]['original'] ?? '',
];
}
usort($items, fn(array $a, array $b) => strcmp($b['modified'], $a['modified']));
@@ -524,10 +777,10 @@ final class CMSController
$alt = is_string($_POST['alt'] ?? null) ? trim($_POST['alt']) : '';
$entry = ['alt' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $alt) ?? '', 0, 500)];
$this->store->update('media', function (array $metadata) use ($name, $entry) {
$metadata[$name] = $entry;
$metadata[$name] = $entry + ($metadata[$name] ?? []);
return $metadata;
});
$this->activity('Updated image alt text', $name);
$this->activity(MediaTypes::categoryForName($name) === 'imagery' ? 'Updated image alt text' : 'Updated media description', $name);
$this->respond(['message' => 'Media details saved']);
}
@@ -555,18 +808,201 @@ final class CMSController
$schedules = $this->store->read('schedules');
$activity = array_slice(array_reverse($this->store->read('activity')), 0, 20);
$problems = [];
foreach ([__DIR__ . '/../../data', dirname(__DIR__, 3) . '/uploads'] as $directory) {
foreach ([(string) ($this->config['dataDirectory'] ?? (__DIR__ . '/../../data')), dirname(__DIR__, 3) . '/uploads'] as $directory) {
if (!is_dir($directory) || !is_writable($directory)) {
$problems[] = $directory . ' is not writable';
}
}
$notices = [];
$dataPath = realpath($this->config['dataDirectory'] ?? (__DIR__ . '/../../data'));
$sitePath = realpath((string) ($this->config['siteRoot'] ?? dirname(__DIR__, 3)));
if ($this->authentication->can('manage') && $dataPath !== false && $sitePath !== false
&& str_starts_with(str_replace('\\', '/', $dataPath) . '/', rtrim(str_replace('\\', '/', $sitePath), '/') . '/')) {
$notices[] = 'Account and page data is stored inside the web root (cms/data). For a live site, set dataDirectory in config.local.php to a folder outside the web root so it can never be served, even if the web server ignores the .htaccess rules.';
}
$this->respond([
'notices' => $notices,
'deleted' => $this->authentication->can('publish') ? $this->deletedPages() : [],
'user' => $this->user(), 'role' => $this->authentication->getRole(),
'permissions' => ['draft' => $this->authentication->can('draft'), 'publish' => $this->authentication->can('publish'), 'schedule' => $this->authentication->can('schedule'), 'manage' => $this->authentication->can('manage')],
'drafts' => $drafts, 'schedules' => $schedules, 'activity' => $activity, 'problems' => $problems,
]);
}
/** Pages whose newest revision is a "Before delete" snapshot and whose file is gone, newest deletion first. */
private function deletedPages(): array
{
$latest = [];
foreach ($this->store->read('revisions') as $revision) {
$uri = $revision['uri'] ?? '';
if (!isset($latest[$uri]) || self::revisionTime($revision) > self::revisionTime($latest[$uri])) {
$latest[$uri] = $revision;
}
}
$deleted = [];
foreach ($latest as $uri => $revision) {
if (($revision['reason'] ?? '') !== 'Before delete') {
continue;
}
try {
$this->paths->existing($uri);
} catch (PageNotFoundException) {
$deleted[] = $revision;
} catch (\Throwable) {
}
}
usort($deleted, fn(array $a, array $b) => self::revisionTime($b) <=> self::revisionTime($a));
return $deleted;
}
/** The signed-in user's account, the role descriptions, and (administrators only) every account. Never includes hashes or tokens. */
private function usersAction(): void
{
$me = $this->user();
$profile = $this->users->profile($me);
$this->respond([
'me' => ['username' => $me, 'name' => $profile['name'], 'email' => $profile['email'], 'role' => $this->authentication->getRole(), 'managed' => $profile['managed']],
'roles' => UserStore::ROLE_INFO,
'users' => $this->authentication->can('manage') ? $this->users->accounts() : [],
]);
}
/** Update the signed-in user's own display name and email; changing the email needs the current password. */
private function saveProfileAction(): void
{
$this->requirePost();
$me = $this->user();
$current = $this->users->profile($me);
$name = $this->requiredPost('name');
$email = is_string($_POST['email'] ?? null) ? trim($_POST['email']) : '';
if (strtolower($email) !== strtolower($current['email'])) {
$this->confirmPassword('current_password');
}
$this->users->setProfile($me, $name, $email);
$this->activity('Updated own account details', $me);
$this->respond(['message' => 'Account updated', 'name' => $this->users->profile($me)['name']]);
}
/** Change the signed-in user's own password. The current password is required and other sessions end. */
private function changePasswordAction(): void
{
$this->requirePost();
$me = $this->user();
if ($this->users->isManaged($me)) {
throw new \RuntimeException('This password is managed in config.local.php');
}
$new = is_string($_POST['new_password'] ?? null) ? $_POST['new_password'] : '';
if (!hash_equals($new, is_string($_POST['confirm_password'] ?? null) ? $_POST['confirm_password'] : "\0")) {
throw new \RuntimeException('The new password and confirmation do not match');
}
$this->confirmPassword('current_password');
$hash = $this->users->setPassword($me, $new);
$this->authentication->renewSession($hash);
$this->activity('Changed own password', $me);
$this->respond(['message' => 'Password changed. Your other sessions have been signed out.']);
}
/** Add a CMS account with a password, or edit one (blank password keeps it). Administrators only, with password confirmation. */
private function saveUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$existing = is_string($_POST['existing'] ?? null) ? trim($_POST['existing']) : '';
$name = $this->requiredPost('name');
$email = is_string($_POST['email'] ?? null) ? $_POST['email'] : '';
$role = $this->requiredPost('role');
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
if ($existing === '') {
$username = $this->requiredPost('username');
$this->users->create($username, $name, $email, $role, $password);
$this->activity('Added user', $username . ' (' . $role . ')');
$this->respond(['message' => 'User added']);
return;
}
$this->refuseSelf($existing);
$before = null;
foreach ($this->users->accounts() as $account) {
if ($account['username'] === $existing) {
$before = $account;
}
}
$this->users->update($existing, $name, $email, $role, $password);
$this->activity('Updated user', $existing);
if ($before && $before['role'] !== $role) {
$this->activity('Changed user role', $existing . ': ' . $before['role'] . ' -> ' . $role);
}
if ($password !== '') {
$this->activity('Reset user password', $existing);
}
$this->respond(['message' => 'User updated']);
}
/** Create or re-issue a one-time invitation link (shown once; only its hash is stored). */
private function inviteUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$existing = is_string($_POST['existing'] ?? null) ? trim($_POST['existing']) : '';
[$username, $token] = $this->users->invite($this->requiredPost('name'), $this->requiredPost('email'), $this->requiredPost('role'), $existing);
$this->activity($existing === '' ? 'Invited user' : 'Re-issued invitation', $username);
$this->respond([
'message' => 'Invitation created. Copy the link now; it is shown only once and expires in 7 days.',
'link' => (string) ($this->config['basePath'] ?? '') . '/cms/login/?invite=' . $token,
'username' => $username,
]);
}
/** Block or unblock a CMS account; a blocked account is signed out on its next request. */
private function blockUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$username = $this->requiredPost('username');
$this->refuseSelf($username);
$blocked = ($_POST['blocked'] ?? '') === '1';
$this->users->setBlocked($username, $blocked);
$this->activity($blocked ? 'Blocked user' : 'Unblocked user', $username);
$this->respond(['message' => $blocked ? 'User blocked' : 'User unblocked']);
}
/** Delete a CMS account. */
private function deleteUserAction(): void
{
$this->requirePost('manage');
$this->confirmPassword('confirm_password');
$username = $this->requiredPost('username');
$this->refuseSelf($username);
$this->users->delete($username);
$this->activity('Deleted user', $username);
$this->respond(['message' => 'User deleted']);
}
/** Stop administrators locking themselves out through the account-management actions. */
private function refuseSelf(string $username): void
{
if ($username === $this->user()) {
throw new \RuntimeException('You cannot do that to your own account. Use My account for your own details.');
}
}
/** Require the signed-in user's own password before a sensitive account action; failures are rate limited and audited. */
private function confirmPassword(string $field): void
{
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$key = 'reauth:' . $this->user();
$limiter = new LoginRateLimiter((string) ($this->config['dataDirectory'] ?? (__DIR__ . '/../../data')), $this->config['security'] ?? []);
if (($wait = $limiter->retryAfter($address, $key)) > 0) {
throw new \RuntimeException("Too many incorrect passwords. Try again in {$wait} seconds.");
}
$password = is_string($_POST[$field] ?? null) ? $_POST[$field] : '';
if ($password === '' || strlen($password) > 4096 || !$this->authentication->verifyCurrentPassword($password)) {
$limiter->recordFailure($address, $key);
$this->activity('Password confirmation failed', 'users');
throw new \RuntimeException('Your password is incorrect');
}
$limiter->clear($address, $key);
}
/** End the authenticated session after CSRF validation. */
private function logoutAction(): void
{
@@ -584,14 +1020,32 @@ final class CMSController
*/
private function publishContent(string $uri, string $content, string $reason): void
{
$path = $this->paths->existing($uri);
$old = (string) file_get_contents($path);
if ($old !== $content) {
$isNew = false;
try {
$path = $this->paths->existing($uri);
} catch (PageNotFoundException $exception) {
// A new page has no file until its first publication.
if (!$this->pendingPage($uri)) {
throw $exception;
}
if (count($this->paths->files()) >= $this->maxManagedPages) {
throw new \RuntimeException('Managed page limit has been reached');
}
$path = $this->paths->newPath($this->paths->normaliseNewUri($uri));
$this->ensureParentDirectory($path);
$this->paths->assertNoSymlinks($path);
$isNew = true;
}
$old = $isNew ? '' : (string) file_get_contents($path);
if (!$isNew && $old !== $content) {
$this->createRevision($uri, $old, $reason);
}
$this->atomicWrite($path, $content);
$this->captureSharedBlocks($content);
$this->activity($reason, $uri);
if ($isNew) {
$this->finishNewPage($uri);
}
}
/** Write beside the destination and rename, so a public page is never served half-written. */
@@ -764,10 +1218,11 @@ final class CMSController
}
/** Replace matching generated menus across public pages, revisioning each page first. */
private function propagateMenu(string $name, string $menuHtml): int
private function propagateMenu(string $name, array $items): int
{
$basePath = (string) ($this->config['basePath'] ?? '');
return $this->rewritePages(
fn(string $html) => ContentDom::withMenu($html, $name, $menuHtml),
fn(string $html, string $uri) => ContentDom::withMenu($html, $name, $items, $uri, $basePath),
'Before menu update'
);
}
@@ -830,7 +1285,7 @@ final class CMSController
$uses = [];
foreach ($this->paths->files() as $path) {
$html = (string) file_get_contents($path);
if (preg_match_all('#/uploads/[a-f0-9]{32}\.(?:jpg|png|gif|webp)#', $html, $matches)) {
if (preg_match_all('#/uploads/' . MediaTypes::nameRegex() . '#', $html, $matches)) {
foreach ($matches[0] as $url) {
$uses[$url] = ($uses[$url] ?? 0) + 1;
}
@@ -839,7 +1294,7 @@ final class CMSController
return $uses;
}
/** Validate that a filename belongs to the randomised image namespace created by uploads. */
/** Validate that a filename belongs to the generated media namespace created by uploads. */
private function managedMediaName(string $name): string
{
$name = basename($name);
@@ -849,10 +1304,10 @@ final class CMSController
return $name;
}
/** Return whether a filename is a random NeoCMS image name with an allowed extension. */
/** Return whether a filename is a NeoCMS-generated media name with an allowed extension. */
private function isManagedMediaName(string $name): bool
{
return preg_match('/^[a-f0-9]{32}\.(?:jpg|png|gif|webp)$/', $name) === 1;
return MediaTypes::isManagedName($name);
}
/** Extract a plain-text document title for the page picker. */
+64 -15
View File
@@ -36,35 +36,50 @@ final class ContentDom
return $dom->saveHTML();
}
/** Replace every element marked data-neo-menu="$name" with the rendered <nav>; null when nothing to change. */
public static function withMenu(string $html, string $name, string $menuHtml): ?string
/**
* Replace the list inside every element marked data-neo-menu="$name", keeping the element's own attributes
* and every other byte of the page. Null when nothing needs to change.
*/
public static function withMenu(string $html, string $name, array $items, string $uri = '', string $basePath = ''): ?string
{
if (!str_contains($html, 'data-neo-menu')) {
return null;
}
$dom = self::load($html);
$nodes = (new \DOMXPath($dom))->query('//*[@data-neo-menu="' . $name . '"]');
$sourceNav = self::load($menuHtml)->getElementsByTagName('nav')->item(0);
if ($nodes->length === 0 || !$sourceNav) {
return null;
$list = self::menuList($items, $uri, $basePath);
$flat = fn(string $s) => preg_replace('/>\s+</', '><', trim($s));
$edits = [];
foreach (SiteAnalyser::navs($html) as $nav) {
if ($nav['menu'] !== $name || $nav['close'] === null) {
continue;
}
if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) {
$edits[] = [$nav['end'], $nav['close'] - $nav['end']];
}
}
foreach (iterator_to_array($nodes) as $node) {
$node->parentNode->replaceChild($dom->importNode($sourceNav, true), $node);
rsort($edits);
foreach ($edits as [$offset, $length]) {
$html = substr($html, 0, $offset) . $list . substr($html, $offset + $length);
}
return $dom->saveHTML();
return $edits ? $html : null;
}
/** A menu as a standalone <nav> (the list itself comes from menuList). */
public static function renderMenu(string $name, array $items): string
{
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . self::menuList($items) . '</nav>';
}
/**
* Render a nested, escaped navigation list from flat parent-labelled items.
* Render a nested, escaped list from flat parent-labelled items, marking the link to the page being written.
* Circular parent references are stopped by the ancestor list rather than pursued forever.
*/
public static function renderMenu(string $name, array $items): string
public static function menuList(array $items, string $uri = '', string $basePath = ''): string
{
$children = [];
foreach ($items as $item) {
$children[$item['parent'] ?? ''][] = $item;
}
$render = function (string $parent, array $ancestors = []) use (&$render, $children): string {
$render = function (string $parent, array $ancestors = []) use (&$render, $children, $uri, $basePath): string {
if (empty($children[$parent])) {
return '';
}
@@ -72,14 +87,48 @@ final class ContentDom
foreach ($children[$parent] as $item) {
$label = htmlspecialchars($item['label'], ENT_QUOTES);
$url = htmlspecialchars($item['url'], ENT_QUOTES);
$current = $uri !== '' && self::isPage($item['url'], $uri, $basePath) ? ' aria-current="page"' : '';
$nested = in_array($item['label'], $ancestors, true) ? '' : $render($item['label'], array_merge($ancestors, [$item['label']]));
$html .= '<li><a href="' . $url . '">' . $label . '</a>' . $nested . '</li>';
$html .= '<li><a href="' . $url . '"' . $current . '>' . $label . '</a>' . $nested . '</li>';
}
return $html . '</ul>';
};
return '<nav data-neo-menu="' . htmlspecialchars($name, ENT_QUOTES) . '">' . $render('') . '</nav>';
return $render('');
}
/** Turn a page link into a site path (relative links resolved against the page, query/fragment kept); null for external or fragment-only links. */
public static function resolveLink(string $url, string $pageUri): ?string
{
$url = trim($url);
$path = parse_url($url, PHP_URL_PATH);
if ($url === '' || $url[0] === '#' || preg_match('#^([a-z][a-z0-9+.-]*:|//)#i', $url) || !is_string($path) || $path === '') {
return null;
}
if ($path[0] !== '/') {
$path = rtrim(str_replace('\\', '/', dirname($pageUri)), '/') . '/' . $path;
}
$out = [];
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
$segment === '..' ? array_pop($out) : $out[] = $segment;
}
return '/' . implode('/', $out) . (str_ends_with($path, '/') && $out ? '/' : '') . substr($url, strcspn($url, '?#'));
}
/** Whether a menu link points at the page with this URI (a folder link means its index.html; a subfolder install's prefix is ignored). */
private static function isPage(string $url, string $uri, string $basePath): bool
{
$resolved = self::resolveLink($url, $uri);
if ($resolved === null) {
return false;
}
$index = fn(string $p) => str_ends_with($p, '/') ? $p . 'index.html' : $p;
$path = $index((string) parse_url($resolved, PHP_URL_PATH));
$uri = $index($uri);
return $path === $uri || ($basePath !== '' && $path === $basePath . $uri);
}
/** Determine whether a document contains at least one element carrying exactly this CSS class token. */
public static function hasClass(string $html, string $class): bool
{
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace NeoCMS;
/**
* The one list of uploadable media: allowed extensions, their categories, and the safety checks applied to every upload.
*
* Only extensions listed here are accepted, so executables, scripts, archives, HTML/SVG and macro-enabled Office files are refused
* by omission. On top of that, content is sniffed and scanned for embedded code. This is best-effort hardening, not a malware scanner.
*/
final class MediaTypes
{
public const CATEGORIES = ['imagery' => 'Imagery', 'documents' => 'Documents', 'video' => 'Video', 'audio' => 'Audio'];
/** extension => category */
private const EXTENSIONS = [
'jpg' => 'imagery', 'jpeg' => 'imagery', 'png' => 'imagery', 'gif' => 'imagery', 'webp' => 'imagery',
'pdf' => 'documents', 'docx' => 'documents', 'xlsx' => 'documents', 'pptx' => 'documents',
'odt' => 'documents', 'ods' => 'documents', 'odp' => 'documents', 'txt' => 'documents', 'csv' => 'documents',
'mp4' => 'video', 'm4v' => 'video', 'webm' => 'video', 'mov' => 'video', 'ogv' => 'video',
'mp3' => 'audio', 'wav' => 'audio', 'ogg' => 'audio', 'oga' => 'audio', 'm4a' => 'audio', 'aac' => 'audio', 'flac' => 'audio',
];
/** Extensions that must never appear anywhere in a filename (for example report.exe.pdf), even though the file is renamed. */
private const DENIED_PARTS = [
'exe', 'dll', 'bat', 'cmd', 'com', 'msi', 'scr', 'ps1', 'vbs', 'vbe', 'js', 'jse', 'jar', 'wsf', 'hta', 'cpl', 'reg',
'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'cgi', 'pl', 'py', 'rb', 'sh', 'bash', 'asp', 'aspx', 'jsp', 'jspx',
'html', 'htm', 'xhtml', 'shtml', 'svg', 'xml', 'swf', 'lnk', 'apk', 'app', 'dmg', 'iso', 'so', 'bin',
'docm', 'xlsm', 'pptm', 'dotm', 'xltm', 'potm', 'xlam', 'ppam',
];
/** MIME types that always mean executable or markup content, whatever the extension claims. */
private const BLOCKED_MIMES = [
'application/x-dosexec', 'application/x-executable', 'application/x-sharedlib', 'application/x-mach-binary', 'application/x-msdownload',
'application/x-elf', 'application/x-msi', 'application/java-archive', 'application/x-httpd-php', 'application/javascript',
'text/javascript', 'text/x-php', 'text/x-shellscript', 'text/x-script.python', 'text/html', 'image/svg+xml', 'text/xml', 'application/xml',
];
/** Case-insensitive markers of server-side or browser code, refused anywhere in any upload. Long enough never to occur by chance in compressed data. */
private const CODE_MARKERS = ['<?php', '<script'];
/** Three-byte markers also refused, but only in plain-text uploads: in compressed media they appear by chance in any file of a few megabytes. */
private const TEXT_CODE_MARKERS = ['<?=', '<%@', '<%='];
/** Entry names refused inside Office/OpenDocument packages. */
private const DENIED_ENTRY_ENDINGS = ['.exe', '.dll', '.bat', '.cmd', '.com', '.msi', '.scr', '.ps1', '.vbs', '.js', '.jar', '.php', '.html', '.htm', '.sh', '.py', '.pl', '.bin'];
/** Expected ODF mimetype entry per extension. */
private const ODF = ['odt' => 'application/vnd.oasis.opendocument.text', 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', 'odp' => 'application/vnd.oasis.opendocument.presentation'];
/** Expected marker entry per OOXML extension. */
private const OOXML = ['docx' => 'word/document.xml', 'xlsx' => 'xl/workbook.xml', 'pptx' => 'ppt/presentation.xml'];
public static function extensions(): array
{
return array_keys(self::EXTENSIONS);
}
public static function categoryForExtension(string $extension): ?string
{
return self::EXTENSIONS[strtolower($extension)] ?? null;
}
/** Category of a stored filename, from its extension. */
public static function categoryForName(string $name): ?string
{
return self::categoryForExtension(pathinfo($name, PATHINFO_EXTENSION));
}
/** Regular-expression fragment (unanchored) matching every stored media filename: legacy 32-hex names or slug-8hex names. */
public static function nameRegex(): string
{
return '(?:[a-f0-9]{32}|[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?-[a-f0-9]{8})\.(?:' . implode('|', self::extensions()) . ')';
}
public static function isManagedName(string $name): bool
{
return preg_match('/^' . self::nameRegex() . '$/', $name) === 1;
}
/** New stored filename: a slug of the original name, a random suffix, and exactly one dot. */
public static function storedName(string $originalName, string $extension): string
{
$base = pathinfo(basename($originalName), PATHINFO_FILENAME);
if (function_exists('iconv')) {
$base = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $base) ?: $base;
}
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($base)), '-');
$slug = trim(substr($slug, 0, 60), '-');
return ($slug !== '' ? $slug : 'file') . '-' . bin2hex(random_bytes(4)) . '.' . $extension;
}
/**
* Check an uploaded file and report how to store it.
*
* @param array $limits maxBytes, maxWidth, maxHeight, maxPixels
* @return array{ext: string, category: string, mime: string}
* @throws \RuntimeException with a message safe to show the user
*/
public static function inspect(string $path, string $originalName, array $limits = []): array
{
$parts = explode('.', strtolower(basename(str_replace('\\', '/', $originalName))));
$extension = count($parts) > 1 ? (string) end($parts) : '';
$category = self::EXTENSIONS[$extension] ?? null;
foreach (array_slice($parts, 1) as $part) {
if (in_array($part, self::DENIED_PARTS, true)) {
throw new \RuntimeException('Files of this type (.' . $part . ') are not allowed.');
}
}
if ($category === null) {
throw new \RuntimeException('This file type is not allowed. Accepted: images, documents (PDF, Word, Excel, PowerPoint, OpenDocument, text, CSV), video, and audio.');
}
$size = @filesize($path);
if ($size === false || $size < 1) {
throw new \RuntimeException('The file is empty.');
}
if (isset($limits['maxBytes']) && $size > $limits['maxBytes']) {
throw new \RuntimeException('This file is larger than the ' . round($limits['maxBytes'] / 1048576, 1) . ' MB limit.');
}
$head = (string) file_get_contents($path, false, null, 0, 8);
foreach (["MZ", "\x7fELF", "#!", "\xCF\xFA\xED\xFE", "\xFE\xED\xFA\xCF", "\xCA\xFE\xBA\xBE"] as $signature) {
if (str_starts_with($head, $signature)) {
throw new \RuntimeException('Executable files are not allowed.');
}
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = (string) finfo_file($finfo, $path);
finfo_close($finfo);
if (in_array($mime, self::BLOCKED_MIMES, true)) {
throw new \RuntimeException('Executable or script content is not allowed.');
}
self::assertNoCode($path, $extension === 'txt' || $extension === 'csv');
match (true) {
$category === 'imagery' => self::checkImage($path, $extension, $mime, $limits),
$extension === 'pdf' => self::checkPdf($path, $mime),
isset(self::OOXML[$extension]) || isset(self::ODF[$extension]) => self::checkPackage($path, $extension),
$extension === 'txt' || $extension === 'csv' => self::checkText($path, $mime),
default => self::checkAv($path, $extension, $mime, $category),
};
return ['ext' => $extension === 'jpeg' ? 'jpg' : $extension, 'category' => $category, 'mime' => $mime];
}
private static function assertNoCode(string $path, bool $plainText): void
{
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new \RuntimeException('The file could not be read.');
}
$carry = '';
while (!feof($handle)) {
$chunk = (string) fread($handle, 1048576);
$window = strtolower($carry . $chunk);
foreach ($plainText ? array_merge(self::CODE_MARKERS, self::TEXT_CODE_MARKERS) : self::CODE_MARKERS as $marker) {
if (str_contains($window, $marker)) {
fclose($handle);
throw new \RuntimeException('Files containing code are not allowed.');
}
}
$carry = substr($chunk, -8);
}
fclose($handle);
}
private static function checkImage(string $path, string $extension, string $mime, array $limits): void
{
$expected = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp'][$extension];
if ($mime !== $expected) {
throw new \RuntimeException('The file contents do not match its image type.');
}
$info = @getimagesize($path);
if ($info === false) {
throw new \RuntimeException('Uploaded file is not a valid image.');
}
[$width, $height] = $info;
if ($width > ($limits['maxWidth'] ?? 8192) || $height > ($limits['maxHeight'] ?? 8192) || $width * $height > ($limits['maxPixels'] ?? 24 * 1024 * 1024)) {
throw new \RuntimeException('Image dimensions exceed the configured limit.');
}
}
private static function checkPdf(string $path, string $mime): void
{
$data = (string) file_get_contents($path);
if ($mime !== 'application/pdf' || !str_contains(substr($data, 0, 1024), '%PDF-')) {
throw new \RuntimeException('The file contents do not match a PDF.');
}
if (preg_match('#/JavaScript|/JS\b|/Launch|/EmbeddedFile|/RichMedia#', $data)) {
throw new \RuntimeException('PDFs containing scripts, launch actions or embedded files are not allowed.');
}
}
private static function checkText(string $path, string $mime): void
{
$data = (string) file_get_contents($path);
if (str_contains($data, "\0") || !preg_match('//u', $data)) {
throw new \RuntimeException('Text files must be plain UTF-8 text.');
}
if (!str_starts_with($mime, 'text/') && $mime !== 'application/csv') {
throw new \RuntimeException('The file contents do not match a text file.');
}
}
/** Audio and video must sniff as such; when libmagic only says "octet-stream", the container signature must match the extension. */
private static function checkAv(string $path, string $extension, string $mime, string $category): void
{
if (str_starts_with($mime, 'audio/') || str_starts_with($mime, 'video/') || $mime === 'application/ogg') {
return;
}
$head = (string) file_get_contents($path, false, null, 0, 12);
$sync = strlen($head) > 1 && ord($head[0]) === 0xFF;
$matches = match ($extension) {
'mp4', 'm4v', 'mov', 'm4a' => in_array(substr($head, 4, 4), ['ftyp', 'moov', 'mdat', 'free', 'wide', 'skip', 'pnot'], true),
'webm' => str_starts_with($head, "\x1A\x45\xDF\xA3"),
'ogv', 'ogg', 'oga' => str_starts_with($head, 'OggS'),
'mp3' => str_starts_with($head, 'ID3') || ($sync && (ord($head[1]) & 0xE0) === 0xE0),
'aac' => $sync && (ord($head[1]) & 0xF0) === 0xF0,
'wav' => str_starts_with($head, 'RIFF'),
'flac' => str_starts_with($head, 'fLaC'),
default => false,
};
if ($mime !== 'application/octet-stream' || !$matches) {
throw new \RuntimeException('The file contents do not match ' . ($category === 'video' ? 'a video' : 'an audio') . ' file.');
}
}
/** OOXML and OpenDocument files are ZIP packages: read the directory only, and refuse macros, embedded objects, and executables. */
private static function checkPackage(string $path, string $extension): void
{
$entries = self::zipEntries($path);
if (isset(self::ODF[$extension])) {
$type = self::zipRead($path, $entries['mimetype'] ?? null);
if ($type !== self::ODF[$extension]) {
throw new \RuntimeException('The file contents do not match an OpenDocument file.');
}
} elseif (!isset($entries['[Content_Types].xml']) || !isset($entries[self::OOXML[$extension]])) {
throw new \RuntimeException('The file contents do not match an Office document.');
}
foreach (array_keys($entries) as $name) {
$lower = strtolower((string) $name);
$bad = str_contains($lower, 'vbaproject') || str_contains($lower, 'activex') || str_contains($lower, 'embeddings/')
|| str_starts_with($lower, 'basic/') || str_starts_with($lower, 'scripts/') || str_contains($lower, '..') || str_starts_with($lower, '/');
foreach (self::DENIED_ENTRY_ENDINGS as $ending) {
$bad = $bad || str_ends_with($lower, $ending);
}
if ($bad) {
throw new \RuntimeException('Documents containing macros, embedded objects or scripts are not allowed.');
}
}
}
/** @return array<string, array{offset: int, csize: int, usize: int, method: int}> */
private static function zipEntries(string $path): array
{
$size = (int) filesize($path);
$tail = (string) file_get_contents($path, false, null, max(0, $size - 65557));
$eocd = strrpos($tail, "PK\x05\x06");
if ($eocd === false || strlen($tail) < $eocd + 22 || !str_starts_with((string) file_get_contents($path, false, null, 0, 4), "PK\x03\x04")) {
throw new \RuntimeException('The file is not a valid document package.');
}
$count = unpack('v', substr($tail, $eocd + 10, 2))[1];
$directorySize = unpack('V', substr($tail, $eocd + 12, 4))[1];
$directoryOffset = unpack('V', substr($tail, $eocd + 16, 4))[1];
if ($count === 0xFFFF || $directorySize === 0xFFFFFFFF || $count > 5000 || $directorySize > 5 * 1048576) {
throw new \RuntimeException('The document package is too complex.');
}
$directory = (string) file_get_contents($path, false, null, $directoryOffset, $directorySize);
$entries = [];
$position = 0;
while ($position + 46 <= strlen($directory) && substr($directory, $position, 4) === "PK\x01\x02") {
$method = unpack('v', substr($directory, $position + 10, 2))[1];
$csize = unpack('V', substr($directory, $position + 20, 4))[1];
$usize = unpack('V', substr($directory, $position + 24, 4))[1];
[$nameLength, $extraLength, $commentLength] = array_values(unpack('v3', substr($directory, $position + 28, 6)));
$offset = unpack('V', substr($directory, $position + 42, 4))[1];
$entries[substr($directory, $position + 46, $nameLength)] = ['offset' => $offset, 'csize' => $csize, 'usize' => $usize, 'method' => $method];
$position += 46 + $nameLength + $extraLength + $commentLength;
}
if (!$entries) {
throw new \RuntimeException('The file is not a valid document package.');
}
return $entries;
}
/** Read one stored (uncompressed) entry, used only for the small ODF mimetype file. */
private static function zipRead(string $path, ?array $entry): string
{
if ($entry === null || $entry['method'] !== 0 || $entry['usize'] > 256) {
return '';
}
$header = (string) file_get_contents($path, false, null, $entry['offset'], 30);
if (strlen($header) < 30) {
return '';
}
[$nameLength, $extraLength] = array_values(unpack('v2', substr($header, 26, 4)));
return (string) file_get_contents($path, false, null, $entry['offset'] + 30 + $nameLength + $extraLength, $entry['usize']);
}
}
+93 -4
View File
@@ -16,7 +16,7 @@ final class SiteAnalyser
private const MIN_SECTION_TEXT = 40;
/** Describe a page: existing and proposed editable regions, images, local references, and SEO tags present. */
public static function analyse(string $html, string $editableClass): array
public static function analyse(string $html, string $editableClass, bool $allNavs = false): array
{
$c = self::scan($html, $editableClass, true);
return [
@@ -26,14 +26,24 @@ final class SiteAnalyser
'images' => array_map(fn(array $i) => ['src' => $i['src'], 'hasAlt' => $i['hasAlt'], 'inRegion' => $i['inRegion'], 'tagged' => $i['tagged']], $c['images']),
'refs' => $c['refs'],
'seo' => $c['seo'],
'navs' => array_values(array_map(
fn(array $n) => ['menu' => $n['menu'] ?? ($allNavs ? $n['name'] : $n['slot']), 'links' => count($n['links']), 'tagged' => $n['menu'] !== null, 'sig' => md5(implode("\n", array_column($n['links'], 'url')))],
array_filter($c['navs'], fn(array $n) => $n['menu'] !== null || ($allNavs ? $n['name'] : $n['slot']) !== null)
)),
];
}
/** Every navigation block in a page with its byte offsets, links and any data-neo-menu name (used to replace menus in place). */
public static function navs(string $html): array
{
return self::navList($html, self::elements($html));
}
/**
* Insert the requested markers into a page.
*
* @param array $options Booleans: content, images, seo.
* @return array|null ['html' => string, 'changes' => string[]], or null when the page needs no change.
* @param array $options Booleans: content, images, seo, menus; allNavs also names navs that are not clearly header or footer menus.
* @return array|null ['html' => string, 'changes' => string[], 'menus' => [name => links]], or null when the page needs no change.
*/
public static function apply(string $html, string $editableClass, array $options): ?array
{
@@ -64,6 +74,19 @@ final class SiteAnalyser
$edits[] = $seo['edit'];
array_push($changes, ...$seo['changes']);
}
$menus = [];
if (!empty($options['menus'])) {
$named = array_filter(array_column($c['navs'], 'menu'));
foreach ($c['navs'] as $nav) {
$name = !empty($options['allNavs']) ? $nav['name'] : $nav['slot'];
// One nav per name per page, and never over a menu the page already marks by hand.
if ($nav['menu'] === null && $name !== null && !isset($menus[$name]) && !in_array($name, $named, true)) {
$edits[] = [$nav['insertAt'], ' data-neo-menu="' . $name . '"'];
$changes[] = 'Marked ' . $name . ' navigation as a menu (' . count($nav['links']) . ' links)';
$menus[$name] = $nav['links'];
}
}
}
if (!$edits) {
return null;
}
@@ -72,7 +95,72 @@ final class SiteAnalyser
foreach ($edits as [$offset, $text]) {
$html = substr($html, 0, $offset) . $text . substr($html, $offset);
}
return ['html' => $html, 'changes' => $changes];
return ['html' => $html, 'changes' => $changes, 'menus' => $menus];
}
/** Navigation blocks: <nav> or role=navigation, classified as the primary (header) or secondary (footer) menu when recognisable. */
private static function navList(string $html, array $els): array
{
$navs = [];
foreach ($els as $k => $e) {
if ($e['name'] !== 'nav' && strtolower((string) self::attr($e['attrs'], 'role')) !== 'navigation') {
continue;
}
$links = [];
foreach ($els as $a) {
if ($a['name'] !== 'a' || ($href = self::attr($a['attrs'], 'href')) === null) {
continue;
}
$inside = false;
$lis = [];
for ($p = $a['parent']; $p >= 0; $p = $els[$p]['parent']) {
if ($p === $k) {
$inside = true;
break;
}
if ($els[$p]['name'] === 'li') {
$lis[] = $p;
}
}
$href = trim(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$label = self::text($html, $a) ?: trim((string) self::attr($a['attrs'], 'aria-label'));
if (!$inside || $href === '' || $label === '' || stripos($href, 'javascript:') === 0) {
continue;
}
// A link inside a nested list is a child of the link that owns the enclosing list item.
$parent = '';
if (isset($lis[1])) {
foreach ($els as $b) {
if ($b['name'] === 'a' && $b['parent'] === $lis[1]) {
$parent = self::text($html, $b);
break;
}
}
}
$links[] = ['label' => $label, 'url' => $href, 'parent' => $parent];
}
$ancestors = [];
for ($p = $e['parent']; $p >= 0; $p = $els[$p]['parent']) {
$ancestors[] = $els[$p]['name'] . ':' . strtolower((string) self::attr($els[$p]['attrs'], 'role'));
}
$in = fn(string $tag, string $role) => in_array($tag . ':', $ancestors, true) || in_array(':' . $role, $ancestors, true) || in_array($tag . ':' . $role, $ancestors, true);
$label = strtolower(self::attr($e['attrs'], 'aria-label') . ' ' . self::attr($e['attrs'], 'class') . ' ' . self::attr($e['attrs'], 'id'));
$slot = null;
if ($links) {
if ($in('footer', 'contentinfo') || preg_match('/\b(footer|secondary|legal)\b/', $label)) {
$slot = 'secondary';
} elseif ($in('header', 'banner') || preg_match('/\b(main|primary|site-nav|top)\b/', $label)) {
$slot = 'primary';
}
}
$navs[] = [
'menu' => ($m = self::attr($e['attrs'], 'data-neo-menu')) !== null && $m !== '' ? $m : null,
'slot' => $slot, 'name' => $slot ?? ($links ? (trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(substr((string) (self::attr($e['attrs'], 'aria-label') ?: self::attr($e['attrs'], 'id')), 0, 40))), '-') ?: 'navigation') : null), 'links' => $links, 'attrs' => $e['attrs'],
'end' => $e['end'], 'close' => $e['close'], 'insertAt' => $e['start'] + 1 + strlen($e['name']),
];
}
return $navs;
}
/** Tokenise a page and gather everything analyse() and apply() need. */
@@ -163,6 +251,7 @@ final class SiteAnalyser
}
return [
'navs' => self::navList($html, $els),
'els' => $els, 'existing' => $existing, 'regions' => $regions, 'images' => $images, 'refs' => $refs,
'title' => (string) $title, 'h1' => $h1, 'paragraph' => $firstParagraph, 'metaText' => $meta,
'seo' => [
+430
View File
@@ -0,0 +1,430 @@
<?php
namespace NeoCMS;
/**
* Account registry: accounts from config.local.php are authoritative and locked; accounts created in the CMS live in data/users.json.
*
* Only active CMS accounts and config accounts ever reach Authentication, so blocking or deleting an account also ends its sessions.
* Password hashes and invite tokens never leave this class except as the hash handed to Authentication.
*/
final class UserStore
{
public const ROLES = ['editor', 'administrator'];
public const INVITE_LIFETIME = 604800;
public const MIN_PASSWORD = 12;
public const MAX_PASSWORD = 72;
/** Plain-language role descriptions shown on the Users screen. */
public const ROLE_INFO = [
'editor' => 'Edits content and saves drafts, uploads images, publishes and schedules pages, and restores revisions or deleted pages. Cannot change the site structure or manage users.',
'administrator' => 'Everything an editor can do, plus creating, duplicating, renaming and deleting pages, templates, navigation menus, shared content, site scan, deleting media, and managing users.',
];
public function __construct(private FileStore $store, private array $config)
{
}
/** Build a store from the site configuration. */
public static function fromConfig(array $config): self
{
$directory = (string) ($config['dataDirectory'] ?? (dirname(__DIR__, 2) . '/data'));
return new self(new FileStore($directory), $config);
}
/** Arguments for `new Authentication(...)`: config credentials plus active CMS accounts, roles, and session options. */
public static function authArgs(array $config): array
{
[$credentials, $roles] = self::fromConfig($config)->forAuth();
return [$credentials, $roles, $config['security'] ?? []];
}
/** Map an arbitrary configured role onto the two supported roles (the old publisher role is an editor). */
public static function normaliseRole(mixed $role): string
{
return $role === 'administrator' ? 'administrator' : 'editor';
}
/** Credentials and roles of every account allowed to sign in. */
public function forAuth(): array
{
$credentials = [];
$roles = [];
foreach ($this->data()['users'] as $username => $record) {
if (($record['status'] ?? '') === 'active' && is_string($record['hash'] ?? null) && $record['hash'] !== '') {
$credentials[$username] = $record['hash'];
$roles[$username] = self::normaliseRole($record['role'] ?? 'editor');
}
}
foreach ($this->configAccounts() as $username => $account) {
$credentials[$username] = $account['hash'];
$roles[$username] = $account['role'];
}
return [$credentials, $roles];
}
/** The name and email shown for an account, or blanks. */
public function profile(string $username): array
{
foreach ($this->accounts() as $account) {
if ($account['username'] === $username) {
return ['name' => $account['name'], 'email' => $account['email'], 'managed' => $account['managed']];
}
}
return ['name' => '', 'email' => '', 'managed' => false];
}
/** Every account without secrets: config accounts first (locked), then CMS accounts. */
public function accounts(): array
{
$data = $this->data();
$list = [];
foreach ($this->configAccounts() as $username => $account) {
$profile = $data['profiles'][$username] ?? [];
$list[] = [
'username' => $username, 'name' => (string) ($profile['name'] ?? ''), 'email' => (string) ($profile['email'] ?? ''),
'role' => $account['role'], 'status' => 'active', 'managed' => true, 'created' => '', 'inviteExpired' => false,
];
}
foreach ($data['users'] as $username => $record) {
if (isset($this->configAccounts()[$username])) {
continue;
}
$list[] = [
'username' => $username, 'name' => (string) ($record['name'] ?? ''), 'email' => (string) ($record['email'] ?? ''),
'role' => self::normaliseRole($record['role'] ?? 'editor'), 'status' => (string) ($record['status'] ?? 'active'),
'managed' => false, 'created' => (string) ($record['created'] ?? ''),
'inviteExpired' => ($record['status'] ?? '') === 'invited' && (int) ($record['invite']['expires'] ?? 0) < time(),
];
}
return $list;
}
public function isManaged(string $username): bool
{
return isset($this->configAccounts()[$username]);
}
/** Set display name and email. Config accounts keep their credentials in config; only this profile overlay is stored. */
public function setProfile(string $username, string $name, string $email): void
{
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
$managed = $this->isManaged($username);
$this->store->update('users', function (array $data) use ($username, $name, $email, $managed): array {
$data = $this->shape($data);
$this->assertEmailFree($data, $email, $username);
if ($managed) {
$data['profiles'][$username] = ['name' => $name, 'email' => $email];
} elseif (isset($data['users'][$username])) {
$data['users'][$username]['name'] = $name;
$data['users'][$username]['email'] = $email;
$data['users'][$username]['updated'] = date(DATE_ATOM);
} else {
throw new \RuntimeException('Account not found');
}
return $data;
});
}
/** Create an active CMS account with a password. */
public function create(string $username, string $name, string $email, string $role, string $password): void
{
$username = trim($username);
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
$this->assertRole($role);
$this->assertPassword($password, $username, $email);
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
$data = $this->shape($data);
$this->assertUsernameFree($data, $username);
$this->assertEmailFree($data, $email, $username);
$now = date(DATE_ATOM);
$data['users'][$username] = ['hash' => $hash, 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'active', 'created' => $now, 'updated' => $now];
return $data;
});
}
/** Edit a CMS account; a blank password leaves the current one. Config accounts are refused. */
public function update(string $username, string $name, string $email, string $role, string $password = ''): void
{
$this->assertNotManaged($username);
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
$this->assertRole($role);
$hash = null;
if ($password !== '') {
$this->assertPassword($password, $username, $email);
$hash = password_hash($password, PASSWORD_DEFAULT);
}
$this->store->update('users', function (array $data) use ($username, $name, $email, $role, $hash): array {
$data = $this->shape($data);
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
$this->assertEmailFree($data, $email, $username);
$record['name'] = $name;
$record['email'] = $email;
$record['role'] = $role;
if ($hash !== null) {
$record['hash'] = $hash;
}
$record['updated'] = date(DATE_ATOM);
$data['users'][$username] = $record;
return $data;
});
}
/** Change a CMS account's own password. Returns the new hash so the caller can refresh the session. */
public function setPassword(string $username, string $password): string
{
$this->assertNotManaged($username, 'This password is managed in config.local.php');
$profile = $this->profile($username);
$this->assertPassword($password, $username, $profile['email']);
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($username, $hash): array {
$data = $this->shape($data);
if (!isset($data['users'][$username])) {
throw new \RuntimeException('Account not found');
}
$data['users'][$username]['hash'] = $hash;
$data['users'][$username]['updated'] = date(DATE_ATOM);
return $data;
});
return $hash;
}
/** Upgrade a stored hash after a successful login when PHP's default algorithm or cost has changed. */
public function rehashIfNeeded(string $username, string $password): void
{
$record = $this->data()['users'][$username] ?? null;
if ($record && !$this->isManaged($username) && is_string($record['hash'] ?? null) && $record['hash'] !== '' && password_needs_rehash($record['hash'], PASSWORD_DEFAULT)) {
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($username, $hash): array {
$data = $this->shape($data);
if (isset($data['users'][$username])) {
$data['users'][$username]['hash'] = $hash;
}
return $data;
});
}
}
public function setBlocked(string $username, bool $blocked): void
{
$this->assertNotManaged($username);
$this->store->update('users', function (array $data) use ($username, $blocked): array {
$data = $this->shape($data);
$record = $data['users'][$username] ?? throw new \RuntimeException('Account not found');
if ($blocked) {
$record['status'] = 'blocked';
} elseif (($record['status'] ?? '') === 'blocked') {
$record['status'] = ($record['hash'] ?? '') === '' ? 'invited' : 'active';
}
$record['updated'] = date(DATE_ATOM);
$data['users'][$username] = $record;
return $data;
});
}
public function delete(string $username): void
{
$this->assertNotManaged($username);
$this->store->update('users', function (array $data) use ($username): array {
$data = $this->shape($data);
if (!isset($data['users'][$username])) {
throw new \RuntimeException('Account not found');
}
unset($data['users'][$username]);
return $data;
});
}
/** Create (or re-issue) a pending invitation. Returns the username and the one-time token, which is stored only as a hash. */
public function invite(string $name, string $email, string $role, string $existing = ''): array
{
$name = $this->cleanName($name);
$email = $this->cleanEmail($email);
if ($email === '') {
throw new \RuntimeException('An email address is required to invite someone');
}
$this->assertRole($role);
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$username = '';
$this->store->update('users', function (array $data) use ($name, $email, $role, $existing, $tokenHash, &$username): array {
$data = $this->shape($data);
$now = date(DATE_ATOM);
$invite = ['token' => $tokenHash, 'expires' => time() + self::INVITE_LIFETIME];
if ($existing !== '') {
$record = $data['users'][$existing] ?? null;
if (!$record || ($record['status'] ?? '') !== 'invited') {
throw new \RuntimeException('Only a pending invitation can be re-issued');
}
$this->assertEmailFree($data, $email, $existing);
$username = $existing;
$data['users'][$username] = array_merge($record, ['name' => $name, 'email' => $email, 'role' => $role, 'invite' => $invite, 'updated' => $now]);
return $data;
}
$this->assertEmailFree($data, $email, '');
$username = $this->generateUsername($data, $email);
$data['users'][$username] = ['hash' => '', 'role' => $role, 'name' => $name, 'email' => $email, 'status' => 'invited', 'invite' => $invite, 'created' => $now, 'updated' => $now];
return $data;
});
return [$username, $token];
}
/** Redeem an invitation: the token is single use and expires. Returns the username. Every failure gives the same message. */
public function acceptInvite(string $token, string $password): string
{
$failure = 'This invitation is invalid or has expired';
$wanted = hash('sha256', $token);
$accepted = null;
$hash = null;
$data = $this->data();
foreach ($data['users'] as $username => $record) {
if (($record['status'] ?? '') === 'invited' && is_string($record['invite']['token'] ?? null)
&& hash_equals($record['invite']['token'], $wanted) && (int) ($record['invite']['expires'] ?? 0) >= time()) {
$accepted = $username;
}
}
if ($accepted === null || $token === '') {
throw new \RuntimeException($failure);
}
$this->assertPassword($password, $accepted, (string) ($data['users'][$accepted]['email'] ?? ''));
$hash = password_hash($password, PASSWORD_DEFAULT);
$this->store->update('users', function (array $data) use ($accepted, $wanted, $hash, $failure): array {
$data = $this->shape($data);
$record = $data['users'][$accepted] ?? null;
if (!$record || ($record['status'] ?? '') !== 'invited' || !hash_equals((string) ($record['invite']['token'] ?? ''), $wanted)) {
throw new \RuntimeException($failure);
}
unset($record['invite']);
$record['hash'] = $hash;
$record['status'] = 'active';
$record['updated'] = date(DATE_ATOM);
$data['users'][$accepted] = $record;
return $data;
});
return $accepted;
}
/** Password policy shared by every path that sets a password. */
public function assertPassword(string $password, string $username = '', string $email = ''): void
{
$length = strlen($password);
if ($length < self::MIN_PASSWORD) {
throw new \RuntimeException('Passwords must be at least ' . self::MIN_PASSWORD . ' characters');
}
if ($length > self::MAX_PASSWORD) {
throw new \RuntimeException('Passwords can be at most ' . self::MAX_PASSWORD . ' bytes long');
}
$lower = strtolower($password);
if (($username !== '' && $lower === strtolower($username)) || ($email !== '' && $lower === strtolower($email))) {
throw new \RuntimeException('The password must differ from the login name and email address');
}
}
private function configAccounts(): array
{
$accounts = [];
foreach ((array) ($this->config['authentication'] ?? []) as $username => $hash) {
if (is_string($username) && is_string($hash) && $hash !== '') {
$accounts[$username] = ['hash' => $hash, 'role' => self::normaliseRole(($this->config['roles'] ?? [])[$username] ?? 'editor')];
}
}
return $accounts;
}
private function data(): array
{
return $this->shape($this->store->read('users'));
}
private function shape(array $data): array
{
$data['users'] = is_array($data['users'] ?? null) ? $data['users'] : [];
$data['profiles'] = is_array($data['profiles'] ?? null) ? $data['profiles'] : [];
return $data;
}
private function cleanName(string $name): string
{
$name = trim(preg_replace('/\s+/u', ' ', $name) ?? '');
if ($name === '' || mb_strlen($name) > 80 || preg_match('/[\x00-\x1f\x7f<>]/u', $name)) {
throw new \RuntimeException('Enter a display name of up to 80 characters');
}
return $name;
}
private function cleanEmail(string $email): string
{
$email = strtolower(trim($email));
if ($email !== '' && (strlen($email) > 254 || !filter_var($email, FILTER_VALIDATE_EMAIL))) {
throw new \RuntimeException('Enter a valid email address');
}
return $email;
}
private function assertRole(string $role): void
{
if (!in_array($role, self::ROLES, true)) {
throw new \RuntimeException('Unknown role');
}
}
private function assertNotManaged(string $username, string $message = 'This account is managed in config.local.php and cannot be changed here'): void
{
if ($this->isManaged($username)) {
throw new \RuntimeException($message);
}
}
private function assertUsernameFree(array $data, string $username): void
{
if (!preg_match('/^[a-zA-Z0-9._-]{3,32}$/', $username)) {
throw new \RuntimeException('Login names are 3-32 letters, numbers, dots, dashes or underscores');
}
foreach ([$data['users'], $this->configAccounts()] as $existing) {
foreach (array_keys($existing) as $known) {
if (strcasecmp((string) $known, $username) === 0) {
throw new \RuntimeException('That login name is already in use');
}
}
}
}
/** Emails are unique across config overlays and CMS accounts, ignoring case. */
private function assertEmailFree(array $data, string $email, string $exceptUsername): void
{
if ($email === '') {
return;
}
$taken = [];
foreach ($data['profiles'] as $username => $profile) {
$taken[$username] = strtolower((string) ($profile['email'] ?? ''));
}
foreach ($data['users'] as $username => $record) {
$taken[$username] = strtolower((string) ($record['email'] ?? ''));
}
foreach ($taken as $username => $known) {
if ($known === $email && (string) $username !== $exceptUsername) {
throw new \RuntimeException('That email address is already in use');
}
}
}
private function generateUsername(array $data, string $email): string
{
$base = strtolower(preg_replace('/[^a-zA-Z0-9._-]/', '', explode('@', $email)[0]) ?? '');
$base = substr(str_pad($base, 3, 'x'), 0, 28);
for ($i = 0; $i < 1000; $i++) {
$candidate = $i === 0 ? $base : $base . $i;
try {
$this->assertUsernameFree($data, $candidate);
return $candidate;
} catch (\RuntimeException) {
}
}
throw new \RuntimeException('Unable to generate a login name');
}
}