UI fixes, code tightening, security fixes

This commit is contained in:
Andy Dixon
2026-03-17 10:19:21 +00:00
parent 40cc6b8c66
commit 42fe5b9304
9 changed files with 335 additions and 78 deletions
+3 -2
View File
@@ -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,
+115 -19
View File
@@ -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;
}
/**
@@ -167,3 +211,55 @@ File List Modal
.redText {
color: #ff4d4d;
}
@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;
}
}
+31 -11
View File
@@ -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
+2
View File
@@ -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()) {
<html lang="en-gb">
<head>
<title>NeoCMS</title>
<meta name="csrf-token" content="<?php echo htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8'); ?>">
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://code.jquery.com/ui/1.14.0/jquery-ui.min.js" crossorigin="anonymous"></script>
<script src="/cms/tinymce/tinymce.min.js"></script>
+57 -17
View File
@@ -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("<strong>Editing:</strong> <div>" + $(iframeDoc)[0].URL + "</div>");
$('#urlbox').empty().append(
$('<strong>').text('Editing:'),
$('<div>').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);
}
+29 -18
View File
@@ -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') {
<img class="logo" src="/cms/img/loginlogo.png" alt="NeoCMS logo"/>
<h2>NeoCMS Login</h2>
<?php if (!empty($error)): ?>
<div class="error-message"><?php echo htmlspecialchars($error); ?></div>
<div class="error-message"><?php echo htmlspecialchars($error, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<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>
+69 -3
View File
@@ -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);
}
}
+19 -4
View File
@@ -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;
+8 -2
View File
@@ -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);
}
}