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