From 4f5ea22dfd13c8de0145513cb84a0c8593631c3e Mon Sep 17 00:00:00 2001 From: Greg Sarjeant <1686767+gsarjeant@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:00:18 -0400 Subject: [PATCH] Add CSS upload. Refactor templates. General cleanup. --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 2 + .../nginx/docker-compose.yml | 2 +- {http_config => examples}/nginx/folder.conf | 12 +- {http_config => examples}/nginx/root.conf | 0 public/css/tkr.css | 59 ++-- public/index.php | 14 +- .../AdminController/AdminController.php | 6 +- src/Controller/Controller.php | 12 +- .../CssController/CssController.php | 257 ++++++++++++++++++ .../FeedController/FeedController.php | 11 + src/Framework/Util/Util.php | 31 ++- .../{Config.php => ConfigModel.php} | 22 +- src/Model/CssModel/CssModel.php | 46 ++++ storage/.gitkeep | 0 templates/main.php | 20 ++ templates/mood.php | 13 - templates/{ => partials}/admin.php | 41 +-- templates/partials/css.php | 62 +++++ templates/partials/head.php | 5 - templates/{ => partials}/home.php | 9 - templates/{ => partials}/login.php | 9 - templates/partials/mood.php | 3 + templates/partials/navbar.php | 1 + templates/setup.php | 68 ----- templates/tick.php | 2 +- 26 files changed, 500 insertions(+), 207 deletions(-) create mode 100644 .DS_Store rename docker-compose.yml => examples/nginx/docker-compose.yml (88%) rename {http_config => examples}/nginx/folder.conf (80%) rename {http_config => examples}/nginx/root.conf (100%) create mode 100644 src/Controller/CssController/CssController.php rename src/Model/ConfigModel/{Config.php => ConfigModel.php} (71%) create mode 100644 src/Model/CssModel/CssModel.php delete mode 100755 storage/.gitkeep create mode 100644 templates/main.php delete mode 100644 templates/mood.php rename templates/{ => partials}/admin.php (64%) create mode 100644 templates/partials/css.php delete mode 100644 templates/partials/head.php rename templates/{ => partials}/home.php (88%) rename templates/{ => partials}/login.php (77%) create mode 100644 templates/partials/mood.php delete mode 100644 templates/setup.php diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ca27c4aec99ac439b68f9dd0f0bdca84902861ea GIT binary patch literal 6148 zcmeHKF-`+P474El&aT(9 ztDWL}HZxzo@9xbOW;TZt?Z{zj+^0|Msv<;>Gu~`#FT2BbzZoXkmjmS9*a5s(d$KS5 zVUHufZ$0De&5&D{&r?(iNC7Dz1*Cu!_+0@OSlIkFQKJ-)0#e{p0e&AEoY)J;#Q1bz zh!y}iLpTic=p}%S0bnm26A^)VQh`bJYB4s&5a{~zE#^#A81t)zey_*V+}Y_(o3@uaG)lgF{vHuxi)IUjHu=0U*_ lu;yGY8s#yR$T;g}e7#Dfmh&j59iNrAss-~_0V8^izr literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index a8d3ba8..2629521 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ *.txt init_complete +storage/upload/css +scratch \ No newline at end of file diff --git a/docker-compose.yml b/examples/nginx/docker-compose.yml similarity index 88% rename from docker-compose.yml rename to examples/nginx/docker-compose.yml index 9694866..0122879 100644 --- a/docker-compose.yml +++ b/examples/nginx/docker-compose.yml @@ -6,7 +6,7 @@ services: - "80:80" volumes: - ./public:/var/www/html/tkr/public - - ./http_config/nginx/folder.conf:/etc/nginx/conf.d/default.conf + - ./examples/nginx/folder.conf:/etc/nginx/conf.d/default.conf depends_on: - php restart: unless-stopped diff --git a/http_config/nginx/folder.conf b/examples/nginx/folder.conf similarity index 80% rename from http_config/nginx/folder.conf rename to examples/nginx/folder.conf index ecf62fe..9416cf8 100644 --- a/http_config/nginx/folder.conf +++ b/examples/nginx/folder.conf @@ -23,9 +23,13 @@ server { index index.php; # Cache static files - # Note that I don't actually serve most of this (just js and css to start) - # but including them all will let caching work later if I add images or something - location ~* ^/tkr/.+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + # Note that I don't actually serve most of this (just css) + # but this prevents requests for static content from getting to the PHP handler. + # + # I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app. + # That lets me store uploaded content outside of the document root, + # so it isn't served directly. + location ~* ^/tkr/(?!css/custom/).+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; try_files $uri =404; @@ -70,7 +74,7 @@ server { } # Deny access to sensitive directories - location ~ ^/tkr/(storage|src|templates|vendor|config) { + location ~ ^/tkr/(storage|src|templates|vendor|uploads|config) { deny all; return 404; } diff --git a/http_config/nginx/root.conf b/examples/nginx/root.conf similarity index 100% rename from http_config/nginx/root.conf rename to examples/nginx/root.conf diff --git a/public/css/tkr.css b/public/css/tkr.css index 6d92079..a5aa491 100644 --- a/public/css/tkr.css +++ b/public/css/tkr.css @@ -126,10 +126,7 @@ input[type="file"]:focus { box-shadow: 0 0 0 3px var(--shadow-primary); } -/* Submit buttons */ -/* Stop deleting this block */ -input[type="submit"], -button[type="submit"] { +button { padding: 10px 20px; border: 1px solid var(--color-primary); border-radius: 6px; @@ -142,21 +139,18 @@ button[type="submit"] { box-sizing: border-box; } -input[type="submit"]:hover, -button[type="submit"]:hover { +button:hover { background-color: var(--color-hover-light); border-color: var(--color-primary-dark); } -input[type="submit"]:focus, -button[type="submit"]:focus { +button:focus { outline: none; border-color: var(--color-primary-darker); box-shadow: 0 0 0 2px var(--shadow-primary); } -input[type="submit"]:active, -button[type="submit"]:active { +button:active { background-color: var(--color-hover-medium); } @@ -169,6 +163,15 @@ label { line-height: 1.2; } +label.description { + font-weight: 300; + color: var(--color-text-muted); + text-align: left; + padding-top: 0; + margin-bottom: 3px; + line-height: 1.2; +} + /* The two common display options for responsive layouts are flex and grid. flex (aka Flexbox) aligns items either horizontally or vertically. @@ -234,33 +237,32 @@ label { grid-column: 1; } -.upload-btn { - background-color: var(--color-primary-lightest); - color: var(--color-text-secondary); - border: 1px solid var(--color-primary); +.delete-btn { + background-color: #fef2f2; + color: #dc2626; + border: 1px solid #fca5a5; padding: 10px 20px; border-radius: 6px; - font-size: 15px; + font-size: 14px; font-weight: 600; cursor: pointer; - width: 100%; transition: all 0.2s ease; - margin-top: 12px; box-sizing: border-box; } -.upload-btn:hover { - background-color: var(--color-hover-light); - border-color: var(--color-primary-dark); +.delete-btn:hover { + background-color: #fee2e2; + border-color: #f87171; } -.upload-btn:active { - background-color: var(--color-hover-medium); -} - -.upload-btn:focus { +.delete-btn:focus { outline: none; - box-shadow: 0 0 0 2px var(--shadow-primary); + border-color: #dc2626; + box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.1); +} + +.delete-btn:active { + background-color: #fecaca; } .required { @@ -314,6 +316,11 @@ label { padding-top: 10px; /* Match input padding */ margin-bottom: 0; } + + label.description { + padding-top: 10px; + margin-bottom: 0; + } .home-container { grid-template-columns: 1fr 2fr; diff --git a/public/index.php b/public/index.php index 7eeb907..03ea1af 100644 --- a/public/index.php +++ b/public/index.php @@ -19,6 +19,7 @@ define('STORAGE_DIR', APP_ROOT . '/storage'); define('TEMPLATES_DIR', APP_ROOT . '/templates'); define('TICKS_DIR', STORAGE_DIR . '/ticks'); define('DATA_DIR', STORAGE_DIR . '/db'); +define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css'); define('DB_FILE', DATA_DIR . '/tkr.sqlite'); // Load all classes from the src/ directory @@ -61,7 +62,9 @@ function route(string $requestPath, string $requestMethod, array $routeHandlers) $controller = $routeHandler[1]; $methods = $routeHandler[2] ?? ['GET']; - $routePattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $routePattern); + # Only allow valid route and filename characters + # to prevent directory traversal and other attacks + $routePattern = preg_replace('/\{([^}]+)\}/', '([a-zA-Z0-9._-]+)', $routePattern); $routePattern = '#^' . $routePattern . '$#'; if (preg_match($routePattern, $requestPath, $matches)) { @@ -88,19 +91,24 @@ function route(string $requestPath, string $requestMethod, array $routeHandlers) return false; } +// Define the recognized routes. +// Anything else will 404. $routeHandlers = [ ['', 'HomeController'], ['', 'HomeController@handleTick', ['POST']], ['admin', 'AdminController'], ['admin', 'AdminController@handleSave', ['POST']], + ['admin/css', 'CssController'], + ['admin/css', 'CssController@handlePost', ['POST']], + ['feed/rss', 'FeedController@rss'], + ['feed/atom', 'FeedController@atom'], ['login', 'AuthController@showLogin'], ['login', 'AuthController@handleLogin', ['POST']], ['logout', 'AuthController@handleLogout', ['GET', 'POST']], ['mood', 'MoodController'], ['mood', 'MoodController@handleMood', ['POST']], - ['feed/rss', 'FeedController@rss'], - ['feed/atom', 'FeedController@atom'], ['tick/{y}/{m}/{d}/{h}/{i}/{s}', 'TickController'], + ['css/custom/{filename}.css', 'CssController@serveCustomCss'], ]; // Set content type diff --git a/src/Controller/AdminController/AdminController.php b/src/Controller/AdminController/AdminController.php index ad4f78b..f6b8ae7 100644 --- a/src/Controller/AdminController/AdminController.php +++ b/src/Controller/AdminController/AdminController.php @@ -119,11 +119,7 @@ class AdminController extends Controller { ConfigModel::completeSetup(); } - header('Location: ' . $config->basePath . '/admin'); + header('Location: ' . $config->basePath . 'admin'); exit; } - - private function getCustomCss(){ - - } } diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 8d4af6c..b8c59d2 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -1,14 +1,18 @@ $user, + 'config' => $config, + 'customCss' => $customCss, + ]; + + $this->render("css.php", $vars); + } + + public function serveCustomCss(string $baseFilename){ + $cssModel = new CssModel(); + $filename = "$baseFilename.css"; + + $cssRow = $cssModel->getByFilename($filename); + + if (!$cssRow){ + http_response_code(404); + exit("CSS file not found: $filename"); + } + + $filePath = CSS_UPLOAD_DIR . "/$filename"; + + if (!file_exists($filePath) || !is_readable($filePath)) { + http_response_code(404); + exit("CSS file not found: $filePath"); + } + + // This shouldn't be possible, but I'm being extra paranoid + // about user input + $ext = strToLower(pathinfo($filename, PATHINFO_EXTENSION)); + if($ext != 'css'){ + http_response_code(400); + exit("Invalid file type requested: $ext"); + } + + header('Content-type: text/css'); + header('Cache-control: public, max-age=3600'); + + readfile($filePath); + exit; + } + + public function handlePost() { + $config = ConfigModel::load(); + + switch ($_POST['action']) { + case 'upload': + $this->handleUpload(); + break; + case 'set_theme': + $this->handleSetTheme($config); + break; + case 'delete': + $this->handleDelete($config); + break; + } + + header('Location: ' . $config->basePath . 'admin/css'); + exit; + } + + public function handleDelete(ConfigModel $config): void{ + // Don't try to delete the default theme. + if (!$_POST['selectCssFile']){ + http_response_code(400); + exit("Cannot delete default theme"); + } + + // Get the data for the selected CSS file + $cssId = $_POST['selectCssFile']; + $cssModel = new CssModel(); + $cssRow = $cssModel->getById($cssId); + + // exit if the requested file isn't in the database + if (!$cssRow){ + http_response_code(400); + exit("No entry found for css id $cssId"); + } + + // get the filename + $cssFilename = $cssRow["filename"]; + + // delete the file from the database + if (!$cssModel->delete($cssId)){ + http_response_code(400); + exit("Error deleting theme"); + } + + // Build the full path to the file + $filePath = CSS_UPLOAD_DIR . "/$cssFilename"; + + // Exit if the file doesn't exist or isn't readable + if (!file_exists($filePath) || !is_readable($filePath)) { + http_response_code(404); + exit("CSS file not found: $filePath"); + } + + // Delete the file + if (!unlink($filePath)){ + http_response_code(400); + exit("Error deleting file: $filePath"); + } + + // Set the theme back to default + $config->cssId = null; + $config = $config->save(); + } + + private function handleSetTheme(ConfigModel $config) { + if ($_POST['selectCssFile']){ + // Set custom theme + $config->cssId = $_POST['selectCssFile']; + } else { + // Set default theme + $config->cssId = null; + } + + // Update the site theme + $config = $config->save(); + } + + private function handleUpload() { + try { + // Check if file was uploaded + if (!isset($_FILES['uploadCssFile']) || $_FILES['uploadCssFile']['error'] !== UPLOAD_ERR_OK) { + throw new Exception('No file uploaded or upload error occurred'); + } + + $file = $_FILES['uploadCssFile']; + $description = $_POST['description'] ?? ''; + + // Validate file extension + $filename = $file['name']; + $fileExtension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + if ($fileExtension !== 'css') { + throw new Exception('File must have a .css extension'); + } + + // Validate file size (1MB = 1048576 bytes) + $maxSize = 1048576; // 1MB + if ($file['size'] > $maxSize) { + throw new Exception('File size must not exceed 1MB'); + } + + // Read and validate CSS content + $fileContent = file_get_contents($file['tmp_name']); + if ($fileContent === false) { + throw new Exception('Unable to read uploaded file'); + } + + // Validate CSS content + $this->validateCssContent($fileContent); + + // Scan for malicious content + $this->scanForMaliciousContent($fileContent, $filename); + + // Create upload directory if it doesn't exist + Util::verify_storage_dir(CSS_UPLOAD_DIR, true); + + // Generate safe filename + $safeFilename = $this->generateSafeFileName($filename); + $uploadPath = CSS_UPLOAD_DIR . '/' . $safeFilename; + + // Move uploaded file + if (!move_uploaded_file($file['tmp_name'], $uploadPath)) { + throw new Exception('Failed to save uploaded file'); + } + + // Add upload to database + $cssModel = new CssModel(); + $cssModel->save($safeFilename, $description); + + return true; + + } catch (Exception $e) { + return false; + } + } + + private function validateCssContent($content) { + // Remove comments + $content = preg_replace('/\/\*.*?\*\//s', '', $content); + + // Basic CSS validation - check for balanced braces + $openBraces = substr_count($content, '{'); + $closeBraces = substr_count($content, '}'); + + if ($openBraces !== $closeBraces) { + throw new Exception('Invalid CSS: Unbalanced braces detected'); + } + + // Check for basic CSS structure (selector { property: value; }) + if (!preg_match('/[^{}]+\{[^{}]*\}/', $content) && !empty(trim($content))) { + // Allow empty files or files with only @charset, @import, etc. + if (!preg_match('/^\s*(@charset|@import|@media|:root)/i', trim($content))) { + throw new Exception('Invalid CSS: No valid CSS rules found'); + } + } + } + + private function scanForMaliciousContent($content, $fileName) { + // Check for suspicious patterns + $suspiciousPatterns = [ + '/javascript:/i', + '/vbscript:/i', + '/data:.*base64/i', + '/