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

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

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

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

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

227 lines
9.1 KiB
PHP

<?php
/**
* Authenticated TinyMCE image-upload endpoint.
*
* 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.
*/
// Load credentials, role assignments, and shared application settings.
require_once __DIR__ . '/bootstrap.php';
use NeoCMS\Activity;
use NeoCMS\Authentication;
use NeoCMS\FileStore;
use NeoCMS\Logger;
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'] ?? []);
if (!$authentication->isLoggedIn()) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorised']);
exit;
}
// Editors may upload; unauthorised roles receive a clear forbidden response.
if (!$authentication->can('upload')) {
http_response_code(403);
echo json_encode(['error' => 'Your role cannot upload files']);
exit;
}
// Uploads alter server state and therefore accept POST only.
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
echo json_encode(['error' => 'Invalid request method']);
exit;
}
// Accept the token as form data or a conventional request header.
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!$authentication->isValidCsrfToken($csrfToken)) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
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));
$maxWidth = max(1, (int) ($uploadConfig['maxWidth'] ?? 8192));
$maxHeight = max(1, (int) ($uploadConfig['maxHeight'] ?? 8192));
$maxPixels = max(1, (int) ($uploadConfig['maxPixels'] ?? 24 * 1024 * 1024));
$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.']);
exit;
}
// Continue only when PHP reports a complete upload.
if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$file = $_FILES['file'];
// 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.']);
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);
if (!array_key_exists($mime_type, $allowed_types)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid file type.']);
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;
// Create the public upload directory on a fresh installation.
$uploadRoot = __DIR__ . '/../uploads';
if (!is_dir($uploadRoot) && !mkdir($uploadRoot, 0755, true)) {
http_response_code(500);
echo json_encode(['error' => 'Failed to create upload directory.']);
exit;
}
$upload_dir = realpath($uploadRoot);
if ($upload_dir === false) {
http_response_code(500);
echo json_encode(['error' => 'Upload directory is invalid.']);
exit;
}
$upload_dir .= DIRECTORY_SEPARATOR;
@chmod($upload_dir, 0755);
// Build the final path from the canonical upload directory and generated safe filename.
$target_file = $upload_dir . $filename;
// Serialise quota checks and moves so concurrent uploads cannot race past the storage ceiling.
$store = new FileStore((string) ($config['dataDirectory'] ?? (__DIR__ . '/data')));
$lockPath = $store->directory('locks') . 'uploads.lock';
$uploadLock = fopen($lockPath, 'c+');
if ($uploadLock === false || !flock($uploadLock, LOCK_EX)) {
if (is_resource($uploadLock)) {
fclose($uploadLock);
}
http_response_code(503);
echo json_encode(['error' => 'Upload service is temporarily unavailable.']);
exit;
}
@chmod($lockPath, 0600);
$existingFiles = array_values(array_filter(glob($upload_dir . '*') ?: [], 'is_file'));
$existingBytes = array_sum(array_map(static fn(string $path): int => (int) filesize($path), $existingFiles));
if (count($existingFiles) >= $maxFiles || $existingBytes + (int) $file['size'] > $maxTotalBytes) {
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
http_response_code(507);
echo json_encode(['error' => 'Upload storage quota has been reached.']);
exit;
}
// move_uploaded_file() additionally confirms that PHP received the source as an upload.
if (move_uploaded_file($file['tmp_name'], $target_file)) {
// Public images need read access but no executable or world-writable permissions.
chmod($target_file, 0644);
// Seed media metadata from the original basename; editors can improve the alt text later.
$location = $config['basePath'] . '/uploads/' . $filename;
$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];
try {
$store->write('media', $metadata);
} catch (\Throwable $exception) {
@unlink($target_file);
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
http_response_code(500);
echo json_encode(['error' => 'Unable to record uploaded media.']);
exit;
}
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
// 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)');
} catch (\Throwable $exception) {
// Deliberately ignored.
}
echo json_encode(['location' => $location]);
} else {
flock($uploadLock, LOCK_UN);
fclose($uploadLock);
// A failed move is a server error because validation has already succeeded.
http_response_code(500);
echo json_encode(['error' => 'Failed to move uploaded file.']);
}
} else {
// Translate PHP's numeric upload failures into messages an editor can act upon.
$error_message = 'No file uploaded.';
if (isset($_FILES['file']['error'])) {
switch ($_FILES['file']['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$error_message = 'File size exceeds the maximum limit.';
break;
case UPLOAD_ERR_PARTIAL:
$error_message = 'The uploaded file was only partially uploaded.';
break;
case UPLOAD_ERR_NO_FILE:
$error_message = 'No file was uploaded.';
break;
case UPLOAD_ERR_NO_TMP_DIR:
$error_message = 'Missing a temporary folder.';
break;
case UPLOAD_ERR_CANT_WRITE:
$error_message = 'Failed to write file to disk.';
break;
case UPLOAD_ERR_EXTENSION:
$error_message = 'A PHP extension stopped the file upload.';
break;
default:
$error_message = 'Unknown upload error.';
break;
}
}
http_response_code(400);
echo json_encode(['error' => $error_message]);
}