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>
This commit is contained in:
Greg Sarjeant 2025-08-14 19:44:41 +00:00 committed by greg
parent f96616bcef
commit dbd27b266d
5 changed files with 89 additions and 39 deletions

View File

@ -12,6 +12,9 @@
--color-flash-success: darkgreen; --color-flash-success: darkgreen;
--color-flash-success-bg: honeydew; --color-flash-success-bg: honeydew;
--color-flash-success-border-left: forestgreen; --color-flash-success-border-left: forestgreen;
--color-flash-warning: darkgoldenrod;
--color-flash-warning-bg: lightgoldenrodyellow;
--color-flash-warning-border-left: gold;
--color-mood-border: darkslateblue; --color-mood-border: darkslateblue;
--color-mood-hover: lightsteelblue; --color-mood-hover: lightsteelblue;
--color-mood-selected: lightblue; --color-mood-selected: lightblue;
@ -324,6 +327,12 @@ summary:focus,
color: var(--color-flash-error); color: var(--color-flash-error);
} }
.flash-warning {
background-color: var(--color-flash-warning-bg);
border-left-color: var(--color-flash-warning-border-left);
color: var(--color-flash-warning);
}
.fieldset-items { .fieldset-items {
margin-bottom: 14px; margin-bottom: 14px;
display: grid; display: grid;

View File

@ -26,24 +26,30 @@ class CssController extends Controller {
$cssRow = $cssModel->getByFilename($filename); $cssRow = $cssModel->getByFilename($filename);
if (!$cssRow){ if (!$cssRow){
http_response_code(404); http_response_code(404);
Log::error("Custom css file not in database: {$filename}"); $msg = "Custom css file not in database: {$filename}";
exit; Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Make sure the file exists on the filesystem and is readable // Make sure the file exists on the filesystem and is readable
$filePath = CSS_UPLOAD_DIR . "/$filename"; $filePath = CSS_UPLOAD_DIR . "/$filename";
if (!file_exists($filePath) || !is_readable($filePath)) { if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404); http_response_code(404);
Log::error("Custom css file not found or not readable: $filePath"); $msg = "Custom css file not found or not readable: {$filePath}";
exit; Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Make sure the file has a .css extension // Make sure the file has a .css extension
$ext = strToLower(pathinfo($filename, PATHINFO_EXTENSION)); $ext = strToLower(pathinfo($filename, PATHINFO_EXTENSION));
if($ext != 'css'){ if($ext != 'css'){
http_response_code(400); http_response_code(400);
Log::error("Invalid file type requested: $ext"); $msg = "Invalid file type requested: {$ext}";
exit; Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// If we get here, serve the file // If we get here, serve the file
@ -61,8 +67,11 @@ class CssController extends Controller {
// Make sure the default CSS file exists and is readable // Make sure the default CSS file exists and is readable
if (!file_exists($filePath) || !is_readable($filePath)) { if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404); http_response_code(404);
Log::error("Default CSS file not found"); $msg = "Default CSS file not found";
exit; Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Serve the file // Serve the file
@ -96,7 +105,10 @@ class CssController extends Controller {
// Don't try to delete the default theme. // Don't try to delete the default theme.
if (!$_POST['selectCssFile']){ if (!$_POST['selectCssFile']){
http_response_code(400); http_response_code(400);
exit("Cannot delete default theme"); $msg = "Cannot delete default theme.";
Log::warning($msg);
Session::setFlashMessage('warning', $msg);
return;
} }
// Get the data for the selected CSS file // Get the data for the selected CSS file
@ -107,7 +119,10 @@ class CssController extends Controller {
// exit if the requested file isn't in the database // exit if the requested file isn't in the database
if (!$cssRow){ if (!$cssRow){
http_response_code(400); http_response_code(400);
exit("No entry found for css id $cssId"); $msg = "No entry found for css id {$cssId}.";
Log::warning($msg);
Session::setFlashMessage('warning', $msg);
return;
} }
// get the filename // get the filename
@ -115,8 +130,11 @@ class CssController extends Controller {
// delete the file from the database // delete the file from the database
if (!$cssModel->delete($cssId)){ if (!$cssModel->delete($cssId)){
http_response_code(400); http_response_code(500);
exit("Error deleting theme"); $msg = "Error deleting theme {$cssId}.";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Build the full path to the file // Build the full path to the file
@ -125,27 +143,36 @@ class CssController extends Controller {
// Exit if the file doesn't exist or isn't readable // Exit if the file doesn't exist or isn't readable
if (!file_exists($filePath) || !is_readable($filePath)) { if (!file_exists($filePath) || !is_readable($filePath)) {
http_response_code(404); http_response_code(404);
exit("CSS file not found: $filePath"); $msg = "CSS file not found: {$filePath}";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Delete the file // Delete the file
if (!unlink($filePath)){ if (!unlink($filePath)){
http_response_code(400); http_response_code(500);
exit("Error deleting file: $filePath"); $msg = "Error deleting file: {$filePath}";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return;
} }
// Set the theme back to default // Set the theme back to default
try { try {
$app['settings']->cssId = null; $app['settings']->cssId = null;
$app['settings'] = $app['settings']->save(); $app['settings'] = $app['settings']->save();
Session::setFlashMessage('success', 'Theme ' . $cssFilename . ' deleted.'); $msg = "Theme {$cssFilename} deleted.";
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Failed to update config after deleting theme: " . $e->getMessage()); $msg = "Failed to update config after deleting theme.";
Session::setFlashMessage('error', 'Theme deleted but failed to update settings'); Log::error($msg . ' ' . $e->getMessage());
Session::setFlashMessage('error', $msg);
} }
} }
private function handleSetTheme() { private function handleSetTheme(): void {
global $app; global $app;
try { try {
@ -166,7 +193,7 @@ class CssController extends Controller {
} }
} }
private function handleUpload() { private function handleUpload(): void {
try { try {
// Check if file was uploaded // Check if file was uploaded
if (!isset($_FILES['uploadCssFile']) || $_FILES['uploadCssFile']['error'] !== UPLOAD_ERR_OK) { if (!isset($_FILES['uploadCssFile']) || $_FILES['uploadCssFile']['error'] !== UPLOAD_ERR_OK) {
@ -221,12 +248,11 @@ class CssController extends Controller {
} catch (Exception $e) { } catch (Exception $e) {
// Set error flash message // Set error flash message
// Todo - don't do a global catch like this. Subclass Exception.
Session::setFlashMessage('error', 'Upload exception: ' . $e->getMessage()); Session::setFlashMessage('error', 'Upload exception: ' . $e->getMessage());
} }
} }
private function validateCssContent($content) { private function validateCssContent($content): void {
// Remove comments // Remove comments
$content = preg_replace('/\/\*.*?\*\//s', '', $content); $content = preg_replace('/\/\*.*?\*\//s', '', $content);
@ -247,7 +273,7 @@ class CssController extends Controller {
} }
} }
private function scanForMaliciousContent($content, $fileName) { private function scanForMaliciousContent($content, $fileName): void {
// Check for suspicious patterns // Check for suspicious patterns
$suspiciousPatterns = [ $suspiciousPatterns = [
'/javascript:/i', '/javascript:/i',
@ -286,12 +312,11 @@ class CssController extends Controller {
} }
} }
private function generateSafeFileName($originalName) { private function generateSafeFileName($originalName): string {
// Remove path information and dangerous characters // Remove path information and dangerous characters
$fileName = basename($originalName); $fileName = basename($originalName);
$fileName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $fileName); $fileName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $fileName);
return $fileName; return $fileName;
} }
} }

View File

@ -10,9 +10,10 @@ declare(strict_types=1);
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiList = $emojiModel->getAll(); $emojiList = $emojiModel->getAll();
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Failed to load emoji list: " . $e->getMessage());
$emojiList = []; $emojiList = [];
Session::setFlashMessage('error', 'Failed to load custom emoji'); $msg = "Failed to load emoji list.";
Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
} }
$vars = [ $vars = [
@ -49,23 +50,31 @@ declare(strict_types=1);
// TODO - log a warning if mbstring isn't loaded // TODO - log a warning if mbstring isn't loaded
$charCount = mb_strlen($emoji, 'UTF-8'); $charCount = mb_strlen($emoji, 'UTF-8');
if ($charCount !== 1) { if ($charCount !== 1) {
// TODO - handle error $msg = "Emoji must be a single UTF-8 encoded character.";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false; return false;
} }
} else {
Log::warning("mbstring extension not loaded. Skipping emoji character count validation.");
} }
// Validate the emoji is actually an emoji // Validate the emoji is actually an emoji
$emojiPattern = '/^[\x{1F000}-\x{1F9FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{1F600}-\x{1F64F}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F1E0}-\x{1F1FF}\x{1F900}-\x{1F9FF}\x{1FA70}-\x{1FAFF}]$/u'; $emojiPattern = '/^[\x{1F000}-\x{1F9FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{1F600}-\x{1F64F}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F1E0}-\x{1F1FF}\x{1F900}-\x{1F9FF}\x{1FA70}-\x{1FAFF}]$/u';
if (!preg_match($emojiPattern, $emoji)) { if (!preg_match($emojiPattern, $emoji)) {
// TODO - handle error $msg = "Character is not a valid emoji.";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false; return false;
} }
// emojis should have more bytes than characters // emojis should have more bytes than characters
$byteCount = strlen($emoji); $byteCount = strlen($emoji);
if ($byteCount <= 1) { if ($byteCount <= 1) {
// TODO - handle error $msg = "Character is not a valid emoji (too few bytes).";
Log::error($msg);
Session::setFlashMessage('error', $msg);
return false; return false;
} }
@ -76,7 +85,7 @@ declare(strict_types=1);
global $app; global $app;
if (!$this->isValidEmoji($emoji)){ if (!$this->isValidEmoji($emoji)){
Session::setFlashMessage('error', 'Invalid emoji format'); // exceptions are handled in isValidEmoji
return; return;
} }
@ -84,10 +93,14 @@ declare(strict_types=1);
try { try {
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiModel->add($emoji, $description); $emojiModel->add($emoji, $description);
Session::setFlashMessage('success', 'Emoji added successfully'); $msg = "Emoji added: {$emoji} - {$description}";
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Failed to add emoji: " . $e->getMessage()); $msg = "Failed to add emoji.";
Session::setFlashMessage('error', 'Failed to add emoji'); Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
} }
} }
@ -100,10 +113,14 @@ declare(strict_types=1);
try { try {
$emojiModel = new EmojiModel($app['db']); $emojiModel = new EmojiModel($app['db']);
$emojiModel->delete($ids); $emojiModel->delete($ids);
Session::setFlashMessage('success', 'Emoji deleted successfully'); $msg = "Emoji deleted.";
Log::debug($msg);
Session::setFlashMessage('success', $msg);
} catch (Exception $e) { } catch (Exception $e) {
Log::error("Failed to delete emoji: " . $e->getMessage()); $msg = "Failed to delete emoji.";
Session::setFlashMessage('error', 'Failed to delete emoji'); Log::error($msg . " " . $e->getMessage());
Session::setFlashMessage('error', $msg);
} }
} }
} }

View File

@ -61,6 +61,6 @@ class TickController extends Controller{
// Redirect back to homepage // Redirect back to homepage
header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, '')); header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, ''));
exit(); exit;
} }
} }

View File

@ -67,7 +67,6 @@ class Session {
// valid types are: // valid types are:
// - success // - success
// - error // - error
// - info
// - warning // - warning
public static function setFlashMessage(string $type, string $message): void { public static function setFlashMessage(string $type, string $message): void {
if (!isset($_SESSION['flash'][$type])){ if (!isset($_SESSION['flash'][$type])){