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