Add CSS upload. Refactor templates. General cleanup.
This commit is contained in:
		
							parent
							
								
									f8e7151e6f
								
							
						
					
					
						commit
						4f5ea22dfd
					
				
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -4,3 +4,5 @@ | ||||
| *.txt | ||||
| 
 | ||||
| init_complete | ||||
| storage/upload/css | ||||
| scratch | ||||
| @ -6,7 +6,7 @@ services: | ||||
|       - "80:80" | ||||
|     volumes: | ||||
|       - ./public:/var/www/html/tkr/public | ||||
|       - ./http_config/nginx/folder.conf:/etc/nginx/conf.d/default.conf | ||||
|       - ./examples/nginx/folder.conf:/etc/nginx/conf.d/default.conf | ||||
|     depends_on: | ||||
|       - php | ||||
|     restart: unless-stopped | ||||
| @ -23,9 +23,13 @@ server { | ||||
|         index index.php; | ||||
| 
 | ||||
|         # Cache static files | ||||
|         # Note that I don't actually serve most of this (just js and css to start) | ||||
|         # but including them all will let caching work later if I add images or something | ||||
|         location ~* ^/tkr/.+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { | ||||
|         # Note that I don't actually serve most of this (just css) | ||||
|         # but this prevents requests for static content from getting to the PHP handler. | ||||
|         # | ||||
|         # I've excluded /css/custom so that requests for uploaded css can be handled by the PHP app. | ||||
|         # That lets me store uploaded content outside of the document root, | ||||
|         # so it isn't served directly. | ||||
|         location ~* ^/tkr/(?!css/custom/).+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { | ||||
|             expires 1y; | ||||
|             add_header Cache-Control "public, immutable"; | ||||
|             try_files $uri =404; | ||||
| @ -70,7 +74,7 @@ server { | ||||
|     } | ||||
| 
 | ||||
|     # Deny access to sensitive directories | ||||
|     location ~ ^/tkr/(storage|src|templates|vendor|config) { | ||||
|     location ~ ^/tkr/(storage|src|templates|vendor|uploads|config) { | ||||
|         deny all; | ||||
|         return 404; | ||||
|     } | ||||
| @ -126,10 +126,7 @@ input[type="file"]:focus { | ||||
|     box-shadow: 0 0 0 3px var(--shadow-primary); | ||||
| } | ||||
| 
 | ||||
| /* Submit buttons */ | ||||
| /* Stop deleting this block */ | ||||
| input[type="submit"], | ||||
| button[type="submit"] { | ||||
| button { | ||||
|     padding: 10px 20px; | ||||
|     border: 1px solid var(--color-primary); | ||||
|     border-radius: 6px; | ||||
| @ -142,21 +139,18 @@ button[type="submit"] { | ||||
|     box-sizing: border-box; | ||||
| } | ||||
| 
 | ||||
| input[type="submit"]:hover, | ||||
| button[type="submit"]:hover { | ||||
| button:hover { | ||||
|     background-color: var(--color-hover-light); | ||||
|     border-color: var(--color-primary-dark); | ||||
| } | ||||
| 
 | ||||
| input[type="submit"]:focus, | ||||
| button[type="submit"]:focus { | ||||
| button:focus { | ||||
|     outline: none; | ||||
|     border-color: var(--color-primary-darker); | ||||
|     box-shadow: 0 0 0 2px var(--shadow-primary); | ||||
| } | ||||
| 
 | ||||
| input[type="submit"]:active, | ||||
| button[type="submit"]:active { | ||||
| button:active { | ||||
|     background-color: var(--color-hover-medium); | ||||
| } | ||||
| 
 | ||||
| @ -169,6 +163,15 @@ label { | ||||
|     line-height: 1.2; | ||||
| } | ||||
| 
 | ||||
| label.description { | ||||
|     font-weight: 300; | ||||
|     color: var(--color-text-muted); | ||||
|     text-align: left; | ||||
|     padding-top: 0; | ||||
|     margin-bottom: 3px; | ||||
|     line-height: 1.2; | ||||
| } | ||||
| 
 | ||||
| /* | ||||
|     The two common display options for responsive layouts are flex and grid. | ||||
|         flex (aka Flexbox) aligns items either horizontally or vertically. | ||||
| @ -234,33 +237,32 @@ label { | ||||
|     grid-column: 1; | ||||
| } | ||||
| 
 | ||||
| .upload-btn { | ||||
|     background-color: var(--color-primary-lightest); | ||||
|     color: var(--color-text-secondary); | ||||
|     border: 1px solid var(--color-primary); | ||||
| .delete-btn { | ||||
|     background-color: #fef2f2; | ||||
|     color: #dc2626; | ||||
|     border: 1px solid #fca5a5; | ||||
|     padding: 10px 20px; | ||||
|     border-radius: 6px; | ||||
|     font-size: 15px; | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     cursor: pointer; | ||||
|     width: 100%; | ||||
|     transition: all 0.2s ease; | ||||
|     margin-top: 12px; | ||||
|     box-sizing: border-box; | ||||
| } | ||||
| 
 | ||||
| .upload-btn:hover { | ||||
|     background-color: var(--color-hover-light); | ||||
|     border-color: var(--color-primary-dark); | ||||
| .delete-btn:hover { | ||||
|     background-color: #fee2e2; | ||||
|     border-color: #f87171; | ||||
| } | ||||
| 
 | ||||
| .upload-btn:active { | ||||
|     background-color: var(--color-hover-medium); | ||||
| } | ||||
| 
 | ||||
| .upload-btn:focus { | ||||
| .delete-btn:focus { | ||||
|     outline: none; | ||||
|     box-shadow: 0 0 0 2px var(--shadow-primary); | ||||
|     border-color: #dc2626; | ||||
|     box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.1); | ||||
| } | ||||
| 
 | ||||
| .delete-btn:active { | ||||
|     background-color: #fecaca; | ||||
| } | ||||
| 
 | ||||
| .required { | ||||
| @ -314,6 +316,11 @@ label { | ||||
|         padding-top: 10px; /* Match input padding */ | ||||
|         margin-bottom: 0; | ||||
|     } | ||||
| 
 | ||||
|     label.description { | ||||
|         padding-top: 10px; | ||||
|         margin-bottom: 0; | ||||
|     } | ||||
|              | ||||
|     .home-container { | ||||
|         grid-template-columns: 1fr 2fr; | ||||
|  | ||||
| @ -19,6 +19,7 @@ define('STORAGE_DIR', APP_ROOT . '/storage'); | ||||
| define('TEMPLATES_DIR', APP_ROOT . '/templates'); | ||||
| define('TICKS_DIR', STORAGE_DIR . '/ticks'); | ||||
| define('DATA_DIR', STORAGE_DIR . '/db'); | ||||
| define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css'); | ||||
| define('DB_FILE', DATA_DIR . '/tkr.sqlite'); | ||||
| 
 | ||||
| // Load all classes from the src/ directory
 | ||||
| @ -61,7 +62,9 @@ function route(string $requestPath, string $requestMethod, array $routeHandlers) | ||||
|         $controller = $routeHandler[1]; | ||||
|         $methods = $routeHandler[2] ?? ['GET']; | ||||
| 
 | ||||
|         $routePattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $routePattern); | ||||
|         # Only allow valid route and filename characters
 | ||||
|         # to prevent directory traversal and other attacks
 | ||||
|         $routePattern = preg_replace('/\{([^}]+)\}/', '([a-zA-Z0-9._-]+)', $routePattern); | ||||
|         $routePattern = '#^' . $routePattern . '$#'; | ||||
| 
 | ||||
|         if (preg_match($routePattern, $requestPath, $matches)) { | ||||
| @ -88,19 +91,24 @@ function route(string $requestPath, string $requestMethod, array $routeHandlers) | ||||
|     return false; | ||||
| } | ||||
| 
 | ||||
| // Define the recognized routes.
 | ||||
| // Anything else will 404.
 | ||||
| $routeHandlers = [ | ||||
|     ['', 'HomeController'], | ||||
|     ['', 'HomeController@handleTick', ['POST']], | ||||
|     ['admin', 'AdminController'], | ||||
|     ['admin', 'AdminController@handleSave', ['POST']], | ||||
|     ['admin/css', 'CssController'], | ||||
|     ['admin/css', 'CssController@handlePost', ['POST']], | ||||
|     ['feed/rss', 'FeedController@rss'], | ||||
|     ['feed/atom', 'FeedController@atom'], | ||||
|     ['login', 'AuthController@showLogin'], | ||||
|     ['login', 'AuthController@handleLogin', ['POST']], | ||||
|     ['logout', 'AuthController@handleLogout', ['GET', 'POST']], | ||||
|     ['mood', 'MoodController'], | ||||
|     ['mood', 'MoodController@handleMood', ['POST']], | ||||
|     ['feed/rss', 'FeedController@rss'], | ||||
|     ['feed/atom', 'FeedController@atom'], | ||||
|     ['tick/{y}/{m}/{d}/{h}/{i}/{s}', 'TickController'], | ||||
|     ['css/custom/{filename}.css', 'CssController@serveCustomCss'], | ||||
| ]; | ||||
| 
 | ||||
| // Set content type
 | ||||
|  | ||||
| @ -119,11 +119,7 @@ class AdminController extends Controller { | ||||
|             ConfigModel::completeSetup(); | ||||
|         } | ||||
| 
 | ||||
|         header('Location: ' . $config->basePath . '/admin'); | ||||
|         header('Location: ' . $config->basePath . 'admin'); | ||||
|         exit; | ||||
|     } | ||||
| 
 | ||||
|     private function getCustomCss(){ | ||||
| 
 | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,14 +1,18 @@ | ||||
| <?php | ||||
| class Controller { | ||||
|     protected function render(string $templateFile, array $vars = []) { | ||||
|         $templatePath = TEMPLATES_DIR . "/" . $templateFile; | ||||
|     // Renders the requested template inside templates/main/php
 | ||||
|     protected function render(string $childTemplateFile, array $vars = []) { | ||||
|         $templatePath = TEMPLATES_DIR . "/main.php"; | ||||
|         $childTemplatePath = TEMPLATES_DIR . "/partials/" . $childTemplateFile; | ||||
| 
 | ||||
|         if (!file_exists($templatePath)) { | ||||
|             throw new RuntimeException("Template not found: $templatePath"); | ||||
|         } | ||||
| 
 | ||||
|         // PHP scoping
 | ||||
|         // extract the variables from $vars into the local scope.
 | ||||
|         if (!file_exists($childTemplatePath)) { | ||||
|             throw new RuntimeException("Template not found: $childTemplatePath"); | ||||
|         } | ||||
| 
 | ||||
|         extract($vars, EXTR_SKIP); | ||||
|         include $templatePath; | ||||
|     }   | ||||
|  | ||||
							
								
								
									
										257
									
								
								src/Controller/CssController/CssController.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										257
									
								
								src/Controller/CssController/CssController.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,257 @@ | ||||
| <?php | ||||
| 
 | ||||
| class CssController extends Controller { | ||||
|     public function index() { | ||||
|         $config = ConfigModel::load(); | ||||
|         $user = UserModel::load(); | ||||
|         $customCss = CssModel::load(); | ||||
| 
 | ||||
|         $vars = [ | ||||
|             'user' => $user, | ||||
|             'config' => $config, | ||||
|             'customCss' => $customCss, | ||||
|         ]; | ||||
| 
 | ||||
|         $this->render("css.php", $vars); | ||||
|     } | ||||
| 
 | ||||
|     public function serveCustomCss(string $baseFilename){ | ||||
|         $cssModel = new CssModel(); | ||||
|         $filename = "$baseFilename.css"; | ||||
| 
 | ||||
|         $cssRow = $cssModel->getByFilename($filename); | ||||
| 
 | ||||
|         if (!$cssRow){ | ||||
|             http_response_code(404); | ||||
|             exit("CSS file not found: $filename"); | ||||
|         } | ||||
| 
 | ||||
|         $filePath = CSS_UPLOAD_DIR . "/$filename"; | ||||
| 
 | ||||
|         if (!file_exists($filePath) || !is_readable($filePath)) { | ||||
|             http_response_code(404); | ||||
|             exit("CSS file not found: $filePath"); | ||||
|         } | ||||
| 
 | ||||
|         // This shouldn't be possible, but I'm being extra paranoid
 | ||||
|         // about user input
 | ||||
|         $ext = strToLower(pathinfo($filename, PATHINFO_EXTENSION)); | ||||
|         if($ext != 'css'){ | ||||
|             http_response_code(400); | ||||
|             exit("Invalid file type requested: $ext"); | ||||
|         } | ||||
| 
 | ||||
|         header('Content-type: text/css'); | ||||
|         header('Cache-control: public, max-age=3600'); | ||||
| 
 | ||||
|         readfile($filePath); | ||||
|         exit; | ||||
|     } | ||||
| 
 | ||||
|     public function handlePost() { | ||||
|         $config = ConfigModel::load(); | ||||
| 
 | ||||
|         switch ($_POST['action']) { | ||||
|         case 'upload': | ||||
|             $this->handleUpload(); | ||||
|             break; | ||||
|         case 'set_theme': | ||||
|             $this->handleSetTheme($config); | ||||
|             break; | ||||
|         case 'delete': | ||||
|             $this->handleDelete($config); | ||||
|             break; | ||||
|         } | ||||
| 
 | ||||
|         header('Location: ' . $config->basePath . 'admin/css'); | ||||
|         exit; | ||||
|     } | ||||
| 
 | ||||
|     public function handleDelete(ConfigModel $config): void{ | ||||
|         // Don't try to delete the default theme.
 | ||||
|         if (!$_POST['selectCssFile']){ | ||||
|             http_response_code(400); | ||||
|             exit("Cannot delete default theme"); | ||||
|         } | ||||
|          | ||||
|         // Get the data for the selected CSS file
 | ||||
|         $cssId = $_POST['selectCssFile']; | ||||
|         $cssModel = new CssModel(); | ||||
|         $cssRow = $cssModel->getById($cssId); | ||||
| 
 | ||||
|         // exit if the requested file isn't in the database
 | ||||
|         if (!$cssRow){ | ||||
|             http_response_code(400); | ||||
|             exit("No entry found for css id $cssId"); | ||||
|         } | ||||
|          | ||||
|         // get the filename
 | ||||
|         $cssFilename = $cssRow["filename"]; | ||||
| 
 | ||||
|         // delete the file from the database
 | ||||
|         if (!$cssModel->delete($cssId)){ | ||||
|             http_response_code(400); | ||||
|             exit("Error deleting theme"); | ||||
|         } | ||||
| 
 | ||||
|         // 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); | ||||
|             exit("CSS file not found: $filePath"); | ||||
|         } | ||||
| 
 | ||||
|         // Delete the file
 | ||||
|         if (!unlink($filePath)){ | ||||
|             http_response_code(400); | ||||
|             exit("Error deleting file: $filePath"); | ||||
|         } | ||||
| 
 | ||||
|         // Set the theme back to default
 | ||||
|         $config->cssId = null; | ||||
|         $config = $config->save(); | ||||
|     } | ||||
| 
 | ||||
|     private function handleSetTheme(ConfigModel $config) { | ||||
|         if ($_POST['selectCssFile']){ | ||||
|             // Set custom theme
 | ||||
|             $config->cssId = $_POST['selectCssFile']; | ||||
|         } else { | ||||
|             // Set default theme
 | ||||
|             $config->cssId = null; | ||||
|         } | ||||
| 
 | ||||
|         // Update the site theme
 | ||||
|         $config = $config->save(); | ||||
|     } | ||||
| 
 | ||||
|     private function handleUpload() { | ||||
|         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); | ||||
| 
 | ||||
|             // Create upload directory if it doesn't exist
 | ||||
|             Util::verify_storage_dir(CSS_UPLOAD_DIR, true); | ||||
| 
 | ||||
|             // 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
 | ||||
|             $cssModel = new CssModel(); | ||||
|             $cssModel->save($safeFilename, $description); | ||||
| 
 | ||||
|             return true; | ||||
| 
 | ||||
|         } catch (Exception $e) { | ||||
|            return false;  | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private function validateCssContent($content) { | ||||
|         // 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) { | ||||
|         // 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) { | ||||
|         // Remove path information and dangerous characters
 | ||||
|         $fileName = basename($originalName); | ||||
|         $fileName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $fileName); | ||||
|          | ||||
|         return $fileName; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -4,6 +4,17 @@ class FeedController extends Controller { | ||||
|     private array $ticks; | ||||
|     private array $vars; | ||||
| 
 | ||||
|     protected function render(string $templateFile, array $vars = []) { | ||||
|         $templatePath = TEMPLATES_DIR . "/" . $templateFile; | ||||
| 
 | ||||
|         if (!file_exists($templatePath)) { | ||||
|             throw new RuntimeException("Template not found: $templatePath"); | ||||
|         } | ||||
| 
 | ||||
|         extract($vars, EXTR_SKIP); | ||||
|         include $templatePath; | ||||
|     } | ||||
| 
 | ||||
|     public function __construct(){ | ||||
|         $this->config = ConfigModel::load(); | ||||
|         $this->ticks = iterator_to_array(TickModel::streamTicks($this->config->itemsPerPage)); | ||||
|  | ||||
| @ -15,7 +15,7 @@ class Util { | ||||
|     } | ||||
| 
 | ||||
|     // For relative time display, compare the stored time to the current time
 | ||||
|     // and display it as "X second/minutes/hours/days etc. "ago
 | ||||
|     // and display it as "X seconds/minutes/hours/days etc." ago
 | ||||
|     public static function relative_time(string $tickTime): string { | ||||
|         $datetime = new DateTime($tickTime); | ||||
|         $now = new DateTime('now', $datetime->getTimezone()); | ||||
| @ -39,7 +39,7 @@ class Util { | ||||
|         return $diff->s . ' second' . ($diff->s != 1 ? 's' : '') . ' ago'; | ||||
|     } | ||||
| 
 | ||||
|     public static function verify_data_dir(string $dir, bool $allow_create = false): void { | ||||
|     public static function verify_storage_dir(string $dir, bool $allow_create = false): void { | ||||
|         if (!is_dir($dir)) { | ||||
|             if ($allow_create) { | ||||
|                 if (!mkdir($dir, 0770, true)) { | ||||
| @ -66,7 +66,7 @@ class Util { | ||||
|     public static function confirm_setup(): void { | ||||
|         $db = Util::get_db(); | ||||
| 
 | ||||
|         // Ensure required tables exist
 | ||||
|         // user table
 | ||||
|         $db->exec("CREATE TABLE IF NOT EXISTS user (
 | ||||
|             id INTEGER PRIMARY KEY, | ||||
|             username TEXT NOT NULL, | ||||
| @ -77,13 +77,22 @@ class Util { | ||||
|             mood TEXT NULL | ||||
|         )");
 | ||||
| 
 | ||||
|         // settings table
 | ||||
|         $db->exec("CREATE TABLE IF NOT EXISTS settings (
 | ||||
|             id INTEGER PRIMARY KEY, | ||||
|             site_title TEXT NOT NULL, | ||||
|             site_description TEXT NULL, | ||||
|             base_url TEXT NOT NULL, | ||||
|             base_path TEXT NOT NULL, | ||||
|             items_per_page INTEGER NOT NULL | ||||
|             items_per_page INTEGER NOT NULL, | ||||
|             css_id INTEGER NULL | ||||
|         )");
 | ||||
| 
 | ||||
|         // css table
 | ||||
|         $db->exec("CREATE TABLE IF NOT EXISTS css (
 | ||||
|             id INTEGER PRIMARY KEY, | ||||
|             filename TEXT NOT NULL, | ||||
|             description TEXT NULL | ||||
|         )");
 | ||||
| 
 | ||||
|         // See if there's any data in the tables
 | ||||
| @ -91,22 +100,14 @@ class Util { | ||||
|         $settings_count = (int) $db->query("SELECT COUNT(*) FROM settings")->fetchColumn(); | ||||
|         $config = ConfigModel::load(); | ||||
| 
 | ||||
|         // If either table has no records and we aren't on /admin
 | ||||
|         // If either table has no records and we aren't on /admin,
 | ||||
|         // redirect to /admin to complete setup
 | ||||
|         if ($user_count === 0 || $settings_count === 0){ | ||||
|             if (basename($_SERVER['PHP_SELF']) !== 'admin'){ | ||||
|                 header('Location: ' . $config->basePath . 'admin'); | ||||
|                 exit; | ||||
|             } | ||||
|         }; | ||||
|         /* | ||||
|          else { | ||||
|             // If setup is complete and we are on setup.php, redirect to index.php.
 | ||||
|             if (basename($_SERVER['PHP_SELF']) === 'admin'){ | ||||
|                 header('Location: ' . $config->basePath); | ||||
|                 exit; | ||||
|             } | ||||
|         }; | ||||
|         */ | ||||
|     } | ||||
| 
 | ||||
|     public static function tick_time_to_tick_path($tickTime){ | ||||
| @ -121,7 +122,7 @@ class Util { | ||||
|     } | ||||
| 
 | ||||
|     public static function get_db(): PDO { | ||||
|         Util::verify_data_dir(DATA_DIR, true); | ||||
|         Util::verify_storage_dir(DATA_DIR, true); | ||||
| 
 | ||||
|         try { | ||||
|             $db = new PDO("sqlite:" . DB_FILE); | ||||
|  | ||||
| @ -7,6 +7,7 @@ class ConfigModel { | ||||
|     public string $basePath = ''; | ||||
|     public int $itemsPerPage = 25; | ||||
|     public string $timezone = 'relative'; | ||||
|     public ?int $cssId; | ||||
| 
 | ||||
|     public static function isFirstSetup(): bool { | ||||
|         return !file_exists(STORAGE_DIR . '/init_complete'); | ||||
| @ -24,7 +25,7 @@ class ConfigModel { | ||||
|         $c->basePath = ($c->basePath === '') ? $init['base_path'] : $c->basePath; | ||||
| 
 | ||||
|         $db = Util::get_db(); | ||||
|         $stmt = $db->query("SELECT site_title, site_description, base_url, base_path, items_per_page FROM settings WHERE id=1"); | ||||
|         $stmt = $db->query("SELECT site_title, site_description, base_url, base_path, items_per_page, css_id FROM settings WHERE id=1"); | ||||
|         $row = $stmt->fetch(PDO::FETCH_ASSOC); | ||||
| 
 | ||||
|         if ($row) { | ||||
| @ -33,20 +34,33 @@ class ConfigModel { | ||||
|             $c->baseUrl = $row['base_url']; | ||||
|             $c->basePath = $row['base_path']; | ||||
|             $c->itemsPerPage = (int) $row['items_per_page']; | ||||
|             $c->cssId = (int) $row['css_id']; | ||||
|         } | ||||
| 
 | ||||
|         return $c; | ||||
|     } | ||||
| 
 | ||||
|     public function customCssFilename() { | ||||
|         if (empty($this->cssId)) { | ||||
|             return null; | ||||
|         } | ||||
|      | ||||
|         // Fetch filename from css table using cssId
 | ||||
|         $cssModel = new CssModel(); | ||||
|         $cssRecord = $cssModel->getById($this->cssId); | ||||
|      | ||||
|         return $cssRecord ? $cssRecord['filename'] : null; | ||||
|     } | ||||
| 
 | ||||
|     public function save(): self { | ||||
|         $db = Util::get_db(); | ||||
| 
 | ||||
|         if (!ConfigModel::isFirstSetup()){ | ||||
|             $stmt = $db->prepare("UPDATE settings SET site_title=?, site_description=?, base_url=?, base_path=?, items_per_page=? WHERE id=1"); | ||||
|             $stmt = $db->prepare("UPDATE settings SET site_title=?, site_description=?, base_url=?, base_path=?, items_per_page=?, css_id=? WHERE id=1"); | ||||
|         } else { | ||||
|             $stmt = $db->prepare("INSERT INTO settings (id, site_title, site_description, base_url, base_path, items_per_page) VALUES (1, ?, ?, ?, ?, ?)"); | ||||
|             $stmt = $db->prepare("INSERT INTO settings (id, site_title, site_description, base_url, base_path, items_per_page, css_id) VALUES (1, ?, ?, ?, ?, ?, ?)"); | ||||
|         } | ||||
|         $stmt->execute([$this->siteTitle, $this->siteDescription, $this->baseUrl, $this->basePath, $this->itemsPerPage]); | ||||
|         $stmt->execute([$this->siteTitle, $this->siteDescription, $this->baseUrl, $this->basePath, $this->itemsPerPage, $this->cssId]); | ||||
| 
 | ||||
|         return self::load(); | ||||
|     } | ||||
							
								
								
									
										46
									
								
								src/Model/CssModel/CssModel.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/Model/CssModel/CssModel.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| <?php | ||||
| class CssModel { | ||||
|     public static function load(): Array { | ||||
|         $db = Util::get_db(); | ||||
|         $stmt = $db->prepare("SELECT id, filename, description FROM css ORDER BY filename"); | ||||
|         $stmt->execute(); | ||||
| 
 | ||||
|         return $stmt->fetchAll(PDO::FETCH_ASSOC); | ||||
|     } | ||||
| 
 | ||||
|     public function getById(int $id): Array{ | ||||
|        $db = Util::get_db(); | ||||
|        $stmt = $db->prepare("SELECT id, filename, description FROM css WHERE id=?"); | ||||
|        $stmt->execute([$id]); | ||||
|        return $stmt->fetch(PDO::FETCH_ASSOC); | ||||
|     } | ||||
| 
 | ||||
|     public function getByFilename(string $filename): Array{ | ||||
|        $db = Util::get_db(); | ||||
|        $stmt = $db->prepare("SELECT id, filename, description FROM css WHERE filename=?"); | ||||
|        $stmt->execute([$filename]); | ||||
|        return $stmt->fetch(PDO::FETCH_ASSOC); | ||||
|     } | ||||
| 
 | ||||
|     public function delete(int $id): bool{ | ||||
|         $db = Util::get_db(); | ||||
|         $stmt = $db->prepare("DELETE FROM css WHERE id=?"); | ||||
|         return $stmt->execute([$id]); | ||||
|     } | ||||
| 
 | ||||
|     public function save(string $filename, ?string $description = null): void { | ||||
|         $db = Util::get_db(); | ||||
| 
 | ||||
|         $stmt = $db->prepare("SELECT COUNT(id) FROM css WHERE filename = ?"); | ||||
|         $stmt->execute([$filename]); | ||||
|         $fileExists = $stmt->fetchColumn(); | ||||
| 
 | ||||
|         if ($fileExists) { | ||||
|             $stmt = $db->prepare("UPDATE css SET description = ? WHERE filename = ?"); | ||||
|         } else { | ||||
|             $stmt = $db->prepare("INSERT INTO css (filename, description) VALUES (?, ?)"); | ||||
|         } | ||||
| 
 | ||||
|         $stmt->execute([$filename, $description]); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										20
									
								
								templates/main.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								templates/main.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| <?php /** @var bool $isLoggedIn */ ?>
 | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var UserModel $user */ ?>
 | ||||
| <?php /** @var string $childTemplateFile */ ?>
 | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|     <head> | ||||
|         <title><?= $config->siteTitle ?></title>
 | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|         <link rel="stylesheet" href="<?= htmlspecialchars($config->basePath) ?>css/tkr.css?v=<?= time() ?>"> | ||||
| <?php if (!empty($config->cssId)): ?>
 | ||||
|         <link rel="stylesheet" href="<?= htmlspecialchars($config->basePath) ?>css/custom/<?= htmlspecialchars($config->customCssFilename()) ?>"> | ||||
| <?php endif; ?>   
 | ||||
|     </head> | ||||
|     <body> | ||||
| <?php include TEMPLATES_DIR . '/partials/navbar.php'?>
 | ||||
| <?php include TEMPLATES_DIR . '/partials/' . $childTemplateFile?>
 | ||||
|     </body> | ||||
| </html> | ||||
| @ -1,13 +0,0 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var string $moodPicker */ ?>
 | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|     <head> | ||||
| <?php include TEMPLATES_DIR . '/partials/head.php'?>
 | ||||
|     </head> | ||||
|     <body> | ||||
| <?php include TEMPLATES_DIR . '/partials/navbar.php'?>
 | ||||
|         <h2>How are you feeling?</h2> | ||||
| <?php echo $moodPicker; ?>
 | ||||
|     </body> | ||||
| </html> | ||||
| @ -1,13 +1,5 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var UserModel $user */ ?>
 | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|     <head> | ||||
| <?php include TEMPLATES_DIR . '/partials/head.php'?>
 | ||||
|     </head> | ||||
|     <body> | ||||
| <?php include TEMPLATES_DIR . '/partials/navbar.php'?>
 | ||||
| <html lang="en"> | ||||
|         <h1>Admin</h1> | ||||
|         <div> | ||||
|             <form method="post"> | ||||
| @ -63,12 +55,6 @@ | ||||
|                             value="<?= $config->itemsPerPage ?>" min="1" max="50" | ||||
|                             required> | ||||
|                     </div> | ||||
|                     <div class="fieldset-items"> | ||||
|                         <label for="setCssFile">Set CSS File</label> | ||||
|                         <select id="setCssFile" name="css_file"> | ||||
|                             <option value="">Default</option> | ||||
|                         </select> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <fieldset> | ||||
|                     <legend>Change password</legend> | ||||
| @ -79,31 +65,6 @@ | ||||
|                         <input type="password" name="confirm_password"> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <fieldset> | ||||
|                     <legend>CSS Upload</legend> | ||||
|                     <div class="fieldset-items"> | ||||
|                         <form action="/upload-css" method="post" enctype="multipart/form-data"> | ||||
|                             <label for="uploadCssFile">Select File to Upload</label> | ||||
|                             <input type="file"  | ||||
|                                    id="uploadCssFile"  | ||||
|                                    name="uploadCssFile"  | ||||
|                                    accept=".css"> | ||||
|                             <div class="file-info"> | ||||
|                                 <strong>File Requirements:</strong><br> | ||||
|                                 • Must be a valid CSS file (.css extension)<br> | ||||
|                                 • Maximum size: 2MB<br> | ||||
|                                 • Will be scanned for malicious content | ||||
|                             </div> | ||||
|                             <label for="description">Description (optional)</label> | ||||
|                             <textarea id="description"  | ||||
|                                       name="description"  | ||||
|                                       placeholder="Describe this CSS file..."></textarea> | ||||
|                             <button type="submit" class="upload-btn">Upload CSS File</button> | ||||
|                         </form> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <button type="submit" class="submit-btn">Save Settings</button> | ||||
|             </form> | ||||
|         </div> | ||||
|     </body> | ||||
| </html> | ||||
|         </div> | ||||
							
								
								
									
										62
									
								
								templates/partials/css.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								templates/partials/css.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,62 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var Array $customCss */ ?>
 | ||||
|         <h1>CSS Management</h1> | ||||
|         <div> | ||||
|             <form action="<?= $config->basePath ?>admin/css" method="post" enctype="multipart/form-data"> | ||||
|                 <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> | ||||
|                 <fieldset> | ||||
|                     <legend>Manage</legend> | ||||
|                     <div class="fieldset-items"> | ||||
|                         <label for="selectCssFile">Select CSS File</label> | ||||
|                         <select id="selectCssFile" name="selectCssFile" value=<?= $config->cssId ?>>
 | ||||
|                             <option value="">Default</option> | ||||
| <?php foreach ($customCss as $cssFile): ?>
 | ||||
|     <?php  | ||||
|         if ($cssFile['id'] == $config->cssId){ | ||||
|             $cssDescription = $cssFile['description']; | ||||
|             $selected = "selected"; | ||||
|         } | ||||
|     ?>
 | ||||
| 
 | ||||
|                             <option value=<?= $cssFile['id'] ?> 
 | ||||
|                                     <?= isset($selected) ? $selected : ""?>>
 | ||||
|                                     <?=$cssFile['filename']?>
 | ||||
|                             </option> | ||||
| <?php endforeach; ?>
 | ||||
|                         </select> | ||||
| <?php if (isset($cssDescription) && $cssDescription): ?>
 | ||||
|                         <label>Description</label> | ||||
|                         <label class="description"><?= $cssDescription ?></label>
 | ||||
| <?php endif; ?>
 | ||||
|                         <div></div> | ||||
|                         <div> | ||||
|                             <button type="submit" name="action" value="set_theme">Set Theme</button> | ||||
|                             <button type="submit" name="action" value="delete" class="delete-btn">Delete</button> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <fieldset> | ||||
|                     <legend>Upload</legend> | ||||
|                     <div class="fieldset-items"> | ||||
|                         <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"> | ||||
|                         <label for="uploadCssFile">Select File to Upload</label> | ||||
|                         <input type="file"  | ||||
|                                id="uploadCssFile"  | ||||
|                                name="uploadCssFile"  | ||||
|                                accept=".css"> | ||||
|                         <div class="file-info"> | ||||
|                             <strong>File Requirements:</strong><br> | ||||
|                             • Must be a valid CSS file (.css extension)<br> | ||||
|                             • Maximum size: 1 MB<br> | ||||
|                             • Will be scanned for malicious content | ||||
|                         </div> | ||||
|                         <label for="description">Description (optional)</label> | ||||
|                         <textarea id="description"  | ||||
|                                   name="description"  | ||||
|                                   placeholder="Describe this CSS file..."></textarea> | ||||
|                         <div></div> | ||||
|                         <button type="submit" name="action" value="upload">Upload CSS File</button> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|             </form> | ||||
|         </div> | ||||
| @ -1,5 +0,0 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
|         <title><?= $config->siteTitle ?></title>
 | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|         <link rel="stylesheet" href="<?= htmlspecialchars($config->basePath) ?>css/tkr.css?v=<?= time() ?>"> | ||||
| @ -2,13 +2,6 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var UserModel $user */ ?>
 | ||||
| <?php /** @var string $tickList */ ?>
 | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|     <head> | ||||
| <?php include TEMPLATES_DIR . '/partials/head.php'?>
 | ||||
|     </head> | ||||
|     <body> | ||||
| <?php include TEMPLATES_DIR . '/partials/navbar.php'?>
 | ||||
|         <div class="home-container"> | ||||
|             <section id="sidebar" class="home-sidebar"> | ||||
|                 <div class="home-header"> | ||||
| @ -37,5 +30,3 @@ | ||||
|             </section> | ||||
|             <?php echo $tickList ?>
 | ||||
|         </div> | ||||
|     </body> | ||||
| </html> | ||||
| @ -1,13 +1,6 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var string $csrf_token */ ?>
 | ||||
| <?php /** @var string $error */ ?>
 | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
|     <head> | ||||
| <?php include TEMPLATES_DIR . '/partials/head.php'?>
 | ||||
|     </head> | ||||
|     <body> | ||||
| <?php include TEMPLATES_DIR . '/partials/navbar.php'?>
 | ||||
|     <h2>Login</h2> | ||||
| <?php if ($error): ?>
 | ||||
|     <p style="color:red"><?=  htmlspecialchars($error) ?></p>
 | ||||
| @ -18,5 +11,3 @@ | ||||
|         <label>Password: <input type="password" name="password" required></label><br> | ||||
|         <button type="submit" class="submit-btn">Login</button> | ||||
|     </form> | ||||
|     </body> | ||||
| </html> | ||||
							
								
								
									
										3
									
								
								templates/partials/mood.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								templates/partials/mood.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| <?php /** @var string $moodPicker */ ?>
 | ||||
|         <h2>How are you feeling?</h2> | ||||
| <?php echo $moodPicker; ?>
 | ||||
| @ -7,6 +7,7 @@ | ||||
|             <a href="<?= $config->basePath ?>login">login</a> | ||||
| <?php else: ?>
 | ||||
|             <a href="<?= $config->basePath ?>admin">admin</a> | ||||
|             <a href="<?= $config->basePath ?>admin/css">css</a> | ||||
|             <a href="<?= $config->basePath ?>logout">logout</a> | ||||
| <?php endif; ?>
 | ||||
|         </div> | ||||
| @ -1,68 +0,0 @@ | ||||
| <?php | ||||
| $db = Utli::get_db(); | ||||
| 
 | ||||
| // Handle submitted form
 | ||||
| if ($_SERVER['REQUEST_METHOD'] === 'POST') { | ||||
|     $username     = trim($_POST['username'] ?? ''); | ||||
|     $display_name = trim($_POST['display_name'] ?? ''); | ||||
|     $password = $_POST['password'] ?? ''; | ||||
|     $site_title = trim($_POST['site_title']) ?? ''; | ||||
|     $site_description = trim($_POST['site_description']) ?? ''; | ||||
|     $base_path = trim($_POST['base_path'] ?? '/'); | ||||
|     $items_per_page = (int) ($_POST['items_per_page'] ?? 25); | ||||
| 
 | ||||
|     // Sanitize base path
 | ||||
|     if (substr($base_path, -1) !== '/') { | ||||
|         $base_path .= '/'; | ||||
|     } | ||||
| 
 | ||||
|     // Validate
 | ||||
|     $errors = []; | ||||
|     if (!$username || !$password) { | ||||
|         $errors[] = "Username and password are required."; | ||||
|     } | ||||
|     if (!$display_name) { | ||||
|         $errors[] = "Display name is required."; | ||||
|     } | ||||
|     if (!$site_title) { | ||||
|         $errors[] = "Site title is required."; | ||||
|     } | ||||
|     if (!preg_match('#^/[^?<>:"|\\*]*$#', $base_path)) { | ||||
|         $errors[] = "Base path must look like a valid URL path (e.g. / or /tkr/)."; | ||||
|     } | ||||
|     if ($items_per_page < 1 || $items_per_page > 50) { | ||||
|         $errors[] = "Items per page must be a number between 1 and 50."; | ||||
|     } | ||||
| 
 | ||||
|     // TODO: Actually handle errors
 | ||||
|     if (empty($errors)) { | ||||
|         $hash = password_hash($password, PASSWORD_DEFAULT); | ||||
| 
 | ||||
|         $stmt = $db->prepare("INSERT INTO user (username, display_name, password_hash) VALUES (?, ?, ?)"); | ||||
|         $stmt->execute([$username, $display_name, $hash]); | ||||
| 
 | ||||
|         $stmt = $db->prepare("INSERT INTO settings (id, site_title, site_description, base_path, items_per_page) VALUES (1, ?, ?, ?, ?)"); | ||||
|         $stmt->execute([$site_title, $site_description, $base_path, $items_per_page]); | ||||
| 
 | ||||
|         header("Location: index.php"); | ||||
|         exit; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| ?>
 | ||||
| 
 | ||||
| <h1>Let’s Set Up Your tkr</h1> | ||||
| <form method="post"> | ||||
|     <h3>UserModel settings</h3> | ||||
|     <label>Username: <input type="text" name="username" required></label><br> | ||||
|     <label>Display name: <input type="text" name="display_name" required></label><br> | ||||
|     <label>Password: <input type="password" name="password" required></label><br> | ||||
|     <br/><br/> | ||||
|     <h3>Site settings</h3> | ||||
|     <label>Title: <input type="text" name="site_title" value="My tkr" required></label><br> | ||||
|     <label>Description: <input type="text" name="site_description"></label><br> | ||||
|     <label>Base path: <input type="text" name="base_path" value="/" required></label><br> | ||||
|     <label>Items per page (max 50): <input type="number" name="items_per_page" value="25" min="1" max="50" required></label><br> | ||||
|     <br/> | ||||
|     <button type="submit">Complete Setup</button> | ||||
| </form> | ||||
| @ -2,7 +2,7 @@ | ||||
| <?php /** @var Date $tickTime */ ?>
 | ||||
| <?php /** @var string $tick */ ?>
 | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <html lang="en"> | ||||
|     <head> | ||||
| <?php include TEMPLATES_DIR . '/partials/head.php'?>
 | ||||
|     </head> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user