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
init_complete
storage/upload/css
scratch

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 $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));

View File

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

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

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

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

View File

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

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

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 string $tick */ ?>
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<?php include TEMPLATES_DIR . '/partials/head.php'?>
</head>