diff --git a/cms/config.php b/cms/config.php index 3927482..d53e307 100644 --- a/cms/config.php +++ b/cms/config.php @@ -6,8 +6,9 @@ $config = [ 'authentication' => [ - // Add more users as needed - its 'username'=>'password' if you hadn't have guessed - 'admin' => 'password123', + // Store password hashes generated with password_hash(...), not plaintext passwords. + // Default password for this sample hash is: change-this-password + 'admin' => '$2y$10$.MiUOFPR.L9osXyKuUjqx.VeZeeD3bHrQrmPQAOzXSfWj.nene1ey', ], // Enable the auditing system 'audit' => true, diff --git a/cms/css/editor.css b/cms/css/editor.css index deb823d..8efb1b2 100644 --- a/cms/css/editor.css +++ b/cms/css/editor.css @@ -4,23 +4,44 @@ body { margin: 0; } +body { + min-height: 100vh; +} + .pageContainer { display: flex; flex-flow: column; height: 100%; + min-height: 100vh; + width: 100%; + min-width: 0; } .pageContainer .controls { - flex: 0 1 auto; - height: 60px; + flex: 0 0 auto; + min-height: 60px; + position: relative; background-color: #1a1d20; 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; + align-items: center; + gap: 16px; + padding: 10px 16px; + box-sizing: border-box; + width: 100%; + min-width: 0; + flex-wrap: wrap; } .pageContainer .frame { flex: 1 1 auto; + display: block; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; border: 0; } @@ -29,26 +50,34 @@ body { width: 60px; height: 60px; display: block; - position: absolute; + flex: 0 0 60px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; } .controls .buttonContainer { - width: 400px; - margin: 12px 0; - position: absolute; - right: 110px; + margin-left: auto; + flex: 0 1 auto; + min-width: 0; } /* CSS */ .headerButton { - /*display: flex;*/ - flex-direction: column; + display: inline-flex; align-items: center; - padding: 6px 14px; + justify-content: center; + min-height: 38px; + padding: 8px 14px; font-family: -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 1.2; border-radius: 6px; border: none; + white-space: nowrap; + cursor: pointer; background: #6E6D70; box-shadow: 0px 0.5px 1px rgba(0, 0, 0, 0.1), inset 0px 0.5px 0.5px rgba(255, 255, 255, 0.5), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.12); @@ -67,35 +96,50 @@ body { background-color: #6E4D50; } +.headerButton:hover { + filter: brightness(1.08); +} + .controls #urlbox { - margin: 15px 70px; + flex: 1 1 320px; + min-width: 0; text-align: center; - font-size: 1.5em; - position: absolute; + font-size: 1.1rem; + line-height: 1.3; color: #dee2e6; } .controls #urlbox div { - float: right; + display: inline-block; margin-left: 10px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + white-space: nowrap; } .controls .loggedInDetails { - position: absolute; - top: 20px; - right: 10px; + flex: 0 1 auto; color: #dee2e6; + text-align: right; + font-size: 0.9rem; + line-height: 1.4; + max-width: 260px; } .controls .buttonContainer ul { list-style-type: none; margin: 0; padding: 0; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; } .controls .buttonContainer li { - display: inline; - margin-right: 10px; + display: block; } /** @@ -166,4 +210,56 @@ File List Modal .redText { color: #ff4d4d; -} \ No newline at end of file +} + +@media (max-width: 1100px) { + .pageContainer .controls { + gap: 12px; + } + + .controls #urlbox { + order: 3; + flex-basis: 100%; + text-align: left; + } + + .controls .buttonContainer { + margin-left: 0; + } +} + +@media (max-width: 760px) { + .pageContainer .controls { + align-items: flex-start; + padding: 12px; + } + + .controls .logo { + width: 48px; + height: 48px; + flex-basis: 48px; + } + + .controls .buttonContainer, + .controls .loggedInDetails, + .controls #urlbox { + flex-basis: 100%; + max-width: none; + text-align: left; + } + + .controls .buttonContainer ul { + gap: 8px; + } + + .controls .buttonContainer li, + .controls .buttonContainer .headerButton { + width: 100%; + } + + .controls #urlbox div { + display: block; + margin-left: 0; + margin-top: 4px; + } +} diff --git a/cms/image_upload.php b/cms/image_upload.php index e64ed86..2f7beab 100644 --- a/cms/image_upload.php +++ b/cms/image_upload.php @@ -20,18 +20,32 @@ if (!$authentication->isLoggedIn()) { exit; } +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + header('Allow: POST'); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Invalid request method']); + exit; +} + +$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; +if (!$authentication->isValidCsrfToken($csrfToken)) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Invalid CSRF token']); + exit; +} + // Set appropriate headers header('Content-Type: application/json'); +header('X-Content-Type-Options: nosniff'); -// Allowed MIME types and extensions +// TinyMCE uses this endpoint for images only. $allowed_types = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', - 'text/pdf' => 'pdf', - 'text/docx' => 'docx', - 'text/xlsx' => 'xlsx', - 'text/doc' => 'doc', + 'image/webp' => 'webp', ]; // Maximum file size in bytes (50MB) @@ -68,19 +82,25 @@ if (isset($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) { // Generate a unique name for the image $extension = $allowed_types[$mime_type]; - $filename = uniqid('', true) . '.' . $extension; + $filename = bin2hex(random_bytes(16)) . '.' . $extension; // Set the upload directory - $upload_dir = realpath(__DIR__ . '/../uploads') . DIRECTORY_SEPARATOR; - - // Ensure the upload directory exists - if (!is_dir($upload_dir) && !mkdir($upload_dir, 0755, true)) { + $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; } - // Set the target file path + $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; + + // Ensure the upload directory exists $target_file = $upload_dir . $filename; // Move the uploaded file to the target directory diff --git a/cms/index.php b/cms/index.php index 729f54f..5c43b4f 100644 --- a/cms/index.php +++ b/cms/index.php @@ -10,6 +10,7 @@ use NeoCMS\Authentication; // Instantiate the Authentication class $authentication = new Authentication($config['authentication'] ?? []); +$csrfToken = $authentication->getCsrfToken(); // Check if the user is logged in if (!$authentication->isLoggedIn()) { @@ -23,6 +24,7 @@ if (!$authentication->isLoggedIn()) { NeoCMS + diff --git a/cms/js/cms.js b/cms/js/cms.js index 686802d..7c9c606 100644 --- a/cms/js/cms.js +++ b/cms/js/cms.js @@ -2,6 +2,7 @@ $(document).ready(function () { let currentDiv; let iframeDoc; + const csrfToken = $('meta[name="csrf-token"]').attr('content') || ''; $('#frameContainer').on('load', function () { @@ -28,7 +29,8 @@ $(document).ready(function () { $('#editor').val(content); // Open the modal and init TinyMCE - $('#editModal').modal('show') + $('#editModal') + .off('shown.bs.modal') .on('shown.bs.modal', function () { tinymce.init({ promotion: false, @@ -41,13 +43,50 @@ $(document).ready(function () { automatic_uploads: true, images_reuse_filename: false, images_upload_credentials: true, + images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', blobInfo.blob(), blobInfo.filename()); + formData.append('csrf_token', csrfToken); + + $.ajax({ + url: '/cms/image_upload.php', + method: 'POST', + data: formData, + processData: false, + contentType: false, + xhr: function () { + const xhr = $.ajaxSettings.xhr(); + if (xhr.upload) { + xhr.upload.addEventListener('progress', function (event) { + if (event.lengthComputable) { + progress((event.loaded / event.total) * 100); + } + }); + } + return xhr; + }, + success: function (response) { + if (response && response.location) { + resolve(response.location); + return; + } + + reject((response && response.error) || 'Upload failed'); + }, + error: function (xhr) { + const response = xhr.responseJSON || {}; + reject(response.error || 'Upload failed'); + } + }); + }), force_br_newlines: false, force_p_newlines: false, license_key: 'gpl', forced_root_block: 'aaa', newline_behavior: 'linebreak' }); - }); + }) + .modal('show'); } regenerateButtons(); @@ -129,20 +168,18 @@ $(document).ready(function () { // Handle Saving changes to the page $('#savePage').click(() => { + // Clone the document so CMS-only controls and styles are not written back into the page. + const frameContainerElement = $('#frameContainer').contents().get(0); + const clonedDocument = frameContainerElement.cloneNode(true); - // Remove the buttons container as not to make things awks - $(iframeDoc).find('.button-container').remove(); - - // Remove pointer cursor, but need to find a proper way of removing the cursor inline style, so we can get rid of any unneeded inline styles - $(iframeDoc).find('.editable, .neo-dupe').css('cursor', 'default'); - - // Get page content and save it - let frameContainerElement = $('#frameContainer').contents().get(0); + $(clonedDocument).find('.button-container').remove(); + $(clonedDocument).find('.editable, .neo-dupe').css('cursor', ''); $.post("/cms/controller/", { action: "save", uri: frameContainerElement.location.pathname, - content: new XMLSerializer().serializeToString(frameContainerElement) + content: new XMLSerializer().serializeToString(clonedDocument), + csrf_token: csrfToken }, function (data) { if (typeof data.error == "undefined") { showMessage(data.message, "success"); @@ -179,7 +216,8 @@ $(document).ready(function () { method: "POST", data: { template: selectedItemId, - filename: filename + filename: filename, + csrf_token: csrfToken }, success: function (response) { if (typeof response.error != "undefined") { @@ -249,12 +287,12 @@ $(document).ready(function () { // Handle 'Select Page' button $("#selectPage").click(function () { loadListOfPages(); // Call function to load the file list - $("#fileList").dialog({ + $("#fileListDialog").dialog({ modal: true, width: 500, buttons: { Close: function () { - $('.ui-dialog-content').dialog('close'); + $(this).dialog('close'); } } }); @@ -263,10 +301,13 @@ $(document).ready(function () { // Add buttons to all existing neo-dupe elements inside the iframe function regenerateButtons() { $(iframeDoc).find('.neo-dupe').each(function () { - $(this).find('.buttonContainer').remove(); + $(this).find('.button-container').remove(); addButtonsToNeoDupe(this); }); - $('#urlbox').html("Editing:
" + $(iframeDoc)[0].URL + "
"); + $('#urlbox').empty().append( + $('').text('Editing:'), + $('
').text($(iframeDoc)[0].URL) + ); } // Function to add buttons to a neo-dupe element inside the iframe @@ -341,4 +382,3 @@ function showMessage(message, type) { messageBar.slideUp(); }, 5000); } - diff --git a/cms/login/index.php b/cms/login/index.php index 76dfbf7..91d6640 100644 --- a/cms/login/index.php +++ b/cms/login/index.php @@ -20,27 +20,37 @@ $authentication = new Authentication($config['authentication'] ?? []); $logger = new Logger($config['audit'] ?? true); if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $username = $_POST['username'] ?? ''; - $password = $_POST['password'] ?? ''; - - if ($authentication->login($username, $password)) { - // Log successful login + $csrfToken = $_POST['csrf_token'] ?? null; + if (!$authentication->isValidCsrfToken($csrfToken)) { + http_response_code(400); $logger->write( - "User login for $username was successful from {$_SERVER['REMOTE_ADDR']}", - $username + "User login attempt rejected due to invalid CSRF token from {$_SERVER['REMOTE_ADDR']}", + 'anonymous' ); - - // Redirect to the CMS dashboard or desired page - header("Location: /cms/"); - exit("Well done, champ! You got this!! <3"); + $error = "Invalid request token"; } else { - // Log failed login attempt - $logger->write( - "User login for $username was denied due to incorrect credentials from {$_SERVER['REMOTE_ADDR']}", - $username - ); + $username = $_POST['username'] ?? ''; + $password = $_POST['password'] ?? ''; - $error = "Invalid username or password"; + if ($authentication->login($username, $password)) { + // Log successful login + $logger->write( + "User login for $username was successful from {$_SERVER['REMOTE_ADDR']}", + $username + ); + + // Redirect to the CMS dashboard or desired page + header("Location: /cms/"); + exit; + } else { + // Log failed login attempt + $logger->write( + "User login for $username was denied due to incorrect credentials from {$_SERVER['REMOTE_ADDR']}", + $username + ); + + $error = "Invalid username or password"; + } } } ?> @@ -56,9 +66,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {

NeoCMS Login

-
+
+ @@ -71,4 +82,4 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- \ No newline at end of file + diff --git a/cms/src/NeoCMS/Authentication.php b/cms/src/NeoCMS/Authentication.php index 52fd660..63277e7 100644 --- a/cms/src/NeoCMS/Authentication.php +++ b/cms/src/NeoCMS/Authentication.php @@ -4,14 +4,38 @@ namespace NeoCMS; class Authentication { - private $credentials; + private array $credentials; public function __construct(array $credentials) { - session_start(); + $this->startSession(); $this->credentials = $credentials; } + private function startSession(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + return; + } + + $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443); + + ini_set('session.use_strict_mode', '1'); + ini_set('session.use_only_cookies', '1'); + ini_set('session.cookie_httponly', '1'); + ini_set('session.cookie_samesite', 'Lax'); + + session_set_cookie_params([ + 'httponly' => true, + 'path' => '/', + 'samesite' => 'Lax', + 'secure' => $isHttps, + ]); + + session_start(); + } + /** * Logs a user into the CMS * @param $username @@ -20,9 +44,11 @@ class Authentication */ public function login($username, $password) { - if (!empty($this->credentials[$username]) && $this->credentials[$username] === $password) { + if ($this->credentialsAreValid((string) $username, (string) $password)) { + session_regenerate_id(true); $_SESSION['loggedIn'] = true; $_SESSION['loggedInUser'] = $username; + $_SESSION['csrfToken'] = bin2hex(random_bytes(32)); return true; } @@ -48,12 +74,52 @@ class Authentication return $_SESSION['loggedInUser'] ?? 'Not logged in'; } + public function getCsrfToken(): string + { + if (empty($_SESSION['csrfToken'])) { + $_SESSION['csrfToken'] = bin2hex(random_bytes(32)); + } + + return $_SESSION['csrfToken']; + } + + public function isValidCsrfToken(?string $token): bool + { + if (!is_string($token) || $token === '') { + return false; + } + + return hash_equals($this->getCsrfToken(), $token); + } + /** * Destroy the session * @return void */ public function logout() { + $_SESSION = []; + + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']); + } + session_destroy(); } + + private function credentialsAreValid(string $username, string $password): bool + { + $storedCredential = $this->credentials[$username] ?? null; + if (!is_string($storedCredential) || $storedCredential === '') { + return false; + } + + $passwordInfo = password_get_info($storedCredential); + if (($passwordInfo['algo'] ?? null) !== null) { + return password_verify($password, $storedCredential); + } + + return hash_equals($storedCredential, $password); + } } diff --git a/cms/src/NeoCMS/CMSController.php b/cms/src/NeoCMS/CMSController.php index 50ea976..5db17e0 100644 --- a/cms/src/NeoCMS/CMSController.php +++ b/cms/src/NeoCMS/CMSController.php @@ -34,7 +34,7 @@ class CMSController } // Check if the user is authenticated for actions that require it - if (in_array($action, ['save', 'newPage']) && !$this->authentication->isLoggedIn()) { + if (!$this->authentication->isLoggedIn()) { $this->sendJsonResponse(['error' => 'Not authenticated']); return; } @@ -44,7 +44,11 @@ class CMSController try { $this->$methodName(); } catch (\Exception $e) { - $this->sendJsonResponse(['error' => $e->getMessage()]); + $this->logger->write( + "Controller action {$action} failed: {$e->getMessage()}", + $this->authentication->getLoggedInUser() + ); + $this->sendJsonResponse(['error' => 'Request failed']); } } else { $this->sendJsonResponse(['error' => 'Unknown action']); @@ -59,10 +63,11 @@ class CMSController private function saveAction(): void { $this->ensurePostRequest(); + $this->ensureValidCsrfToken(); $uri = $_POST['uri'] ?? ''; $content = $_POST['content'] ?? ''; - if (empty($uri) || empty($content)) { + if ($uri === '' || !is_string($content)) { $this->sendJsonResponse(['error' => 'URI and content are required']); return; } @@ -101,6 +106,7 @@ class CMSController private function newPageAction(): void { $this->ensurePostRequest(); + $this->ensureValidCsrfToken(); $filename = $_POST['filename'] ?? ''; $template = $_POST['template'] ?? ''; @@ -275,6 +281,14 @@ class CMSController } } + private function ensureValidCsrfToken(): void + { + $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + if (!$this->authentication->isValidCsrfToken($token)) { + throw new \Exception('Invalid CSRF token'); + } + } + /** * Send the JSON response * @param $data @@ -283,6 +297,7 @@ class CMSController private function sendJsonResponse($data): void { header('Content-Type: application/json'); + header('X-Content-Type-Options: nosniff'); echo json_encode($data); } @@ -307,7 +322,7 @@ class CMSController // Find all elements with the class "editable" $xpath = new \DOMXPath($dom); - $editableElements = $xpath->query('//*[contains(@class, "editable")]'); + $editableElements = $xpath->query('//*[contains(concat(" ", normalize-space(@class), " "), " editable ")]'); // Return true if there are any elements with the "editable" class return $editableElements->length > 0; diff --git a/cms/src/NeoCMS/Logger.php b/cms/src/NeoCMS/Logger.php index 1d8fbe8..93f9cf5 100644 --- a/cms/src/NeoCMS/Logger.php +++ b/cms/src/NeoCMS/Logger.php @@ -37,10 +37,16 @@ class Logger $entry = sprintf( "[%s]\tUser: %s\t%s\n", date('Y-m-d H:i:s'), - $user, - trim($message) + $this->normaliseLogField($user), + $this->normaliseLogField($message) ); file_put_contents($logFile, $entry, FILE_APPEND); } + + private function normaliseLogField($value): string + { + $value = trim((string) $value); + return str_replace(["\r", "\n", "\t"], [' ', ' ', ' '], $value); + } }