Files
neoCMS/cms/image_upload.php
Geoff MooreandClaude Sonnet 5 8946915bfe 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>
2026-09-24 17:47:34 +01:00

214 lines
9.4 KiB
PHP

<?php
/**
* Authenticated media-upload endpoint (images, documents, video, audio), also used by TinyMCE for pasted images.
*
* 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.
require_once __DIR__ . '/bootstrap.php';
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(...\NeoCMS\UserStore::authArgs($config));
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;
}
// 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' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
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' => 'This file is larger than the ' . round($max_file_size / 1048576, 1) . ' MB limit.']);
exit;
}
// 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] ?? '');
}
// 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' => $exception->getMessage()]);
exit;
}
// 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';
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, 'original' => substr(preg_replace('/[\x00-\x1F\x7F]/u', '', basename($originalName)) ?? '', 0, 200), 'by' => $authentication->getLoggedInUser()];
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 ' . (['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, 'name' => $filename, 'category' => $inspection['category']]);
} 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]);
}