Handle CSS and Validation emoji errors so users get descriptive messages and are able to return to the application. Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/71 Co-authored-by: Greg Sarjeant <greg@subcultureofone.org> Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
		
			
				
	
	
		
			323 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			323 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| declare(strict_types=1);
 | |
| 
 | |
| class CssController extends Controller {
 | |
|     public function index() {
 | |
|         global $app;
 | |
|         $cssModel = new CssModel($app['db']);
 | |
|         $customCss = $cssModel->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',
 | |
|             '/<script/i',
 | |
|             '/eval\s*\(/i',
 | |
|             '/expression\s*\(/i',
 | |
|             '/behavior\s*:/i',
 | |
|             '/-moz-binding/i',
 | |
|             '/\\\00/i', // null bytes
 | |
|         ];
 | |
| 
 | |
|         foreach ($suspiciousPatterns as $pattern) {
 | |
|             if (preg_match($pattern, $content)) {
 | |
|                 throw new Exception('Malicious content detected in CSS file');
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Check filename for suspicious characters
 | |
|         if (preg_match('/[<>:"\/\\|?*\x00-\x1f]/', $fileName)) {
 | |
|             throw new Exception('Filename contains invalid characters');
 | |
|         }
 | |
| 
 | |
|         // Check for excessively long lines (potential DoS)
 | |
|         $lines = explode("\n", $content);
 | |
|         foreach ($lines as $line) {
 | |
|             if (strlen($line) > 10000) {  // 10KB per line limit
 | |
|                 throw new Exception('CSS file contains excessively long lines');
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Check for excessive nesting or selectors (potential DoS)
 | |
|         if (substr_count($content, '{') > 1000) {
 | |
|             throw new Exception('CSS file contains too many rules');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private function generateSafeFileName($originalName): string {
 | |
|         // Remove path information and dangerous characters
 | |
|         $fileName = basename($originalName);
 | |
|         $fileName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $fileName);
 | |
| 
 | |
|         return $fileName;
 | |
|     }
 | |
| }
 |