Massive refactor of code into classes, improve security on the image/file upload handler. Clean up the init.php into a config.php and refactor everything else to work around it.

Resolves issues #13 #14 and #16
This commit is contained in:
Andy Dixon
2024-11-12 21:20:46 +00:00
parent 94f1634fc2
commit d0c38378b6
13 changed files with 581 additions and 242 deletions
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace NeoCMS;
class Authentication
{
private $credentials;
public function __construct(array $credentials)
{
session_start();
$this->credentials = $credentials;
}
/**
* Logs a user into the CMS
* @param $username
* @param $password
* @return bool
*/
public function login($username, $password)
{
if (!empty($this->credentials[$username]) && $this->credentials[$username] === $password) {
$_SESSION['loggedIn'] = true;
$_SESSION['loggedInUser'] = $username;
return true;
}
$_SESSION['loggedIn'] = false;
return false;
}
/**
* Checks if a user has logged in
* @return bool
*/
public function isLoggedIn(): bool
{
return $_SESSION['loggedIn'] ?? false;
}
/**
* Returns the user logged in
* @return string
*/
public function getLoggedInUser(): string
{
return $_SESSION['loggedInUser'] ?? 'Not logged in';
}
/**
* Destroy the session
* @return void
*/
public function logout()
{
session_destroy();
}
}
+286
View File
@@ -0,0 +1,286 @@
<?php
namespace NeoCMS;
class CMSController
{
private array $config;
private Authentication $authentication;
private Logger $logger;
private array $request;
private string $templatesDir;
private string $documentRoot;
public function __construct($config)
{
$this->config = $config;
$this->authentication = new Authentication($config['authentication']);
$this->logger = new Logger($config['audit']);
$this->request = $_REQUEST;
$this->templatesDir = realpath(__DIR__ . '/../../templates/') . DIRECTORY_SEPARATOR;
$this->documentRoot = rtrim(realpath($_SERVER['DOCUMENT_ROOT']), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
}
/**
* Handle an AJAX call
* @return void
*/
public function handleRequest(): void
{
$action = $this->request['action'] ?? null;
if (!$action) {
$this->sendJsonResponse(['error' => 'No action specified']);
return;
}
// Check if the user is authenticated for actions that require it
if (in_array($action, ['save', 'newPage']) && !$this->authentication->isLoggedIn()) {
$this->sendJsonResponse(['error' => 'Not authenticated']);
return;
}
$methodName = $action . 'Action';
if (method_exists($this, $methodName)) {
try {
$this->$methodName();
} catch (\Exception $e) {
$this->sendJsonResponse(['error' => $e->getMessage()]);
}
} else {
$this->sendJsonResponse(['error' => 'Unknown action']);
}
}
/**
* Saves a page to the filesystem
* @return void
* @throws \Exception
*/
private function saveAction(): void
{
$this->ensurePostRequest();
$uri = $_POST['uri'] ?? '';
$content = $_POST['content'] ?? '';
if (empty($uri) || empty($content)) {
$this->sendJsonResponse(['error' => 'URI and content are required']);
return;
}
$filename = $this->saveContent($uri, $content);
$this->sendJsonResponse([
'message' => 'Page has been saved',
'destination' => $filename,
]);
}
/**
* Returns back a JSON object containing templates
* @return void
*/
private function getTemplatesAction(): void
{
$files = array_diff(scandir($this->templatesDir), ['..', '.']);
$templates = [];
foreach ($files as $file) {
$templates[] = [
'id' => $file,
'name' => pathinfo($file, PATHINFO_FILENAME),
];
}
$this->sendJsonResponse($templates);
}
/**
* Create a new page based on a template file
* @return void
* @throws \Exception
*/
private function newPageAction(): void
{
$this->ensurePostRequest();
$filename = $_POST['filename'] ?? '';
$template = $_POST['template'] ?? '';
$filename = $this->sanitiseFileName($filename);
$template = $this->sanitiseFileName($template);
if (!$filename || !$template) {
$this->sendJsonResponse(['error' => 'Filename and template are required']);
return;
}
if (!preg_match('/\.(html|htm)$/', $filename)) {
$filename .= '.html';
}
$destination = $this->documentRoot . ltrim($filename, DIRECTORY_SEPARATOR);
$templatePath = realpath($this->templatesDir . $template);
if (!$templatePath || strpos($templatePath, $this->templatesDir) !== 0) {
$this->sendJsonResponse(['error' => 'Template not found']);
return;
}
if (file_exists($destination)) {
$this->sendJsonResponse(['error' => 'Page or file already exists']);
return;
}
if (!copy($templatePath, $destination)) {
$this->sendJsonResponse(['error' => 'Template copy failed']);
return;
}
$this->logger->write(
"Created new page: $filename from template $template",
$this->authentication->getLoggedInUser()
);
$this->sendJsonResponse([
'ok' => 'DONE_THE_NEEDFUL',
'url' => '/' . ltrim($filename, '/'),
]);
}
/**
* Get list of pages and return as a JSON response
* @return void
*/
private function getPagesAction(): void
{
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($this->documentRoot)
);
$pages = [];
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
$filePath = $file->getRealPath();
// Exclude CMS directory
if (strpos($filePath, $this->documentRoot . 'cms' . DIRECTORY_SEPARATOR) === 0) {
continue;
}
// Include only .html and .htm files
if (preg_match('/\.(html|htm)$/i', $filePath)) {
$relativePath = str_replace($this->documentRoot, '/', $filePath);
$pages[] = [
'name' => $relativePath,
'url' => $relativePath,
];
}
}
$this->sendJsonResponse($pages);
}
/**
* Do the actual save to the filesystem and return back the path
* @param $uri
* @param $content
* @return string
* @throws \Exception
*/
private function saveContent($uri, $content): string
{
$uri = $this->sanitiseUri($uri);
$filePath = $this->resolveUriToFilePath($uri);
if (file_put_contents($filePath, $content) === false) {
throw new \Exception('Failed to save content');
}
$this->logger->write(
"Content for $uri was saved.",
$this->authentication->getLoggedInUser()
);
return $uri;
}
/**
* Identify the file to write to
* @param $uri
* @return string
* @throws \Exception
*/
private function resolveUriToFilePath($uri): string
{
$path = $this->documentRoot . ltrim($uri, '/');
if (is_dir($path)) {
$indexFiles = ['index.html', 'index.htm'];
foreach ($indexFiles as $indexFile) {
if (file_exists($path . $indexFile)) {
$path .= $indexFile;
break;
}
}
if (is_dir($path)) {
throw new \Exception('No default index file found in directory: ' . $uri);
}
} else {
if (!preg_match('/\.(html|htm)$/i', $path)) {
$path .= '.html';
}
}
$realPath = realpath($path);
if (!$realPath || strpos($realPath, $this->documentRoot) !== 0) {
throw new \Exception('Invalid file path');
}
return $realPath;
}
/**
* Sanitise the filename
* @param $filename
* @return string
*/
private function sanitiseFileName($filename): string
{
return preg_replace('/[^a-zA-Z0-9_\-\.]/', '', basename($filename));
}
/**
* Sanitise a URI
* @param $uri
* @return string
*/
private function sanitiseUri($uri): string
{
$uri = filter_var($uri, FILTER_SANITIZE_URL);
return preg_replace('#(\.\./|/\.{2})#', '', $uri);
}
/**
* Ensure that the controller is not being spammed by GET or other rubbish
* @return void
* @throws \Exception
*/
private function ensurePostRequest(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new \Exception('Invalid request method');
}
}
/**
* Send the JSON response
* @param $data
* @return void
*/
private function sendJsonResponse($data): void
{
header('Content-Type: application/json');
echo json_encode($data);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace NeoCMS;
class Logger
{
private $auditEnabled;
private $logsDir;
public function __construct($auditEnabled)
{
$this->auditEnabled = $auditEnabled;
$this->logsDir = __DIR__ . '/../../logs';
$this->logsDir = rtrim($this->logsDir, '/\\') . DIRECTORY_SEPARATOR;
// Ensure the logs directory exists
if (!is_dir($this->logsDir)) {
if (!mkdir($this->logsDir, 0755, true)) {
throw new \Exception('Failed to create logs directory: ' . $this->logsDir);
}
}
}
/**
* Write an entry to the log
* @param $message
* @param $user
* @return void
*/
public function write($message, $user): void
{
if (!$this->auditEnabled) {
return;
}
$logFile = $this->logsDir . date('Y-m-d') . '-audit.txt';
$entry = sprintf(
"[%s]\tUser: %s\t%s\n",
date('Y-m-d H:i:s'),
$user,
trim($message)
);
file_put_contents($logFile, $entry, FILE_APPEND);
}
}