Add CSS upload. Refactor templates. General cleanup.

This commit is contained in:
Greg Sarjeant 2025-06-10 20:00:18 -04:00
parent f8e7151e6f
commit 4f5ea22dfd
26 changed files with 500 additions and 207 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.gitignore vendored
View File

@ -4,3 +4,5 @@
*.txt *.txt
init_complete init_complete
storage/upload/css
scratch

View File

@ -6,7 +6,7 @@ services:
- "80:80" - "80:80"
volumes: volumes:
- ./public:/var/www/html/tkr/public - ./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: depends_on:
- php - php
restart: unless-stopped restart: unless-stopped

View File

@ -23,9 +23,13 @@ server {
index index.php; index index.php;
# Cache static files # Cache static files
# Note that I don't actually serve most of this (just js and css to start) # Note that I don't actually serve most of this (just css)
# but including them all will let caching work later if I add images or something # but this prevents requests for static content from getting to the PHP handler.
location ~* ^/tkr/.+\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { #
# 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; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
try_files $uri =404; try_files $uri =404;
@ -70,7 +74,7 @@ server {
} }
# Deny access to sensitive directories # Deny access to sensitive directories
location ~ ^/tkr/(storage|src|templates|vendor|config) { location ~ ^/tkr/(storage|src|templates|vendor|uploads|config) {
deny all; deny all;
return 404; return 404;
} }

View File

@ -126,10 +126,7 @@ input[type="file"]:focus {
box-shadow: 0 0 0 3px var(--shadow-primary); box-shadow: 0 0 0 3px var(--shadow-primary);
} }
/* Submit buttons */ button {
/* Stop deleting this block */
input[type="submit"],
button[type="submit"] {
padding: 10px 20px; padding: 10px 20px;
border: 1px solid var(--color-primary); border: 1px solid var(--color-primary);
border-radius: 6px; border-radius: 6px;
@ -142,21 +139,18 @@ button[type="submit"] {
box-sizing: border-box; box-sizing: border-box;
} }
input[type="submit"]:hover, button:hover {
button[type="submit"]:hover {
background-color: var(--color-hover-light); background-color: var(--color-hover-light);
border-color: var(--color-primary-dark); border-color: var(--color-primary-dark);
} }
input[type="submit"]:focus, button:focus {
button[type="submit"]:focus {
outline: none; outline: none;
border-color: var(--color-primary-darker); border-color: var(--color-primary-darker);
box-shadow: 0 0 0 2px var(--shadow-primary); box-shadow: 0 0 0 2px var(--shadow-primary);
} }
input[type="submit"]:active, button:active {
button[type="submit"]:active {
background-color: var(--color-hover-medium); background-color: var(--color-hover-medium);
} }
@ -169,6 +163,15 @@ label {
line-height: 1.2; 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. The two common display options for responsive layouts are flex and grid.
flex (aka Flexbox) aligns items either horizontally or vertically. flex (aka Flexbox) aligns items either horizontally or vertically.
@ -234,33 +237,32 @@ label {
grid-column: 1; grid-column: 1;
} }
.upload-btn { .delete-btn {
background-color: var(--color-primary-lightest); background-color: #fef2f2;
color: var(--color-text-secondary); color: #dc2626;
border: 1px solid var(--color-primary); border: 1px solid #fca5a5;
padding: 10px 20px; padding: 10px 20px;
border-radius: 6px; border-radius: 6px;
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
width: 100%;
transition: all 0.2s ease; transition: all 0.2s ease;
margin-top: 12px;
box-sizing: border-box; box-sizing: border-box;
} }
.upload-btn:hover { .delete-btn:hover {
background-color: var(--color-hover-light); background-color: #fee2e2;
border-color: var(--color-primary-dark); border-color: #f87171;
} }
.upload-btn:active { .delete-btn:focus {
background-color: var(--color-hover-medium);
}
.upload-btn:focus {
outline: none; 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 { .required {
@ -315,6 +317,11 @@ label {
margin-bottom: 0; margin-bottom: 0;
} }
label.description {
padding-top: 10px;
margin-bottom: 0;
}
.home-container { .home-container {
grid-template-columns: 1fr 2fr; grid-template-columns: 1fr 2fr;
grid-gap: 2em; grid-gap: 2em;

View File

@ -19,6 +19,7 @@ define('STORAGE_DIR', APP_ROOT . '/storage');
define('TEMPLATES_DIR', APP_ROOT . '/templates'); define('TEMPLATES_DIR', APP_ROOT . '/templates');
define('TICKS_DIR', STORAGE_DIR . '/ticks'); define('TICKS_DIR', STORAGE_DIR . '/ticks');
define('DATA_DIR', STORAGE_DIR . '/db'); define('DATA_DIR', STORAGE_DIR . '/db');
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css');
define('DB_FILE', DATA_DIR . '/tkr.sqlite'); define('DB_FILE', DATA_DIR . '/tkr.sqlite');
// Load all classes from the src/ directory // Load all classes from the src/ directory
@ -61,7 +62,9 @@ function route(string $requestPath, string $requestMethod, array $routeHandlers)
$controller = $routeHandler[1]; $controller = $routeHandler[1];
$methods = $routeHandler[2] ?? ['GET']; $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 . '$#'; $routePattern = '#^' . $routePattern . '$#';
if (preg_match($routePattern, $requestPath, $matches)) { if (preg_match($routePattern, $requestPath, $matches)) {
@ -88,19 +91,24 @@ function route(string $requestPath, string $requestMethod, array $routeHandlers)
return false; return false;
} }
// Define the recognized routes.
// Anything else will 404.
$routeHandlers = [ $routeHandlers = [
['', 'HomeController'], ['', 'HomeController'],
['', 'HomeController@handleTick', ['POST']], ['', 'HomeController@handleTick', ['POST']],
['admin', 'AdminController'], ['admin', 'AdminController'],
['admin', 'AdminController@handleSave', ['POST']], ['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@showLogin'],
['login', 'AuthController@handleLogin', ['POST']], ['login', 'AuthController@handleLogin', ['POST']],
['logout', 'AuthController@handleLogout', ['GET', 'POST']], ['logout', 'AuthController@handleLogout', ['GET', 'POST']],
['mood', 'MoodController'], ['mood', 'MoodController'],
['mood', 'MoodController@handleMood', ['POST']], ['mood', 'MoodController@handleMood', ['POST']],
['feed/rss', 'FeedController@rss'],
['feed/atom', 'FeedController@atom'],
['tick/{y}/{m}/{d}/{h}/{i}/{s}', 'TickController'], ['tick/{y}/{m}/{d}/{h}/{i}/{s}', 'TickController'],
['css/custom/{filename}.css', 'CssController@serveCustomCss'],
]; ];
// Set content type // Set content type

View File

@ -119,11 +119,7 @@ class AdminController extends Controller {
ConfigModel::completeSetup(); ConfigModel::completeSetup();
} }
header('Location: ' . $config->basePath . '/admin'); header('Location: ' . $config->basePath . 'admin');
exit; exit;
} }
private function getCustomCss(){
}
} }

View File

@ -1,14 +1,18 @@
<?php <?php
class Controller { class Controller {
protected function render(string $templateFile, array $vars = []) { // Renders the requested template inside templates/main/php
$templatePath = TEMPLATES_DIR . "/" . $templateFile; protected function render(string $childTemplateFile, array $vars = []) {
$templatePath = TEMPLATES_DIR . "/main.php";
$childTemplatePath = TEMPLATES_DIR . "/partials/" . $childTemplateFile;
if (!file_exists($templatePath)) { if (!file_exists($templatePath)) {
throw new RuntimeException("Template not found: $templatePath"); throw new RuntimeException("Template not found: $templatePath");
} }
// PHP scoping if (!file_exists($childTemplatePath)) {
// extract the variables from $vars into the local scope. throw new RuntimeException("Template not found: $childTemplatePath");
}
extract($vars, EXTR_SKIP); extract($vars, EXTR_SKIP);
include $templatePath; include $templatePath;
} }

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

View File

@ -4,6 +4,17 @@ class FeedController extends Controller {
private array $ticks; private array $ticks;
private array $vars; 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(){ public function __construct(){
$this->config = ConfigModel::load(); $this->config = ConfigModel::load();
$this->ticks = iterator_to_array(TickModel::streamTicks($this->config->itemsPerPage)); $this->ticks = iterator_to_array(TickModel::streamTicks($this->config->itemsPerPage));

View File

@ -15,7 +15,7 @@ class Util {
} }
// For relative time display, compare the stored time to the current time // 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 { public static function relative_time(string $tickTime): string {
$datetime = new DateTime($tickTime); $datetime = new DateTime($tickTime);
$now = new DateTime('now', $datetime->getTimezone()); $now = new DateTime('now', $datetime->getTimezone());
@ -39,7 +39,7 @@ class Util {
return $diff->s . ' second' . ($diff->s != 1 ? 's' : '') . ' ago'; 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 (!is_dir($dir)) {
if ($allow_create) { if ($allow_create) {
if (!mkdir($dir, 0770, true)) { if (!mkdir($dir, 0770, true)) {
@ -66,7 +66,7 @@ class Util {
public static function confirm_setup(): void { public static function confirm_setup(): void {
$db = Util::get_db(); $db = Util::get_db();
// Ensure required tables exist // user table
$db->exec("CREATE TABLE IF NOT EXISTS user ( $db->exec("CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@ -77,13 +77,22 @@ class Util {
mood TEXT NULL mood TEXT NULL
)"); )");
// settings table
$db->exec("CREATE TABLE IF NOT EXISTS settings ( $db->exec("CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
site_title TEXT NOT NULL, site_title TEXT NOT NULL,
site_description TEXT NULL, site_description TEXT NULL,
base_url TEXT NOT NULL, base_url TEXT NOT NULL,
base_path 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 // 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(); $settings_count = (int) $db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
$config = ConfigModel::load(); $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 ($user_count === 0 || $settings_count === 0){
if (basename($_SERVER['PHP_SELF']) !== 'admin'){ if (basename($_SERVER['PHP_SELF']) !== 'admin'){
header('Location: ' . $config->basePath . 'admin'); header('Location: ' . $config->basePath . 'admin');
exit; 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){ public static function tick_time_to_tick_path($tickTime){
@ -121,7 +122,7 @@ class Util {
} }
public static function get_db(): PDO { public static function get_db(): PDO {
Util::verify_data_dir(DATA_DIR, true); Util::verify_storage_dir(DATA_DIR, true);
try { try {
$db = new PDO("sqlite:" . DB_FILE); $db = new PDO("sqlite:" . DB_FILE);

View File

@ -7,6 +7,7 @@ class ConfigModel {
public string $basePath = ''; public string $basePath = '';
public int $itemsPerPage = 25; public int $itemsPerPage = 25;
public string $timezone = 'relative'; public string $timezone = 'relative';
public ?int $cssId;
public static function isFirstSetup(): bool { public static function isFirstSetup(): bool {
return !file_exists(STORAGE_DIR . '/init_complete'); return !file_exists(STORAGE_DIR . '/init_complete');
@ -24,7 +25,7 @@ class ConfigModel {
$c->basePath = ($c->basePath === '') ? $init['base_path'] : $c->basePath; $c->basePath = ($c->basePath === '') ? $init['base_path'] : $c->basePath;
$db = Util::get_db(); $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); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) { if ($row) {
@ -33,20 +34,33 @@ class ConfigModel {
$c->baseUrl = $row['base_url']; $c->baseUrl = $row['base_url'];
$c->basePath = $row['base_path']; $c->basePath = $row['base_path'];
$c->itemsPerPage = (int) $row['items_per_page']; $c->itemsPerPage = (int) $row['items_per_page'];
$c->cssId = (int) $row['css_id'];
} }
return $c; 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 { public function save(): self {
$db = Util::get_db(); $db = Util::get_db();
if (!ConfigModel::isFirstSetup()){ 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 { } 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(); return self::load();
} }

View 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]);
}
}

View File

20
templates/main.php Normal file
View 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>

View File

@ -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>

View File

@ -1,13 +1,5 @@
<?php /** @var ConfigModel $config */ ?> <?php /** @var ConfigModel $config */ ?>
<?php /** @var UserModel $user */ ?> <?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> <h1>Admin</h1>
<div> <div>
<form method="post"> <form method="post">
@ -63,12 +55,6 @@
value="<?= $config->itemsPerPage ?>" min="1" max="50" value="<?= $config->itemsPerPage ?>" min="1" max="50"
required> required>
</div> </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>
<fieldset> <fieldset>
<legend>Change password</legend> <legend>Change password</legend>
@ -79,31 +65,6 @@
<input type="password" name="confirm_password"> <input type="password" name="confirm_password">
</div> </div>
</fieldset> </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> <button type="submit" class="submit-btn">Save Settings</button>
</form> </form>
</div> </div>
</body>
</html>

View 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>

View File

@ -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() ?>">

View File

@ -2,13 +2,6 @@
<?php /** @var ConfigModel $config */ ?> <?php /** @var ConfigModel $config */ ?>
<?php /** @var UserModel $user */ ?> <?php /** @var UserModel $user */ ?>
<?php /** @var string $tickList */ ?> <?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"> <div class="home-container">
<section id="sidebar" class="home-sidebar"> <section id="sidebar" class="home-sidebar">
<div class="home-header"> <div class="home-header">
@ -37,5 +30,3 @@
</section> </section>
<?php echo $tickList ?> <?php echo $tickList ?>
</div> </div>
</body>
</html>

View File

@ -1,13 +1,6 @@
<?php /** @var ConfigModel $config */ ?> <?php /** @var ConfigModel $config */ ?>
<?php /** @var string $csrf_token */ ?> <?php /** @var string $csrf_token */ ?>
<?php /** @var string $error */ ?> <?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> <h2>Login</h2>
<?php if ($error): ?> <?php if ($error): ?>
<p style="color:red"><?= htmlspecialchars($error) ?></p> <p style="color:red"><?= htmlspecialchars($error) ?></p>
@ -18,5 +11,3 @@
<label>Password: <input type="password" name="password" required></label><br> <label>Password: <input type="password" name="password" required></label><br>
<button type="submit" class="submit-btn">Login</button> <button type="submit" class="submit-btn">Login</button>
</form> </form>
</body>
</html>

View File

@ -0,0 +1,3 @@
<?php /** @var string $moodPicker */ ?>
<h2>How are you feeling?</h2>
<?php echo $moodPicker; ?>

View File

@ -7,6 +7,7 @@
<a href="<?= $config->basePath ?>login">login</a> <a href="<?= $config->basePath ?>login">login</a>
<?php else: ?> <?php else: ?>
<a href="<?= $config->basePath ?>admin">admin</a> <a href="<?= $config->basePath ?>admin">admin</a>
<a href="<?= $config->basePath ?>admin/css">css</a>
<a href="<?= $config->basePath ?>logout">logout</a> <a href="<?= $config->basePath ?>logout">logout</a>
<?php endif; ?> <?php endif; ?>
</div> </div>

View File

@ -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>Lets 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>

View File

@ -2,7 +2,7 @@
<?php /** @var Date $tickTime */ ?> <?php /** @var Date $tickTime */ ?>
<?php /** @var string $tick */ ?> <?php /** @var string $tick */ ?>
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<?php include TEMPLATES_DIR . '/partials/head.php'?> <?php include TEMPLATES_DIR . '/partials/head.php'?>
</head> </head>