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
+3
View File
@@ -11,6 +11,9 @@ return [
'roles' => [
'admin' => 'administrator',
],
// Live sites: store CMS data (accounts, drafts, revisions) OUTSIDE the web root, as an absolute path PHP can write to,
// for example '/var/neocms-data'. The default cms/data/ is protected only by .htaccess.
// 'dataDirectory' => '/var/neocms-data',
// Set this to true when HTTPS is terminated by a trusted reverse proxy.
'security' => [
'cookieSecure' => true,
+2 -1
View File
@@ -10,7 +10,8 @@ $config = [
'authentication' => [
// 'admin' => '$2y$10$replace-with-a-real-password-hash',
],
// Grant each user the least powerful role they need: editor, publisher, or administrator.
// Accounts here are the main administrators and are managed only in this file. Other accounts are created in the CMS (Tools > Users).
// Roles: editor (edit, publish, schedule) or administrator (everything, including users). The old 'publisher' role counts as editor.
'roles' => [
// 'admin' => 'administrator',
],
+91 -46
View File
@@ -27,14 +27,15 @@ body {
border-bottom: 1px solid #1a1a1a;
-webkit-box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
box-shadow: 0 10px 13px -7px #000000, -1px -1px 15px 5px rgba(0, 0, 0, 0);
display: flex;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas: "logo url user" "logo url buttons";
align-items: center;
gap: 16px;
gap: 6px 16px;
padding: 10px 16px;
box-sizing: border-box;
width: 100%;
min-width: 0;
flex-wrap: wrap;
}
/* Same-origin preview iframe; JavaScript narrows its width for responsive previews. */
@@ -50,33 +51,29 @@ body {
/* Product mark remains a fixed size while neighbouring controls wrap around it. */
.controls .logo {
background-image: url("/cms/img/logo.png");
background-image: url("../img/logo.png");
width: 60px;
height: 60px;
display: block;
flex: 0 0 60px;
grid-area: logo;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
/* The two toolbar rows stay together and yield horizontal space when necessary. */
/* Top right: user and Log out; the menu buttons sit directly beneath them. */
.controls .buttonContainer {
margin-left: auto;
flex: 0 1 auto;
min-width: 0;
grid-area: buttons;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 7px 12px;
}
.toolbar-row {
display: flex;
gap: 7px;
justify-content: flex-end;
margin: 3px 0;
flex-wrap: wrap;
}
.publish-controls {
opacity: 0.94;
margin: 0;
}
@@ -115,9 +112,12 @@ body {
/* Current page path and unpublished-state label. */
.controls #urlbox {
flex: 1 1 320px;
grid-area: url;
min-width: 0;
text-align: center;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.1rem;
line-height: 1.3;
color: #dee2e6;
@@ -135,14 +135,18 @@ body {
/* Compact account, role, logout, and operational-warning area. */
.controls .loggedInDetails {
flex: 0 1 auto;
grid-area: user;
justify-self: end;
color: #dee2e6;
text-align: right;
font-size: 0.9rem;
line-height: 1.4;
max-width: 260px;
}
/* Name and Log out share one line. */
.controls .loggedInDetails br:first-of-type { display: none; }
.controls .loggedInDetails .link-button { margin-left: 10px; }
.link-button {
border: 0;
padding: 0;
@@ -219,6 +223,9 @@ body {
/* Consistent form controls across SEO, scheduling, shared-content, and menu dialogues. */
.cms-dialog input[type="text"],
.cms-dialog input[type="email"],
.cms-dialog input[type="password"],
.cms-dialog select,
.cms-dialog input[type="url"],
.cms-dialog input[type="datetime-local"],
.cms-dialog textarea {
@@ -347,44 +354,34 @@ body {
color: #ff4d4d;
}
/* At medium widths the page path receives its own row. */
/* At medium widths the buttons take their own row under the user block; the path stays beside the logo. */
@media (max-width: 1100px) {
.pageContainer .controls {
gap: 12px;
}
.controls #urlbox {
order: 3;
flex-basis: 100%;
text-align: left;
}
.controls .buttonContainer {
margin-left: 0;
grid-template-areas: "logo url user" "buttons buttons buttons";
}
}
/* On small screens controls stack into touch-friendly full-width rows. */
/* On small screens controls stack into touch-friendly full-width rows; the user block stays top right. */
@media (max-width: 760px) {
.pageContainer .controls {
align-items: flex-start;
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas: "logo user" "url url" "buttons buttons";
padding: 12px;
}
.controls .logo {
width: 48px;
height: 48px;
flex-basis: 48px;
}
.controls .buttonContainer,
.controls .loggedInDetails,
.toolbar-row {
flex: 1 1 100%;
flex-wrap: wrap;
}
.controls #urlbox {
flex-basis: 100%;
max-width: none;
text-align: left;
}
.controls .buttonContainer ul {
gap: 8px;
}
@@ -501,10 +498,58 @@ body {
.newpage-content { padding: 0; background: none; border: 0; width: auto; }
#newPageForm > label[for="filename"] { margin: 4px 0 6px; font-weight: 600; }
#newPageForm input[type="text"] { width: 100%; box-sizing: border-box; padding: 9px; margin-bottom: 16px; border: 1px solid #c9cfd8; border-radius: 6px; }
#newPageForm button[type="submit"] { align-self: flex-end; padding: 9px 22px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
#newPageForm button[type="submit"]:hover { background: #2f5a8a; }
/* Primary submit buttons inside dialogue forms. */
.cms-dialog form button[type="submit"] { padding: 9px 18px; border: 0; border-radius: 6px; background: #3b6ea5; color: #fff; font-weight: 600; cursor: pointer; }
.cms-dialog form button[type="submit"]:hover { background: #2f5a8a; }
/* Dialogue buttons use the small row-button metrics; primary actions are filled blue. */
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)) {
padding: 4px 9px; font-family: inherit; font-size: 12px; font-weight: 400; color: #374151;
background: #fff; border: 1px solid #c9cfd8; border-radius: 4px; cursor: pointer;
}
.ui-dialog .ui-dialog-content :where(button:not([class*="tox"]):not([class*="ui-"]):not(.page-open)):hover { background: #f3f5f8; }
.ui-dialog .ui-dialog-content button.danger-text { color: #a51d2d; }
.ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content #siteScanApply {
border-color: #3b6ea5; background: #3b6ea5; color: #fff; font-weight: 600;
}
.ui-dialog .ui-dialog-content button[type="submit"]:hover, .ui-dialog .ui-dialog-content .step-next:hover, .ui-dialog .ui-dialog-content #siteScanApply:hover { background: #2f5a8a; }
#fileList .row-actions button { padding: 4px 9px; font-size: 12px; }
/* Action buttons (editor Save/Schedule/Publish, dialogue Save/Next/Apply) are a step larger than row buttons. */
#editModal .editor-actions button, .ui-dialog .ui-dialog-content button[type="submit"], .ui-dialog .ui-dialog-content .step-next, .ui-dialog .ui-dialog-content .step-back, .ui-dialog .ui-dialog-content #siteScanApply { padding: 6px 14px; font-size: 13px; }
#sharedList, #menuList { margin-bottom: 16px; }
/* Device-preview buttons are icon-only; the active one is highlighted. */
.viewport-button {
min-width: 42px;
padding: 8px 10px;
}
.viewport-button.active {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -3px;
}
/* New-page wizard. */
.step-indicator { margin: 0 0 4px; font-size: 12px; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; }
.step-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
#menuChoice { display: grid; gap: 8px; }
#newPageForm section[hidden] { display: none; }
/* Wizard lists reuse the page-table look. */
#radioList, #menuChoice { display: block; margin-bottom: 14px; }
#radioList .table-wrap, #menuChoice .table-wrap { max-height: 46vh; overflow: auto; }
/* Users dialogue. */
#usersDialog form { margin-bottom: 8px; }
#usersDialog #inviteLink { width: calc(100% - 100px); box-sizing: border-box; padding: 6px; }
/* Important administrator recommendations (for example the data directory notice). */
.important-note, .cms-dialog .scan-note.important-note { color: #b3141f; font-weight: 600; }
/* Media library: upload, category tabs, and file cards. */
.media-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 10px; }
.media-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.media-tab[aria-selected="true"] { background: #3b6ea5 !important; border-color: #3b6ea5 !important; color: #fff !important; }
.media-thumb { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; height: 130px; background: #eef2f5; color: #4a7bb7; font-weight: 600; font-size: 12px; }
.media-thumb svg { width: 40px; height: 40px; }
.media-actions { display: flex; flex-wrap: wrap; gap: 6px; }
#mediaUploadReport:empty { display: none; }
/* New page wizard sits under the page list in the Pages dialogue. */
#newPageSection { margin-top: 22px; padding-top: 6px; border-top: 1px solid #d4dde4; }
+27 -40
View File
@@ -1,9 +1,9 @@
<?php
/**
* Authenticated TinyMCE image-upload endpoint.
* Authenticated media-upload endpoint (images, documents, video, audio), also used by TinyMCE for pasted images.
*
* Files are checked by byte size, detected MIME type, and image decoding before receiving a random
* server filename. Trusting only the browser-supplied extension would be admirably optimistic.
* Every file is checked by MediaTypes::inspect (extension allow-list, content sniffing, code scanning) before it receives a
* server-generated filename. Trusting only the browser-supplied extension would be admirably optimistic.
*/
// Load credentials, role assignments, and shared application settings.
@@ -13,12 +13,13 @@ use NeoCMS\Activity;
use NeoCMS\Authentication;
use NeoCMS\FileStore;
use NeoCMS\Logger;
use NeoCMS\MediaTypes;
use NeoCMS\SecurityHeaders;
SecurityHeaders::json(isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
// Authentication is required before any upload details are processed.
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
if (!$authentication->isLoggedIn()) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorised']);
@@ -48,14 +49,6 @@ if (!$authentication->isValidCsrfToken($csrfToken)) {
exit;
}
// Map explicitly supported image MIME types to controlled filename extensions.
$allowed_types = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
// Apply configured byte, dimension, pixel-count, file-count, and total-storage limits.
$uploadConfig = is_array($config['uploads'] ?? null) ? $config['uploads'] : [];
$max_file_size = max(1024, (int) ($uploadConfig['maxFileBytes'] ?? 10 * 1024 * 1024));
@@ -66,7 +59,7 @@ $maxFiles = max(1, (int) ($uploadConfig['maxFiles'] ?? 2000));
$maxTotalBytes = max($max_file_size, (int) ($uploadConfig['maxTotalBytes'] ?? 500 * 1024 * 1024));
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $max_file_size + 1024 * 1024) {
http_response_code(413);
echo json_encode(['error' => 'Upload request is too large.']);
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
exit;
}
@@ -77,38 +70,32 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
// Enforce the application limit even if php.ini permits larger files.
if ($file['size'] > $max_file_size) {
http_response_code(400);
echo json_encode(['error' => 'File size exceeds the maximum limit.']);
echo json_encode(['error' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
exit;
}
// Inspect file bytes rather than trusting the user-controlled original filename.
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
// A pasted image may arrive without an extension; name it from its detected type so it can be checked like any other file.
$originalName = (string) $file['name'];
if (pathinfo($originalName, PATHINFO_EXTENSION) === '') {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detected = (string) finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$originalName .= '.' . (['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'][$detected] ?? '');
}
if (!array_key_exists($mime_type, $allowed_types)) {
// Extension allow-list, content sniffing, code scanning and per-format checks (see MediaTypes::inspect).
try {
$inspection = MediaTypes::inspect($file['tmp_name'], $originalName, [
'maxBytes' => $max_file_size, 'maxWidth' => $maxWidth, 'maxHeight' => $maxHeight, 'maxPixels' => $maxPixels,
]);
} catch (\RuntimeException $exception) {
http_response_code(400);
echo json_encode(['error' => 'Invalid file type.']);
echo json_encode(['error' => $exception->getMessage()]);
exit;
}
// Confirm that an image decoder recognises the payload as an actual image.
$imageInfo = getimagesize($file['tmp_name']);
if ($imageInfo === false) {
http_response_code(400);
echo json_encode(['error' => 'Uploaded file is not a valid image.']);
exit;
}
[$width, $height] = $imageInfo;
if ($width > $maxWidth || $height > $maxHeight || $width * $height > $maxPixels) {
http_response_code(400);
echo json_encode(['error' => 'Image dimensions exceed the configured limit.']);
exit;
}
// Random names prevent collisions and avoid publishing the original local filename.
$extension = $allowed_types[$mime_type];
$filename = bin2hex(random_bytes(16)) . '.' . $extension;
// Generated names keep the original filename out of the URL namespace and allow only one extension.
$filename = MediaTypes::storedName($originalName, $inspection['ext']);
// Create the public upload directory on a fresh installation.
$uploadRoot = __DIR__ . '/../uploads';
@@ -164,7 +151,7 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$metadata = $store->read('media');
$originalBase = pathinfo((string) $file['name'], PATHINFO_FILENAME);
$altText = substr(preg_replace('/[\x00-\x1F\x7F]/u', '', $originalBase) ?? '', 0, 200);
$metadata[$filename] = ['alt' => $altText];
$metadata[$filename] = ['alt' => $altText, 'original' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', basename($originalName)) ?? '', 0, 200), 'by' => $authentication->getLoggedInUser()];
try {
$store->write('media', $metadata);
} catch (\Throwable $exception) {
@@ -180,11 +167,11 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
// The file is saved by now, so a failure to write the audit entry must not fail the upload.
try {
(new Activity($store, new Logger($config['audit'] ?? true, $config['security'] ?? [])))
->record($authentication->getLoggedInUser(), 'Uploaded image', $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
->record($authentication->getLoggedInUser(), 'Uploaded ' . (['imagery' => 'image', 'documents' => 'document', 'video' => 'video', 'audio' => 'audio'][$inspection['category']]), $filename . ' (' . max(1, (int) round(((int) $file['size']) / 1024)) . ' KB)');
} catch (\Throwable $exception) {
// Deliberately ignored.
}
echo json_encode(['location' => $location]);
echo json_encode(['location' => $location, 'name' => $filename, 'category' => $inspection['category']]);
} else {
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
+101 -27
View File
@@ -8,7 +8,7 @@ use NeoCMS\Authentication;
use NeoCMS\SecurityHeaders;
// Prepare the session values required by both the rendered shell and JavaScript client.
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
$csrfToken = $authentication->getCsrfToken();
// Only a single valid CSS class is allowed; malformed values fall back safely.
$editableClass = $config['editableClass'] ?? 'editable';
@@ -54,20 +54,20 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<div class="buttonContainer">
<div class="toolbar-row">
<button class="headerButton" id="dashboardButton">Dashboard</button>
<button class="headerButton manage-only" id="newPage">New Page</button>
<button class="headerButton" id="selectPage">Pages</button>
<button class="headerButton" id="mediaButton">Media</button>
<button class="headerButton" id="seoButton">SEO</button>
<button class="headerButton manage-only" id="menusButton">Navigation Menus</button>
<button class="headerButton" id="moreButton">Tools</button>
</div>
<div class="toolbar-row publish-controls">
<button class="headerButton viewport-button" data-width="100%">Desktop</button>
<button class="headerButton viewport-button" data-width="768px">Tablet</button>
<button class="headerButton viewport-button" data-width="390px">Mobile</button>
<button class="headerButton viewport-button active" data-width="100%" title="Desktop view" aria-label="Desktop view" aria-pressed="true"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="4" width="19" height="12.5" rx="1.5"/><path d="M8 20.5h8M12 16.5v4"/></svg></button>
<button class="headerButton viewport-button" data-width="768px" title="Tablet view" aria-label="Tablet view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="2.5" width="14" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
<button class="headerButton viewport-button" data-width="390px" title="Mobile view" aria-label="Mobile view" aria-pressed="false"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18.5h2"/></svg></button>
</div>
</div>
<div class="loggedInDetails">
<?php echo htmlspecialchars($authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?>
<span id="whoami"><?php $profile = \NeoCMS\UserStore::fromConfig($config)->profile($authentication->getLoggedInUser()); echo htmlspecialchars($profile['name'] !== '' ? $profile['name'] : $authentication->getLoggedInUser(), ENT_QUOTES, 'UTF-8'); ?></span>
(<?php echo htmlspecialchars($authentication->getRole(), ENT_QUOTES, 'UTF-8'); ?>)<br/>
<button id="logoutButton" class="link-button">Log out</button><br/>
<?php
@@ -91,24 +91,8 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
</div>
</div>
<!-- Template and target path controls for administrator page creation. -->
<div id="newPageDialog" title="Create a New Page">
<div class="newpage-content">
<form id="newPageForm">
<h3 class="dialog-section">Choose a template</h3>
<div id="radioList">
<!-- JavaScript populates available filesystem templates here. -->
</div>
<label for="filename">New Filename:</label>
<input type="text" id="filename" name="filename" placeholder="Enter new filename" required>
<button type="submit">Submit</button>
</form>
</div>
</div>
<!-- Searchable page picker with role-dependent management actions. -->
<div id="fileListDialog" title="Select an Existing Page">
<div id="fileListDialog" title="Pages">
<div class="filelist-content">
<input id="pageSearch" type="search" placeholder="Search pages" aria-label="Search pages">
<div class="table-wrap">
@@ -123,6 +107,37 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<p id="pageListEmpty" class="scan-note" hidden>No pages match your search.</p>
</div>
</div>
<!-- New page wizard: template, name, navigation group. Administrators only. -->
<div id="newPageSection" class="manage-only">
<h3 class="dialog-section">New page</h3>
<div class="newpage-content">
<form id="newPageForm">
<p id="newPageStep" class="step-indicator"></p>
<section data-step="1">
<h3 class="dialog-section">Choose a template</h3>
<div id="radioList">
<!-- JavaScript populates available filesystem templates here. -->
</div>
<div class="step-actions"><button type="button" class="step-next">Next</button></div>
</section>
<section data-step="2" hidden>
<h3 class="dialog-section">Name your page</h3>
<label for="pageName">Page name</label>
<input type="text" id="pageName" maxlength="80" placeholder="For example: About us" autocomplete="off">
<p class="scan-note">Used as the page title and the navigation link text. The filename is created for you.</p>
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="button" class="step-next">Next</button></div>
</section>
<section data-step="3" hidden>
<h3 class="dialog-section">Choose a navigation group</h3>
<div id="menuChoice">
<!-- JavaScript lists the saved navigation menus here. -->
</div>
<p class="scan-note">The page is saved as a private draft. It appears on the site, and in this menu, only once you publish it.</p>
<div class="step-actions"><button type="button" class="step-back">Back</button><button type="submit">Save</button></div>
</section>
</form>
</div>
</div>
</div>
<!-- Operational overview: pending work, schedules, activity, and filesystem warnings. -->
@@ -134,14 +149,20 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<button id="revisionsButton">Revision history</button>
<button id="accessibilityButton">Accessibility check</button>
<button id="sharedButton" class="manage-only">Shared content</button>
<button id="menusButton" class="manage-only">Navigation menus</button>
<button id="siteScanButton" class="manage-only">Site scan</button>
<button id="usersButton">Users</button>
</div>
</div>
<!-- Reusable uploaded-image browser and metadata editor. -->
<div id="mediaDialog" class="cms-dialog" title="Media Library">
<p>Select an image to insert it into the open editable region.</p>
<div class="media-toolbar">
<button type="button" id="mediaUploadButton">Upload files</button>
<input type="file" id="mediaFile" multiple hidden accept="<?php echo htmlspecialchars('.' . implode(',.', \NeoCMS\MediaTypes::extensions()), ENT_QUOTES, 'UTF-8'); ?>">
<div id="mediaTabs" class="media-tabs" role="tablist" aria-label="Media categories"></div>
</div>
<p id="mediaHint" class="scan-note"></p>
<div id="mediaUploadReport" class="scan-note" role="status" aria-live="polite"></div>
<div id="mediaList" class="media-grid"></div>
</div>
@@ -184,13 +205,65 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<!-- Line-oriented menu editor supporting an optional parent label for nesting. -->
<div id="menusDialog" class="cms-dialog" title="Navigation Menus">
<div id="menuList"></div>
<form id="menuForm">
<label>Menu name<input id="menuName" type="text" pattern="[A-Za-z0-9_-]+" required></label>
<p class="scan-note"><button id="menuScanButton" type="button">Scan site for new navigation</button> Finds navigation added to any page since the last scan.</p>
<form id="menuForm" hidden>
<h3 class="dialog-section">Editing menu: <code id="menuKey"></code></h3>
<input id="menuName" type="hidden">
<label>Menu name<input id="menuTitle" type="text" maxlength="60" placeholder="Display name"></label>
<label>One item per line: Label | URL | Optional parent label<textarea id="menuItems" rows="9" placeholder="Home | /&#10;About | /about.html&#10;Team | /team.html | About" required></textarea></label>
<button type="submit">Save menu</button>
</form>
</div>
<!-- Own account (everyone), role descriptions, and account management (administrators only). -->
<div id="usersDialog" class="cms-dialog" title="Users">
<h3 class="dialog-section">My account</h3>
<form id="profileForm" autocomplete="off">
<label>Login name <span class="scan-note">(used to sign in; set by an administrator)</span><input id="profileLogin" type="text" readonly></label>
<label>Display name<input id="profileName" type="text" maxlength="80" required autocomplete="name"></label>
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="profileEmail" type="email" maxlength="254" autocomplete="email"></label>
<label>Current password <span class="scan-note">(only needed to change the email address)</span><input id="profileCurrent" type="password" maxlength="4096" autocomplete="current-password"></label>
<button type="submit">Save details</button>
</form>
<form id="passwordForm" autocomplete="off">
<h3 class="dialog-section">Change password</h3>
<label>Current password<input id="pwCurrent" type="password" maxlength="4096" required autocomplete="current-password"></label>
<label>New password <span class="scan-note">(12 to 72 characters)</span><input id="pwNew" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
<label>Confirm new password<input id="pwConfirm" type="password" minlength="12" maxlength="72" required autocomplete="new-password"></label>
<button type="submit">Change password</button>
</form>
<p id="passwordManaged" class="scan-note" hidden>Your password is managed in the site configuration (config.local.php) and cannot be changed here.</p>
<h3 class="dialog-section">Roles</h3>
<div id="rolesList"></div>
<div id="userAdmin" class="manage-only">
<h3 class="dialog-section">User accounts</h3>
<div id="userList"></div>
<p class="scan-note"><button id="userAddButton" type="button">Add user</button> <button id="userInviteButton" type="button">Invite user</button> Accounts marked Config are managed in config.local.php and cannot be changed here.</p>
<form id="userForm" autocomplete="off" hidden>
<h3 class="dialog-section" id="userFormTitle"></h3>
<input id="userExisting" type="hidden">
<label id="userUsernameRow">Login name <span class="scan-note">(3 to 32 letters, numbers, . - _)</span><input id="userUsername" type="text" maxlength="32" autocomplete="off"></label>
<label>Display name<input id="userName" type="text" maxlength="80" required autocomplete="off"></label>
<label>Email address <span class="scan-note">(contact details only; not used to sign in)</span><input id="userEmail" type="email" maxlength="254" autocomplete="off"></label>
<label>Role<select id="userRole"><option value="editor">Editor</option><option value="administrator">Administrator</option></select></label>
<label id="userPasswordRow">Password <span class="scan-note" id="userPasswordNote"></span><input id="userPassword" type="password" maxlength="72" autocomplete="new-password"></label>
<label>Your password <span class="scan-note">(to confirm this change)</span><input id="userConfirm" type="password" maxlength="4096" required autocomplete="current-password"></label>
<button type="submit" id="userSubmit">Save user</button> <button type="button" id="userCancel">Cancel</button>
</form>
<form id="confirmBox" autocomplete="off" hidden>
<h3 class="dialog-section" id="confirmTitle"></h3>
<label>Your password<input id="confirmPassword" type="password" maxlength="4096" required autocomplete="current-password"></label>
<button type="submit">Confirm</button> <button type="button" id="confirmCancel">Cancel</button>
</form>
<div id="inviteResult" hidden>
<h3 class="dialog-section">Invitation link</h3>
<p class="scan-note">Send this link to the person. It is shown only once, works once, and expires in 7 days. Their login name is <strong id="inviteLogin"></strong>.</p>
<input id="inviteLink" type="text" readonly> <button type="button" id="inviteCopy">Copy link</button>
</div>
</div>
</div>
<!-- Whole-site analysis with one-click tagging of content regions, images, and SEO tags. Administrators only. -->
<div id="siteScanDialog" class="cms-dialog" title="Site Scan">
<div id="siteScanProgress" hidden>
@@ -203,6 +276,7 @@ SecurityHeaders::html(true, isset($config['security']['cookieSecure']) ? (bool)
<label><input type="checkbox" id="scanContent" checked> Make content areas editable</label>
<label><input type="checkbox" id="scanImages" checked> Make images editable</label>
<label><input type="checkbox" id="scanSeo" checked> Add missing SEO tags (never overwrites existing ones)</label>
<label><input type="checkbox" id="scanMenus" checked> Detect navigation menus (fills Navigation Menus; never overwrites a saved menu)</label>
</fieldset>
<div id="siteScanPages"></div>
<p class="scan-note">Each changed page keeps a revision, so this can be undone from Revision history.</p>
+475 -52
View File
@@ -76,14 +76,18 @@
});
}
/** Latest response from the users action. */
let usersData = null;
/** Configure all jQuery UI dialogues once, leaving individual workflows to populate them. */
function initialiseDialogs() {
$('.cms-dialog, #newPageDialog, #fileListDialog').hide();
$('.cms-dialog, #newPageDialog, #fileListDialog').not('#editModal').each(function () {
$('.cms-dialog, #fileListDialog').hide();
$('.cms-dialog, #fileListDialog').not('#editModal').each(function () {
$(this).dialog({autoOpen: false, modal: true, width: Math.min(760, window.innerWidth - 30)});
});
// The page list is a wide table, so it gets a wider dialogue than the other tools.
$('#fileListDialog').dialog('option', 'width', Math.min(1000, window.innerWidth - 30));
$('#fileListDialog').dialog('option', 'maxHeight', Math.round(window.innerHeight * 0.92));
$('#editModal').dialog({
autoOpen: false,
modal: true,
@@ -96,9 +100,10 @@
/** Connect toolbar controls and forms to their workflow handlers. */
function bindToolbar() {
$('#dashboardButton').on('click', function () { loadDashboard(true); });
$('#newPage').on('click', openNewPage);
$('#selectPage').on('click', openPages);
$('#mediaButton').on('click', openMedia);
$('#mediaButton').on('click', function () { openMedia(); });
$('#mediaUploadButton').on('click', function () { $('#mediaFile').trigger('click'); });
$('#mediaFile').on('change', function () { const files = Array.from(this.files); this.value = ''; if (files.length) uploadMedia(files); });
$('#seoButton').on('click', openSeo);
$('#moreButton').on('click', function () { $('#toolsDialog').dialog('open'); });
$('#revisionsButton').on('click', openRevisions);
@@ -106,18 +111,25 @@
$('#sharedButton').on('click', openShared);
$('#menusButton').on('click', openMenus);
$('#siteScanButton').on('click', openSiteScan);
$('#usersButton').on('click', openUsers);
bindUsers();
$('#siteScanApply').on('click', applySiteScan);
$('#imageForm').on('submit', applyImage);
$('#seoForm').on('submit', applySeo);
$('#scheduleForm').on('submit', schedulePage);
$('#sharedForm').on('submit', saveShared);
$('#menuForm').on('submit', saveMenu);
$('#menuScanButton').on('click', scanForNavigation);
$('#newPageForm').on('submit', createPage);
$('#newPageForm').on('click', '.step-next', function () { stepNewPage(1); });
$('#newPageForm').on('click', '.step-back', function () { stepNewPage(-1); });
$('#pageSearch').on('input', filterPages);
$('#logoutButton').on('click', logout);
$('#closeEditorBtn').on('click', function () { $('#editModal').dialog('close'); });
$('.viewport-button').on('click', function () {
$('#frameContainer').css({width: $(this).data('width'), margin: '0 auto'});
$('.viewport-button').removeClass('active').attr('aria-pressed', 'false');
$(this).addClass('active').attr('aria-pressed', 'true');
});
}
@@ -139,7 +151,8 @@
return;
}
revalidatedHref = null;
if (!draftWrite) {
// about:blank is only ever our own draft surface, so it must not reset the page path or draft state.
if (!draftWrite && href !== 'about:blank') {
draftSaved = false;
framePath = iframe.contentWindow.location.pathname;
}
@@ -202,7 +215,14 @@
tinymce.init({
selector: '#editor', height: 360, branding: false, promotion: false, license_key: 'gpl',
plugins: 'preview searchreplace autolink autosave directionality code visualblocks visualchars fullscreen image link media codesample table charmap pagebreak nonbreaking anchor insertdatetime advlist lists wordcount quickbars emoticons help',
toolbar: 'undo redo | blocks | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image media table | code preview fullscreen',
toolbar: 'undo redo | blocks | bold italic underline | alignleft aligncenter alignright | bullist numlist | link image media neomedia table | code preview fullscreen',
setup: function (editor) {
editor.ui.registry.addButton('neomedia', {
text: 'Media library', icon: 'gallery', tooltip: 'Insert an uploaded image, document, video or audio file',
onAction: function () { openMedia(function (item) { editor.insertContent(mediaHtml(item)); }); }
});
},
toolbar_mode: 'wrap',
images_upload_handler: uploadImage,
automatic_uploads: true,
convert_urls: false
@@ -423,20 +443,23 @@
$('#pageSearch').val('');
$('#pageListEmpty').prop('hidden', true);
pages.forEach(function (page) {
const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { navigateTo(page.url); });
const row = $('<tr class="page-row">').attr('data-search', (page.name + ' ' + page.title).toLowerCase()).on('click', function () { page.pending ? openPendingPage(page.url) : navigateTo(page.url); });
const name = $('<td class="page-cell">').append(docIcon());
$('<button type="button" class="page-open">').text(page.name).appendTo(name);
if (page.draft) $('<span class="badge-draft">').text('Draft').appendTo(name);
if (page.draft) $('<span class="badge-draft">').text(page.pending ? 'New - unpublished' : 'Draft').appendTo(name);
row.append(name, $('<td class="page-title">').text(page.title), $('<td class="page-date">').text(formatDate(page.modified)));
const actions = $('<td class="row-actions manage-col">').toggle(permissions.manage).appendTo(row);
if (permissions.manage) {
[['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
if (permissions.manage && page.pending) {
$('<button type="button" class="danger-text">').text('Discard').on('click', function (event) { event.stopPropagation(); discardNewPage(page.url); }).appendTo(actions);
} else if (permissions.manage) {
[['Create Template', 'template'], ['Duplicate', 'duplicate'], ['Rename', 'rename'], ['Delete', 'delete']].forEach(function (action) {
$('<button type="button">').text(action[0]).toggleClass('danger-text', action[1] === 'delete')
.on('click', function (event) { event.stopPropagation(); managePage(action[1], page.url); }).appendTo(actions);
.on('click', function (event) { event.stopPropagation(); action[1] === 'template' ? setPageTemplate(page.url, true) : managePage(action[1], page.url); }).appendTo(actions);
});
}
body.append(row);
});
await loadNewPage();
$('#fileListDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); }
}
@@ -511,29 +534,118 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Load available templates and open the new-page dialogue. */
async function openNewPage() {
/** Fill the New page section of the Pages dialogue with templates and navigation groups, at step 1. */
async function loadNewPage() {
$('#newPageSection').toggle(permissions.manage);
if (!permissions.manage) return;
try {
const templates = await api('getTemplates');
const menus = permissions.manage ? await api('menus') : {};
// Both lists use the same table styling as the page picker; a row selects its radio button.
const pick = function (body, name, value, checked, cells) {
const row = $('<tr class="page-row">');
const radio = $('<input type="radio">').attr('name', name).val(value).prop('checked', checked).on('click', function (event) { event.stopPropagation(); });
row.on('click', function () { radio.prop('checked', true); });
$('<td class="check-cell">').append(radio).appendTo(row);
cells.forEach(function (cell) { row.append(cell); });
body.append(row);
};
const list = $('#radioList').empty();
const templateBody = dataTable(list, ['', 'Template', 'Source', 'Actions']);
templates.forEach(function (item, index) {
$('<label class="template-option">').append($('<input type="radio" name="item">').val(item.id).prop('checked', index === 0), docIcon(), $('<span>').text(item.name)).appendTo(list);
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(item.name));
const actions = $('<td class="row-actions">');
if (permissions.manage) actions.append(rowButton('Delete', function () { deleteTemplate(item); }, true));
pick(templateBody, 'item', item.id, index === 0, [name, $('<td class="page-title">').text(item.detail ? 'Page ' + item.detail : 'Template file'), actions]);
});
$('#newPageDialog').dialog('open');
const groups = $('#menuChoice').empty();
const groupBody = dataTable(groups, ['', 'Navigation group', 'Items']);
pick(groupBody, 'menu', '', true, [$('<td class="page-cell">').append($('<strong>').text('None')), $('<td class="page-title">').text('Do not add to navigation')]);
Object.keys(menus).forEach(function (key) {
pick(groupBody, 'menu', key, false, [$('<td class="page-cell">').append(docIcon(), $('<strong>').text(menus[key].title || key)), $('<td class="page-title">').text(menus[key].items.length + ' link(s)')]);
});
$('#pageName').val('');
showNewPageStep(1);
} catch (error) { showMessage(error.message, 'error'); }
}
/** Create a template-based page and immediately navigate the preview to it. */
/** Show one step of the new-page wizard. */
function showNewPageStep(step) {
$('#newPageForm section').each(function () { $(this).prop('hidden', Number($(this).data('step')) !== step); });
$('#newPageStep').text('Step ' + step + ' of 3');
if (step > 1) $('#newPageForm section[data-step="' + step + '"]').find('input[type="text"]').first().trigger('focus');
}
/** Move between wizard steps, checking the current step is complete before going forward. */
function stepNewPage(direction) {
const step = Number($('#newPageForm section:not([hidden])').data('step'));
if (direction > 0 && step === 1 && !$('input[name="item"]:checked').length) { showMessage('Choose a template.', 'error'); return; }
if (direction > 0 && step === 2 && !$('#pageName').val().trim()) { showMessage('Enter a page name.', 'error'); return; }
showNewPageStep(step + direction);
}
/** Create the page as a private draft (nothing is public yet) and open it for editing. */
async function createPage(event) {
event.preventDefault();
try {
const result = await api('newPage', {template: $('input[name="item"]:checked').val(), filename: $('#filename').val()}, 'POST');
$('#newPageDialog').dialog('close');
navigateTo(result.url);
const result = await api('newPage', {template: $('input[name="item"]:checked').val(), name: $('#pageName').val(), menu: $('input[name="menu"]:checked').val() || ''}, 'POST');
await openPendingPage(result.url);
showMessage(result.message, 'success');
} catch (error) { showMessage(error.message, 'error'); }
}
/** Open a page that has never been published: it exists only as a draft, so it is written into the preview. */
async function openPendingPage(uri) {
if (dirty && !window.confirm('Discard unpublished changes?')) return;
try {
const draft = await api('getDraft', {uri: uri});
if (!draft.exists) throw new Error('This new page has no saved content.');
dirty = false;
framePath = basePath + uri;
loadedDrafts.add(uri);
writingDraft = true;
$('#fileListDialog').dialog('close');
$('#frameContainer').one('load.pending', function () {
writingDraft = true;
iframeDoc.open(); iframeDoc.write(withBase(draft.content, location.origin + basePath + uri)); iframeDoc.close();
// Writing into a blank frame does not raise a second load event, so bind the editing handlers directly.
writingDraft = true;
initialiseFrame();
draftSaved = true;
updateUrl();
});
$('#frameContainer').attr('src', 'about:blank');
} catch (error) { showMessage(error.message, 'error'); }
}
/** Remove a template: a template file is deleted; a page used as a template is only unmarked (the page stays). */
async function deleteTemplate(item) {
const isPage = item.id.indexOf('page:') === 0;
const message = isPage
? 'Stop using ' + item.detail + ' as a template? The page itself is not deleted.'
: 'Delete the template file "' + item.name + '"? This cannot be undone. Pages already made from it are not affected.';
if (!window.confirm(message)) return;
try {
showMessage((await api('deleteTemplate', {template: item.id}, 'POST')).message, 'success');
loadNewPage();
} catch (error) { showMessage(error.message, 'error'); }
}
async function setPageTemplate(uri, on) {
try {
showMessage((await api('setPageTemplate', {uri: uri, value: on ? '1' : '0'}, 'POST')).message, 'success');
openPages();
} catch (error) { showMessage(error.message, 'error'); }
}
async function discardNewPage(uri) {
if (!window.confirm('Discard this unpublished page and its draft? This cannot be undone.')) return;
try {
showMessage((await api('discardNewPage', {uri: uri}, 'POST')).message, 'success');
openPages();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Load the current page's revision history and render restore controls for publishers. */
async function openRevisions() {
try {
@@ -568,42 +680,111 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Build the media library with previews, metadata controls, use counts, and deletion. */
async function openMedia() {
/** Media categories in display order; the server derives each file's category from its extension. */
const MEDIA_CATEGORIES = [['imagery', 'Imagery'], ['documents', 'Documents'], ['video', 'Video'], ['audio', 'Audio']];
let mediaItems = [];
let mediaCategory = 'all';
/** When set, the library is open to choose a file for the editor: called with the chosen item. */
let mediaPick = null;
/** Open the media library, optionally to pick a file for the open editor. */
async function openMedia(pick) {
mediaPick = typeof pick === 'function' ? pick : null;
$('#mediaUploadReport').empty();
await loadMedia();
$('#mediaHint').text(mediaPick ? 'Choose a file to insert at the cursor.' : 'Upload files and organise them by type. Open a page and use the Media library button in the editor to insert them.');
$('#mediaDialog').dialog('open');
}
/** Fetch the library and draw the category tabs and cards. */
async function loadMedia() {
try {
const media = await api('media');
const list = $('#mediaList').empty();
media.forEach(function (item) {
const card = $('<div class="media-card">');
$('<img>').attr({src: item.url, alt: item.alt || ''}).on('click', function () { insertMedia(item); }).appendTo(card);
$('<strong>').text(item.name).appendTo(card);
$('<small>').text(formatBytes(item.size) + ' - used ' + item.uses + ' time(s)').appendTo(card);
const alt = $('<input type="text" placeholder="Alternative text">').val(item.alt).appendTo(card);
$('<button>').text('Save alt text').on('click', async function () {
try { showMessage((await api('updateMedia', {name: item.name, alt: alt.val()}, 'POST')).message, 'success'); } catch (error) { showMessage(error.message, 'error'); }
}).appendTo(card);
if (permissions.manage) $('<button class="danger-text">').text('Delete').on('click', function () { deleteMedia(item); }).appendTo(card);
list.append(card);
});
$('#mediaDialog').dialog('open');
mediaItems = await api('media');
renderMedia();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Append a library image to the currently selected editable region. */
function insertMedia(item) {
if (!currentElement) {
showMessage('Open an editable region first, then select an image.', 'error');
return;
}
currentElement.append($('<img>').attr({src: item.url, alt: item.alt || ''}));
function mediaIcon(category) {
if (category === 'video') return svgIcon('<rect x="3" y="5" width="14" height="14" rx="2"/><path d="M17 10l4-2v8l-4-2z"/>');
if (category === 'audio') return svgIcon('<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>');
return docIcon();
}
/** HTML inserted into the editor for a library file, by category. */
function mediaHtml(item) {
const label = item.alt || item.original || item.name;
if (item.category === 'imagery') return $('<img>').attr({src: item.url, alt: item.alt || ''})[0].outerHTML;
if (item.category === 'video') return $('<video controls preload="metadata" style="max-width:100%">').attr('src', item.url)[0].outerHTML;
if (item.category === 'audio') return $('<audio controls preload="none">').attr('src', item.url)[0].outerHTML;
return $('<a>').attr('href', item.url).text(label + ' (' + item.ext.toUpperCase() + ', ' + formatBytes(item.size) + ')')[0].outerHTML;
}
function renderMedia() {
const counts = {all: mediaItems.length};
MEDIA_CATEGORIES.forEach(function (c) { counts[c[0]] = mediaItems.filter(function (item) { return item.category === c[0]; }).length; });
const tabs = $('#mediaTabs').empty();
[['all', 'All']].concat(MEDIA_CATEGORIES).forEach(function (c) {
$('<button type="button" role="tab" class="media-tab">').text(c[1] + ' (' + counts[c[0]] + ')').attr('aria-selected', c[0] === mediaCategory ? 'true' : 'false')
.on('click', function () { mediaCategory = c[0]; renderMedia(); }).appendTo(tabs);
});
const list = $('#mediaList').empty();
const shown = mediaItems.filter(function (item) { return mediaCategory === 'all' || item.category === mediaCategory; });
if (!shown.length) $('<p class="empty-state">').text(mediaItems.length ? 'Nothing in this category yet.' : 'No media yet. Use Upload files to add some.').appendTo(list);
shown.forEach(function (item) {
const card = $('<div class="media-card">');
const thumb = item.category === 'imagery'
? $('<img>').attr({src: item.url, alt: item.alt || ''})
: $('<div class="media-thumb">').append(mediaIcon(item.category), $('<span>').text(item.ext.toUpperCase()));
thumb.on('click', function () { if (mediaPick) chooseMedia(item); }).appendTo(card);
$('<strong>').text(item.original || item.name).attr('title', item.name).appendTo(card);
$('<small>').text(item.ext.toUpperCase() + ' - ' + formatBytes(item.size) + ' - used ' + item.uses + ' time(s)').appendTo(card);
const alt = $('<input type="text">').attr('placeholder', item.category === 'imagery' ? 'Alternative text' : 'Link text or description').val(item.alt).appendTo(card);
const actions = $('<div class="media-actions">').appendTo(card);
if (mediaPick) $('<button type="button">').text('Insert').addClass('media-insert').on('click', function () { item.alt = alt.val(); chooseMedia(item); }).appendTo(actions);
else $('<button type="button">').text('Copy link').on('click', function () { copyText(location.origin + item.url); }).appendTo(actions);
$('<button type="button">').text('Save description').on('click', async function () {
try { showMessage((await api('updateMedia', {name: item.name, alt: alt.val()}, 'POST')).message, 'success'); item.alt = alt.val(); } catch (error) { showMessage(error.message, 'error'); }
}).appendTo(actions);
if (permissions.manage) $('<button type="button" class="danger-text">').text('Delete').on('click', function () { deleteMedia(item); }).appendTo(actions);
list.append(card);
});
}
function chooseMedia(item) {
const pick = mediaPick;
mediaPick = null;
$('#mediaDialog').dialog('close');
commitEdit();
if (pick) pick(item);
}
function copyText(text) {
(navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).then(function () { showMessage('Link copied.', 'success'); }, function () { window.prompt('Copy this link:', text); });
}
/** Upload the chosen files one request each; every refusal is reported with its reason. */
async function uploadMedia(files) {
const report = $('#mediaUploadReport').empty();
let done = 0;
for (const file of files) {
const form = new FormData();
form.append('file', file, file.name);
form.append('csrf_token', csrfToken);
try {
await $.ajax({url: basePath + '/cms/image_upload.php', method: 'POST', data: form, processData: false, contentType: false, dataType: 'json'});
done++;
} catch (xhr) {
$('<div class="issue">').text(file.name + ': ' + ((xhr.responseJSON && xhr.responseJSON.error) || 'Upload failed')).appendTo(report);
}
}
if (done) showMessage('Uploaded ' + done + ' of ' + files.length + ' file(s).', 'success');
else showMessage('No files were uploaded.', 'error');
await loadMedia();
}
/** Delete a media file, warning more firmly when public pages still reference it. */
async function deleteMedia(item) {
if (item.uses && !window.confirm('This image is used on ' + item.uses + ' page location(s). Delete it anyway?')) return;
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); openMedia(); } catch (error) { showMessage(error.message, 'error'); }
if (item.uses && !window.confirm('This file is used on ' + item.uses + ' page location(s). Delete it anyway?')) return;
try { showMessage((await api('deleteMedia', {name: item.name}, 'POST')).message, 'success'); await loadMedia(); } catch (error) { showMessage(error.message, 'error'); }
}
// Pages per request: small enough for smooth progress updates, large enough to keep request overhead low.
@@ -696,6 +877,19 @@
[s.brokenRefs, 'broken references', 'Local image, CSS, or script files that do not exist.'],
[s.seoGaps, 'missing title or description', ''], [scan.uploads.files + ' (' + scan.uploads.unused + ' unused)', 'uploaded files', '']
].forEach(function (stat) { $('<div class="stat">').attr('title', stat[2]).append($('<strong>').text(stat[0]), document.createTextNode(' ' + stat[1])).appendTo(stats); });
const menus = {};
scan.pages.forEach(function (page) {
(page.navs || []).forEach(function (nav) {
const menu = menus[nav.menu] = menus[nav.menu] || {pages: 0, links: nav.links, sigs: {}};
menu.pages++;
menu.sigs[nav.sig] = (menu.sigs[nav.sig] || 0) + 1;
});
});
const menuText = Object.keys(menus).map(function (name) {
const menu = menus[name], variants = Object.keys(menu.sigs).length;
return name + ' (' + menu.links + ' links on ' + menu.pages + ' pages' + (variants > 1 ? ', ' + variants + ' different versions' : '') + ')';
});
if (menuText.length) $('<p class="scan-note">').text('Navigation: ' + menuText.join(', ')).appendTo(summary);
$('<p class="scan-note">').text('Folders: ' + Object.keys(scan.structure).map(function (dir) { return dir + ' (' + scan.structure[dir] + ')'; }).join(', ')).appendTo(summary);
// After the first run only pages with something new to do are listed; finished pages stay out of the way.
@@ -728,7 +922,7 @@
const uris = $('#siteScanPages tbody input:checked').map(function () { return $(this).val(); }).get();
if (!uris.length) { showMessage('Select at least one page.', 'error'); return; }
if (!window.confirm('Add editing markers to ' + uris.length + ' page(s)? A revision of each is kept.')) return;
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked')};
const options = {content: $('#scanContent').is(':checked'), images: $('#scanImages').is(':checked'), seo: $('#scanSeo').is(':checked'), menus: $('#scanMenus').is(':checked')};
setScanBusy(true);
let updated = 0;
try {
@@ -755,7 +949,7 @@
$('#imageAlt').val(image.is('img') ? image.attr('alt') || '' : '');
const picker = $('#imagePicker').empty();
try {
(await api('media')).forEach(function (item) {
(await api('media')).filter(function (item) { return item.category === 'imagery'; }).forEach(function (item) {
$('<img>').attr({src: item.url, alt: item.alt || '', title: item.name}).on('click', function () {
$('#imageSrc').val(item.url);
if (!$('#imageAlt').val()) $('#imageAlt').val(item.alt || '');
@@ -898,25 +1092,188 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Own account, role descriptions, and (administrators) account management. Secrets never come back from the server. */
async function openUsers() {
try {
$('#userForm, #confirmBox, #inviteResult').prop('hidden', true);
$('#profileCurrent, #pwCurrent, #pwNew, #pwConfirm').val('');
await loadUsers();
$('#toolsDialog').dialog('close');
$('#usersDialog').dialog('open');
} catch (error) { showMessage(error.message, 'error'); }
}
async function loadUsers() {
const data = await api('users');
usersData = data;
$('#profileLogin').val(data.me.username);
$('#profileName').val(data.me.name || data.me.username);
$('#profileEmail').val(data.me.email);
$('#passwordForm').prop('hidden', data.me.managed);
$('#passwordManaged').prop('hidden', !data.me.managed);
const roles = dataTable($('#rolesList').empty(), ['Role', 'What it can do']);
Object.keys(data.roles).forEach(function (role) {
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(role.charAt(0).toUpperCase() + role.slice(1)));
if (role === data.me.role) name.append($('<span class="badge-pending">').text('You'));
roles.append($('<tr>').append(name, $('<td class="page-title">').text(data.roles[role])));
});
$('#userAdmin').toggle(permissions.manage);
const list = $('#userList').empty();
if (!permissions.manage) return;
const body = dataTable(list, ['Name and login name', 'Email', 'Role', 'Status', 'Actions']);
data.users.forEach(function (account) {
const isMe = account.username === data.me.username;
const name = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(account.name || account.username), $('<span class="page-sub">').text(account.username));
const status = $('<td>');
if (account.managed) $('<span class="badge-draft">').attr('title', 'Managed in config.local.php').text('Config').appendTo(status);
else if (account.status === 'blocked') $('<span class="badge-failed">').text('Blocked').appendTo(status);
else if (account.status === 'invited') $('<span class="badge-draft">').text(account.inviteExpired ? 'Invite expired' : 'Invited').appendTo(status);
else $('<span class="badge-pending">').text('Active').appendTo(status);
const actions = $('<td class="row-actions">');
if (!account.managed && !isMe) {
if (account.status === 'invited') actions.append(rowButton('Re-invite', function () { showUserForm('reinvite', account); }));
else actions.append(rowButton('Edit', function () { showUserForm('edit', account); }));
if (account.status !== 'invited') actions.append(rowButton(account.status === 'blocked' ? 'Unblock' : 'Block', function () { blockUser(account); }));
actions.append(rowButton('Delete', function () { deleteUser(account); }, true));
}
body.append($('<tr>').append(name, $('<td class="page-date">').text(account.email), $('<td>').text(account.role.charAt(0).toUpperCase() + account.role.slice(1)), status, actions));
});
}
/** Show the add/edit/invite form; the username and password fields appear only where they apply. */
function showUserForm(mode, account) {
const isNew = mode === 'add' || mode === 'invite';
const invite = mode === 'invite' || mode === 'reinvite';
$('#inviteResult, #confirmBox').prop('hidden', true);
$('#userForm').data('mode', mode).prop('hidden', false);
$('#userFormTitle').text({add: 'Add user', edit: 'Edit user', invite: 'Invite user', reinvite: 'Re-invite user'}[mode]);
$('#userExisting').val(isNew ? '' : account.username);
$('#userUsername').val('').prop('required', mode === 'add');
$('#userUsernameRow').prop('hidden', mode !== 'add');
$('#userName').val(isNew ? '' : account.name);
$('#userEmail').val(isNew ? '' : account.email).prop('required', invite);
$('#userRole').val(isNew ? 'editor' : account.role);
$('#userPassword').val('').prop('required', mode === 'add');
$('#userPasswordRow').prop('hidden', invite);
$('#userPasswordNote').text(mode === 'edit' ? '(leave blank to keep the current password; 12 to 72 characters)' : '(12 to 72 characters)');
$('#userConfirm').val('');
$('#userSubmit').text(invite ? 'Create invitation' : 'Save user');
$('#userName').trigger('focus');
}
async function saveUserForm(event) {
event.preventDefault();
const mode = $('#userForm').data('mode');
const invite = mode === 'invite' || mode === 'reinvite';
const data = {existing: $('#userExisting').val(), name: $('#userName').val(), email: $('#userEmail').val(), role: $('#userRole').val(), confirm_password: $('#userConfirm').val()};
if (!invite) { data.password = $('#userPassword').val(); if (mode === 'add') data.username = $('#userUsername').val(); }
try {
const result = await api(invite ? 'inviteUser' : 'saveUser', data, 'POST');
$('#userForm').prop('hidden', true);
$('#userPassword, #userConfirm').val('');
if (invite) {
$('#inviteLink').val(location.origin + result.link);
$('#inviteLogin').text(result.username);
$('#inviteResult').prop('hidden', false);
}
showMessage(result.message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Ask for the administrator's own password inline; resolves to the password, or null when cancelled. */
function askPassword(title) {
return new Promise(function (resolve) {
$('#userForm, #inviteResult').prop('hidden', true);
$('#confirmTitle').text(title);
$('#confirmPassword').val('');
$('#confirmBox').prop('hidden', false).off('submit.ask').on('submit.ask', function (event) {
event.preventDefault();
const value = $('#confirmPassword').val();
$('#confirmPassword').val('');
$('#confirmBox').prop('hidden', true);
resolve(value);
});
$('#confirmCancel').off('click.ask').on('click.ask', function () { $('#confirmPassword').val(''); $('#confirmBox').prop('hidden', true); resolve(null); });
$('#confirmPassword').trigger('focus');
});
}
async function blockUser(account) {
const blocking = account.status !== 'blocked';
const password = await askPassword((blocking ? 'Block ' : 'Unblock ') + account.username + '? Confirm with your password');
if (password === null) return;
try {
showMessage((await api('blockUser', {username: account.username, blocked: blocking ? '1' : '0', confirm_password: password}, 'POST')).message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
}
async function deleteUser(account) {
if (!window.confirm('Delete ' + account.username + '? This cannot be undone.')) return;
const password = await askPassword('Delete ' + account.username + '? Confirm with your password');
if (password === null) return;
try {
showMessage((await api('deleteUser', {username: account.username, confirm_password: password}, 'POST')).message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
}
/** Wire the Users dialogue forms once. */
function bindUsers() {
$('#profileForm').on('submit', async function (event) {
event.preventDefault();
try {
const result = await api('saveProfile', {name: $('#profileName').val(), email: $('#profileEmail').val(), current_password: $('#profileCurrent').val()}, 'POST');
$('#profileCurrent').val('');
$('#whoami').text(result.name);
showMessage(result.message, 'success');
await loadUsers();
} catch (error) { showMessage(error.message, 'error'); }
});
$('#passwordForm').on('submit', async function (event) {
event.preventDefault();
try {
const result = await api('changePassword', {current_password: $('#pwCurrent').val(), new_password: $('#pwNew').val(), confirm_password: $('#pwConfirm').val()}, 'POST');
$('#pwCurrent, #pwNew, #pwConfirm').val('');
showMessage(result.message, 'success');
} catch (error) { showMessage(error.message, 'error'); }
});
$('#userAddButton').on('click', function () { showUserForm('add'); });
$('#userInviteButton').on('click', function () { showUserForm('invite'); });
$('#userCancel').on('click', function () { $('#userForm').prop('hidden', true); $('#userPassword, #userConfirm').val(''); });
$('#userForm').on('submit', saveUserForm);
$('#inviteCopy').on('click', function () {
const field = $('#inviteLink')[0];
field.select();
(navigator.clipboard ? navigator.clipboard.writeText(field.value) : Promise.reject()).then(function () { showMessage('Invitation link copied.', 'success'); }, function () { document.execCommand('copy'); showMessage('Invitation link selected; press Ctrl+C to copy.', 'success'); });
});
}
/** Load named navigation menus and their editable line-based representation. */
async function openMenus() {
try {
const menus = await api('menus');
const names = Object.keys(menus);
const list = $('#menuList').empty();
$('#menuForm').prop('hidden', true);
if (!names.length) {
$('<p class="empty-state">').text('No menus yet. Use the form below to create one.').appendTo(list);
$('<p class="empty-state">').text('No menus yet. Use "Scan site for new navigation" to find them.').appendTo(list);
} else {
const body = dataTable(list, ['Menu', 'Items', 'Last updated', 'Actions']);
names.forEach(function (name) {
const load = function () {
$('#menuName').val(name);
$('#menuKey').text(name);
$('#menuTitle').val(menus[name].title || '');
$('#menuForm').prop('hidden', false);
$('#menuItems').val(menus[name].items.map(function (item) { return item.label + ' | ' + item.url + (item.parent ? ' | ' + item.parent : ''); }).join('\n'));
};
const row = $('<tr class="page-row">').on('click', load);
$('<td class="page-cell">').append(docIcon(), $('<strong>').text(name)).appendTo(row);
const cell = $('<td class="page-cell">').append(docIcon(), $('<strong>').text(menus[name].title || name)).appendTo(row);
if (menus[name].title) $('<span class="page-sub">').text(name).appendTo(cell);
row.append($('<td>').text(menus[name].items.length), $('<td class="page-date">').text(formatDate(menus[name].updated)),
$('<td class="row-actions">').append(rowButton('Edit', load)));
$('<td class="row-actions">').append(rowButton('Edit', load), rowButton('Delete', function () { deleteMenu(name); }, true)));
body.append(row);
});
}
@@ -926,6 +1283,48 @@
}
/** Parse menu lines, save the structure, and optionally insert its rendered navigation. */
/** Find navigation blocks no menu is attached to yet (for example, one a developer added to a page) and offer to expose them. */
async function scanForNavigation() {
const button = $('#menuScanButton').prop('disabled', true).text('Scanning...');
try {
const listing = await api('listSitePages');
const found = {};
for (let i = 0; i < listing.pages.length; i += SCAN_BATCH) {
const batch = listing.pages.slice(i, i + SCAN_BATCH);
button.text('Scanning ' + Math.min(i + SCAN_BATCH, listing.pages.length) + ' of ' + listing.pages.length + '...');
(await api('analyseSitePages', {uris: JSON.stringify(batch), allNavs: 1})).pages.forEach(function (page) {
page.navs.filter(function (nav) { return !nav.tagged; }).forEach(function (nav) {
const entry = found[nav.menu] = found[nav.menu] || {links: nav.links, pages: []};
entry.pages.push(page.uri);
});
});
}
const names = Object.keys(found);
if (!names.length) { showMessage('No new navigation found. Every navigation block is already a menu.', 'success'); return; }
const known = await api('menus');
const summary = names.map(function (name) {
return '- ' + name + ': ' + found[name].links + ' links on ' + found[name].pages.length + ' page(s)' + (known[name] ? ' (joins the existing menu)' : ' (new menu)');
}).join('\n');
if (!window.confirm('New navigation found:\n' + summary + '\n\nAdd these to Navigation Menus? Each changed page keeps a revision.')) return;
const pages = Array.from(new Set([].concat.apply([], names.map(function (name) { return found[name].pages; }))));
for (let i = 0; i < pages.length; i += APPLY_BATCH) {
await api('applySiteTagging', {uris: JSON.stringify(pages.slice(i, i + APPLY_BATCH)), options: JSON.stringify({menus: true, allNavs: true})}, 'POST');
}
showMessage('Added ' + names.length + ' menu(s) from ' + pages.length + ' page(s).', 'success');
if (!dirty) document.getElementById('frameContainer').contentWindow.location.reload();
openMenus();
} catch (error) { showMessage(error.message, 'error'); }
finally { button.prop('disabled', false).text('Scan site for new navigation'); }
}
async function deleteMenu(name) {
if (!window.confirm("Delete the menu '" + name + "'? Pages keep the navigation they already have, but it will no longer update from this menu.")) return;
try {
showMessage((await api('deleteMenu', {name: name}, 'POST')).message, 'success');
openMenus();
} catch (error) { showMessage(error.message, 'error'); }
}
async function saveMenu(event) {
event.preventDefault();
const items = $('#menuItems').val().split('\n').map(function (line) {
@@ -933,7 +1332,7 @@
return {label: (parts[0] || '').trim(), url: (parts[1] || '').trim(), parent: (parts[2] || '').trim()};
}).filter(function (item) { return item.url; });
try {
const result = await api('saveMenu', {name: $('#menuName').val(), items: JSON.stringify(items)}, 'POST');
const result = await api('saveMenu', {name: $('#menuName').val(), title: $('#menuTitle').val(), items: JSON.stringify(items)}, 'POST');
if (currentElement && window.confirm('Insert this menu into the currently selected editable region?')) {
currentElement.html(result.html);
await commitEdit(result.message + ' This page was also saved as a draft.');
@@ -987,6 +1386,15 @@
});
}
if ((dashboard.deleted || []).length) {
$('<h3 class="dialog-section">').text('Deleted pages').appendTo(content);
const body = dataTable(content, ['Page', 'Deleted', 'By', 'Actions']);
dashboard.deleted.forEach(function (item) {
body.append($('<tr>').append($('<td class="page-cell">').append(docIcon(), document.createTextNode(item.uri)), $('<td class="page-date">').text(formatDate(item.created)),
$('<td>').text(item.user || ''), $('<td class="row-actions">').append(rowButton('Recover', function () { recoverPage(item); }))));
});
}
$('<h3 class="dialog-section">').text('Recent activity').appendTo(content);
if (!dashboard.activity.length) {
$('<p class="empty-state">').text('No activity has been recorded yet.').appendTo(content);
@@ -997,6 +1405,10 @@
$('<td>').text(entry.action), $('<td class="page-title">').text(entry.target)));
});
}
if ((dashboard.notices || []).length) {
$('<h3 class="dialog-section important-note">').text('Recommendations').appendTo(content);
dashboard.notices.forEach(function (notice) { $('<p class="scan-note important-note">').text(notice).appendTo(content); });
}
if (dashboard.problems.length) {
$('<h3 class="dialog-section">').text('System problems').appendTo(content);
dashboard.problems.forEach(function (problem) { $('<div class="issue">').text(problem).appendTo(content); });
@@ -1005,6 +1417,17 @@
} catch (error) { showMessage(error.message, 'error'); }
}
/** Recreate a deleted page from its snapshot, then open it. */
async function recoverPage(item) {
if (!window.confirm('Recover ' + item.uri + '? It will be published again as it was when deleted.')) return;
try {
const result = await api('restoreRevision', {id: item.id}, 'POST');
$('#dashboardDialog').dialog('close');
navigateTo(result.url);
showMessage('Page recovered: ' + result.url, 'success');
} catch (error) { showMessage(error.message, 'error'); }
}
/** Cancel one queued publication and refresh the dashboard. */
async function cancelSchedule(id) {
try { showMessage((await api('cancelSchedule', {id: id}, 'POST')).message, 'success'); loadDashboard(true); } catch (error) { showMessage(error.message, 'error'); }
+50 -7
View File
@@ -8,17 +8,23 @@ use NeoCMS\Authentication;
use NeoCMS\LoginRateLimiter;
use NeoCMS\Logger;
use NeoCMS\SecurityHeaders;
use NeoCMS\UserStore;
// Keep one escaped, user-facing error message for the rendered form.
$error = '';
// Authentication owns the session; the logger records successful and failed attempts.
$authentication = new Authentication($config['authentication'] ?? [], $config['roles'] ?? [], $config['security'] ?? []);
$authentication = new Authentication(...\NeoCMS\UserStore::authArgs($config));
$logger = new Logger($config['audit'] ?? true, $config['security'] ?? []);
$dataDirectory = (string) ($config['dataDirectory'] ?? (__DIR__ . '/../data'));
$rateLimiter = new LoginRateLimiter($dataDirectory, $config['security'] ?? []);
SecurityHeaders::html(false, isset($config['security']['cookieSecure']) ? (bool) $config['security']['cookieSecure'] : null);
// An invitation link (?invite=TOKEN) shows a set-password form instead of the login form.
$users = UserStore::fromConfig($config);
$invite = is_string($_POST['invite'] ?? null) ? $_POST['invite'] : (is_string($_GET['invite'] ?? null) ? $_GET['invite'] : '');
$invite = preg_match('/^[a-f0-9]{64}$/', $invite) ? $invite : '';
// GET displays the form, while POST validates its token and submitted credentials.
$maxLoginRequestBytes = 16 * 1024;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $maxLoginRequestBytes) {
@@ -33,6 +39,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
'anonymous'
);
$error = "Invalid request token";
} elseif ($invite !== '' && isset($_POST['invite'])) {
$address = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
$confirm = is_string($_POST['confirm'] ?? null) ? $_POST['confirm'] : '';
if (($retryAfter = $rateLimiter->retryAfter($address, 'invite')) > 0) {
http_response_code(429);
header('Retry-After: ' . $retryAfter);
$error = 'Too many attempts. Please try again later.';
} elseif (!hash_equals($password, $confirm)) {
$error = 'The passwords do not match';
} else {
try {
$username = $users->acceptInvite($invite, $password);
$rateLimiter->clear($address, 'invite');
$authentication = new Authentication(...UserStore::authArgs($config));
$authentication->login($username, $password);
$logger->write("Invitation accepted for {$username} from {$address}", $username);
header('Location: ' . $config['basePath'] . '/cms/');
exit;
} catch (\RuntimeException $exception) {
$rateLimiter->recordFailure($address, 'invite');
$logger->write('Invitation rejected from ' . $address, 'anonymous');
$error = $exception->getMessage();
}
}
} else {
$username = is_string($_POST['username'] ?? null) ? trim($_POST['username']) : '';
$password = is_string($_POST['password'] ?? null) ? $_POST['password'] : '';
@@ -40,7 +71,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
if (strlen($username) > 128 || strlen($password) > 4096) {
http_response_code(400);
$error = 'Invalid username or password';
$error = 'Invalid login name or password';
} elseif (($retryAfter = $rateLimiter->retryAfter($address, $username)) > 0) {
http_response_code(429);
header('Retry-After: ' . $retryAfter);
@@ -48,6 +79,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
$error = 'Too many login attempts. Please try again later.';
} elseif ($authentication->login($username, $password)) {
$rateLimiter->clear($address, $username);
$users->rehashIfNeeded($username, $password);
// Record the source address before entering the privileged interface.
$logger->write(
@@ -66,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
$username
);
$error = "Invalid username or password";
$error = "Invalid login name or password";
}
}
}
@@ -82,20 +114,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && (int) ($_SERVER['CONTENT_LENGTH'] ?
<!-- The compact login card is intentionally independent of the heavier administration UI. -->
<div class="login-container">
<img class="logo" src="<?php echo htmlspecialchars($config['basePath'], ENT_QUOTES, 'UTF-8'); ?>/cms/img/loginlogo.png" alt="NeoCMS logo"/>
<h2>NeoCMS Login</h2>
<h2><?php echo $invite !== '' ? 'Set your password' : 'NeoCMS Login'; ?></h2>
<?php if (!empty($error)): ?>
<div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<!-- CSRF protection applies to login too, preventing forced authentication state changes. -->
<form method="post">
<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($authentication->getCsrfToken(), ENT_QUOTES, 'UTF-8'); ?>">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
<?php if ($invite !== ''): ?>
<input type="hidden" name="invite" value="<?php echo htmlspecialchars($invite, ENT_QUOTES, 'UTF-8'); ?>">
<label for="password">New password (12 to 72 characters)</label>
<input type="password" id="password" name="password" required minlength="12" maxlength="72" autocomplete="new-password" autofocus>
<label for="confirm">Confirm password</label>
<input type="password" id="confirm" name="confirm" required minlength="12" maxlength="72" autocomplete="new-password">
<button type="submit">Set password and sign in</button>
<?php else: ?>
<label for="username">Login name</label>
<input type="text" id="username" name="username" required autofocus autocomplete="username">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
<input type="password" id="password" name="password" required autocomplete="current-password">
<button type="submit">Log In</button>
<?php endif; ?>
<p class="shamelessPlug">NeoCMS &copy;<?php echo date('Y'); ?> Andy Dixon</p>
</form>
</div>
+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');
}
}
+11 -7
View File
@@ -16,6 +16,8 @@
main { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px; margin-top: 22px; }
section { padding: 22px; border: 1px solid #d4dde4; border-radius: 10px; background: #fff; }
code { padding: 2px 5px; border-radius: 4px; background: #e8edf1; }
.important { color: #b3141f; }
.important code { color: inherit; background: #fbe4e6; }
footer { margin-top: 24px; color: #586673; text-align: center; }
</style>
</head>
@@ -37,33 +39,35 @@
<!-- Content creation and rollback tools. -->
<section>
<h2>Pages and revisions</h2>
<p>Administrators can create, duplicate, rename, and delete pages. Publishers can restore earlier revisions from <strong>Tools</strong>.</p>
<p>Deleted pages retain a restorable snapshot, because accidents should be reversible rather than memorable.</p>
<p>Administrators can create, duplicate, rename, and delete pages. The <strong>New page</strong> section under the list in <strong>Pages</strong> is a three-step wizard: choose a template, name the page (the filename is generated for you), then choose a navigation group. The new page stays a private draft until it is published; publishing creates the file and adds it to the chosen menu.</p>
<p>Use <strong>Create Template</strong> on any page in the list to offer it as a template, and Delete in the template list to stop offering one. Editors can restore earlier revisions from <strong>Tools</strong>.</p>
<p>Deleted pages retain a restorable snapshot: editors can bring one back with <strong>Recover</strong> in the <strong>Dashboard</strong>'s Deleted pages list, because accidents should be reversible rather than memorable.</p>
</section>
<!-- Asset and metadata management. -->
<section>
<h2>Media and SEO</h2>
<p>The media library reuses uploaded images, records alternative text, and reports usage counts.</p>
<p>The media library uploads images, documents, video and audio, sorts them by type, records alternative text or link text, and reports usage counts. In the editor, the <strong>Media library</strong> button inserts an uploaded file at the cursor. Executables and files containing code are refused.</p>
<p>The SEO dialogue manages titles, descriptions, canonical URLs, Open Graph images, and indexing instructions.</p>
</section>
<!-- Site-wide content management. -->
<section>
<h2>Shared content and menus</h2>
<p>Regions marked with <code>data-neo-shared</code> can be updated across every page. Named menus may be nested and propagated in the same fashion.</p>
<p>Regions marked with <code>data-neo-shared</code> can be updated across every page. Open <strong>Navigation Menus</strong> to edit, rename, or delete menus; changes propagate to every page. Menus are created by your web developer, not the CMS: <strong>Site Scan</strong> and the menu dialogue's scan button find navigation in the site files (primary and secondary are detected automatically).</p>
</section>
<!-- Publication timing and preview controls. -->
<section>
<h2>Schedule and preview</h2>
<p>Publishers may queue a future release. Desktop, tablet, and mobile controls resize the preview without altering page markup.</p>
<p>Editors may queue a future release. The desktop, tablet, and mobile icon buttons resize the preview without altering page markup.</p>
</section>
<!-- Quality and operational tools. -->
<section>
<h2>Checks and activity</h2>
<p>The accessibility checker catches common authoring problems before publication. The dashboard lists drafts, schedules, recent activity, and writable-directory warnings.</p>
<h2>Users, checks and activity</h2>
<p>The accessibility checker catches common authoring problems before publication. The dashboard lists drafts, schedules, deleted pages you can recover, recent activity, writable-directory warnings, and recommendations.</p>
<p>Everyone can update their display name, email address and password from <strong>Tools &gt; Users</strong>. An <strong>Editor</strong> edits and publishes content; an <strong>Administrator</strong> can also manage pages, menus and shared content, and add, invite, edit, block or delete users. Accounts in <code>config.local.php</code> stay managed there. <strong class="important">Important: CMS-created accounts are stored in the data folder, so on a live site set <code>dataDirectory</code> in <code>config.local.php</code> to an absolute path outside the web root; administrators see a Dashboard recommendation until it is.</strong></p>
</section>
</main>