tkr/src/Controller/CssController/CssController.php
Greg Sarjeant dbd27b266d Gracefully handle validation errors in CSS and Emoji pages. (#71)
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>
2025-08-14 19:44:41 +00:00

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;
}
}