getAll(); $vars = [ 'user' => $app['user'], 'settings' => $app['settings'], 'customCss' => $customCss, ]; $this->render("css.php", $vars); } public function serveCustomCss(string $baseFilename){ global $app; $cssModel = new CssModel($app['db']); $filename = "$baseFilename.css"; Log::debug("Attempting to serve custom css: {$filename}"); // Make sure the file exists in the database $cssRow = $cssModel->getByFilename($filename); if (!$cssRow){ http_response_code(404); $msg = "Custom css file not in database: {$filename}"; Log::error($msg); Session::setFlashMessage('error', $msg); return; } // Make sure the file exists on the filesystem and is readable $filePath = CSS_UPLOAD_DIR . "/$filename"; if (!file_exists($filePath) || !is_readable($filePath)) { http_response_code(404); $msg = "Custom css file not found or not readable: {$filePath}"; Log::error($msg); Session::setFlashMessage('error', $msg); return; } // Make sure the file has a .css extension $ext = strToLower(pathinfo($filename, PATHINFO_EXTENSION)); if($ext != 'css'){ http_response_code(400); $msg = "Invalid file type requested: {$ext}"; Log::error($msg); Session::setFlashMessage('error', $msg); return; } // If we get here, serve the file Log::debug("Serving custom css: {$filename}"); header('Content-type: text/css'); header('Cache-control: public, max-age=3600'); readfile($filePath); exit; } public function serveDefaultCss(){ $filePath = PUBLIC_DIR . '/css/default.css'; Log::debug("Serving default css: {$filePath}"); // Make sure the default CSS file exists and is readable if (!file_exists($filePath) || !is_readable($filePath)) { http_response_code(404); $msg = "Default CSS file not found"; Log::error($msg); Session::setFlashMessage('error', $msg); return; } // Serve the file header('Content-type: text/css'); header('Cache-control: public, max-age=3600'); readfile($filePath); exit; } public function handlePost() { switch ($_POST['action']) { case 'upload': $this->handleUpload(); break; case 'set_theme': $this->handleSetTheme(); break; case 'delete': $this->handleDelete(); break; } // redirect after handling to avoid resubmitting form header('Location: ' . $_SERVER['REQUEST_URI']); exit; } public function handleDelete(): void{ global $app; // Don't try to delete the default theme. if (!$_POST['selectCssFile']){ http_response_code(400); $msg = "Cannot delete default theme."; Log::warning($msg); Session::setFlashMessage('warning', $msg); return; } // Get the data for the selected CSS file $cssId = (int) $_POST['selectCssFile']; $cssModel = new CssModel($app['db']); $cssRow = $cssModel->getById($cssId); // exit if the requested file isn't in the database if (!$cssRow){ http_response_code(400); $msg = "No entry found for css id {$cssId}."; Log::warning($msg); Session::setFlashMessage('warning', $msg); return; } // get the filename $cssFilename = $cssRow["filename"]; // delete the file from the database if (!$cssModel->delete($cssId)){ http_response_code(500); $msg = "Error deleting theme {$cssId}."; Log::error($msg); Session::setFlashMessage('error', $msg); return; } // 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); $msg = "CSS file not found: {$filePath}"; Log::error($msg); Session::setFlashMessage('error', $msg); return; } // Delete the file if (!unlink($filePath)){ http_response_code(500); $msg = "Error deleting file: {$filePath}"; Log::error($msg); Session::setFlashMessage('error', $msg); return; } // Set the theme back to default try { $app['settings']->cssId = null; $app['settings'] = $app['settings']->save(); $msg = "Theme {$cssFilename} deleted."; Log::debug($msg); Session::setFlashMessage('success', $msg); } catch (Exception $e) { $msg = "Failed to update config after deleting theme."; Log::error($msg . ' ' . $e->getMessage()); Session::setFlashMessage('error', $msg); } } private function handleSetTheme(): void { global $app; try { if ($_POST['selectCssFile']){ // Set custom theme $app['settings']->cssId = (int)($_POST['selectCssFile']); } else { // Set default theme $app['settings']->cssId = null; } // Update the site theme $app['settings'] = $app['settings']->save(); Session::setFlashMessage('success', 'Theme applied.'); } catch (Exception $e) { Log::error("Failed to save theme setting: " . $e->getMessage()); Session::setFlashMessage('error', 'Failed to apply theme'); } } private function handleUpload(): void { 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); // 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 global $app; $cssModel = new CssModel($app['db']); $cssModel->save($safeFilename, $description); // Set success flash message Session::setFlashMessage('success', 'Theme uploaded as ' . $safeFilename); } catch (Exception $e) { // Set error flash message Session::setFlashMessage('error', 'Upload exception: ' . $e->getMessage()); } } private function validateCssContent($content): void { // 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): void { // Check for suspicious patterns $suspiciousPatterns = [ '/javascript:/i', '/vbscript:/i', '/data:.*base64/i', '/