Compare commits

..

No commits in common. "main" and "v0.7.4" have entirely different histories.
main ... v0.7.4

54 changed files with 485 additions and 3452 deletions

View File

@ -15,10 +15,10 @@ jobs:
tar \
--transform 's,^,tkr/,' \
--exclude='storage/db' \
--exclude='storage/logs' \
--exclude='storage/ticks' \
--exclude='storage/upload' \
-czvf tkr.${{ gitea.ref_name }}.tgz \
check-prerequisites.php config public src storage templates
config public src storage templates
- name: Push to Generic gitea registry
run: |
curl \

View File

@ -1,163 +0,0 @@
name: Prerequisites Testing
on: [pull_request]
jobs:
test-php-version-requirements:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['7.4', '8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: pdo,pdo_sqlite
- name: Test PHP version requirement
run: |
if [[ "${{ matrix.php }}" < "8.2" ]]; then
echo "Testing PHP ${{ matrix.php }} - should fail"
if php check-prerequisites.php; then
echo "ERROR: Should have failed with PHP ${{ matrix.php }}"
exit 1
fi
echo "✓ Correctly failed with old PHP version"
else
echo "Testing PHP ${{ matrix.php }} - should pass"
php check-prerequisites.php
echo "✓ Correctly passed with supported PHP version"
fi
test-extension-progression:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3']
container: ['fedora:39', 'debian:bookworm', 'alpine:latest']
container: ${{ matrix.container }}
steps:
- name: Install Node.js and git
run: |
if [ -f /etc/fedora-release ]; then
dnf install -y nodejs npm git
elif [ -f /etc/alpine-release ]; then
apk add --no-cache nodejs npm git
else
apt-get update && apt-get install -y nodejs npm git
fi
- uses: actions/checkout@v3
- name: Install base PHP only
run: |
if [ -f /etc/fedora-release ]; then
dnf --setopt=install_weak_deps=False install -y php php-cli
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82
ln -s /usr/bin/php82 /usr/bin/php
else
apt-get update && apt-get install -y php
fi
- name: Test failure with missing extensions
run: |
echo "Testing with base PHP - should fail"
if php check-prerequisites.php; then
echo "ERROR: Should have failed with missing extensions"
exit 1
fi
echo "✓ Correctly failed with missing extensions"
- name: Install PDO extension
run: |
if [ -f /etc/fedora-release ]; then
echo "Not installing PDO on fedora because it includes SQLite support."
echo "Will install in subsequent test so this step fails as expected."
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82-pdo
else
apt-get install -y php-pdo
fi
- name: Test still fails without SQLite
run: |
echo "Testing with PDO but no SQLite - should still fail"
if php check-prerequisites.php; then
echo "ERROR: Should have failed without SQLite"
exit 1
fi
echo "✓ Correctly failed without SQLite extension"
- name: Install SQLite extension
run: |
if [ -f /etc/fedora-release ]; then
dnf --setopt=install_weak_deps=False install -y php-pdo
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82-pdo_sqlite
else
apt-get install -y php-sqlite3
fi
- name: Test now passes with required extensions
run: |
echo "Testing with all required extensions - should pass"
php check-prerequisites.php
echo "✓ All required extensions detected correctly"
- name: Install recommended extensions and retest
run: |
if [ -f /etc/fedora-release ]; then
dnf install -y php-mbstring php-curl
elif [ -f /etc/alpine-release ]; then
apk add --no-cache php82-mbstring php82-curl
else
apt-get install -y php-mbstring php-curl
fi
php check-prerequisites.php
echo "✓ Recommended extensions also detected"
test-permission-scenarios:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: pdo,pdo_sqlite
- name: Test with unwritable storage directory
run: |
# Create a non-root user for testing
useradd -m -s /bin/bash testuser
# Make storage unwritable by non-root
mkdir -p storage
chmod 444 storage
chown root:root storage
# Run as the non-root user - should fail
if su testuser -c "php check-prerequisites.php"; then
echo "ERROR: Should have failed with unwritable storage"
exit 1
fi
echo "✓ Correctly failed with unwritable storage"
# Restore permissions for cleanup
chmod 755 storage
- name: Test with missing directories
run: |
# Remove required directories
rm -rf src templates
# Should fail
if php check-prerequisites.php; then
echo "ERROR: Should have failed with missing directories"
exit 1
fi

6
.gitignore vendored
View File

@ -9,14 +9,10 @@ phpunit
*.sqlite
*.txt
storage/upload/css
storage/logs
# Testing stuff
/docker-compose.yml
scratch
# Build artifacts
tkr.tgz
# Test logs
storage/prerequisite-check.log
tkr.tgz

View File

@ -1,5 +1,4 @@
# tkr
![Prerequisite tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/prerequisites.yaml/badge.svg)
![Unit tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/unit_tests.yaml/badge.svg)
A lightweight, HTML-only status feed for self-hosted personal websites. Written in PHP. Heavily inspired by [status.cafe](https://status.cafe).
@ -65,19 +64,22 @@ I'm trying to make sure that the HTML is both semantically valid and accessible,
* The SSL configurations are basic, but should work. For more robust SSL configurations, see https://ssl-config.mozilla.org
### From git
If you'd prefer to install from git:
1. Clone this directory and copy the `/tkr` directory to your web server.
* Required subdirectories are:
1. `config`
1. `public`
1. `src`
1. `storage`
1. `templates`
* Exclude the other directories
2. Follow the main installation from step 4.
## Initial configuration
1. Run `php tkr/prerequisites.php`. This will confirm that:
1. PHP 8.2+ is installed
1. All required PHP extensions are installed
1. PDO
1. PDO::sqlite
1. All required directories exist
1. The `tkr/storage` directory exists and is writable
1. If `tkr/storage` is writable, then it will create the required subdirectories
1. `tkr/storage/db`
1. `tkr/storage/upload`
1. The script will write a summary to stdout and will save a log at `tkr/storage/prerequisite-check.log`
1. Edit `config/init.php` to set the domain and base path correctly for your configuration.
* subdirectory installation (e.g. https://my-domain.com/tkr)
```
@ -143,19 +145,6 @@ tkr stores profile information, custom emojis, and uploaded css metadata in a SQ
You don't have to do any database setup. The database is automatically created and initialized on first run.
## FAQ
### Why don't I see the right IPs in the logs?
This can happen for a few reasons. Some common ones are:
**Docker Development:** If running via Docker, you may see `192.168.65.1` (Docker Desktop gateway). This is normal for development.
**Behind a Proxy/CDN:** If you're behind Cloudflare (with proxy enabled), load balancers, or other proxies, all requests may appear to come from the proxy's IP addresses.
- **For accurate IP logging:** Configure your web server to trust proxy headers. See your proxy provider's documentation for the required nginx/Apache configuration.
## Acknowledgements
It's been a lot of fun to get back to building something. I'm grateful to the people and projects that inspired me to do it:

View File

@ -1,21 +0,0 @@
#!/usr/bin/env php
<?php
/**
* tkr Prerequisites Checker - CLI Diagnostic Tool
*
* This script provides comprehensive diagnostic information for tkr.
* It can be run from the command line or uploaded separately for troubleshooting.
*
* Usage: php check-prerequisites.php
*/
// Minimal bootstrap just for prerequisites
include_once __DIR__ . '/config/bootstrap.php';
$prerequisites = new Prerequisites();
$results = $prerequisites->validate();
// Exit with appropriate code for shell scripts
if (php_sapi_name() === 'cli') {
exit(count($prerequisites->getErrors()) > 0 ? 1 : 0);
}

View File

@ -9,9 +9,10 @@ define('CONFIG_DIR', APP_ROOT . '/config');
define('SRC_DIR', APP_ROOT . '/src');
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('DB_FILE', DATA_DIR . '/tkr.sqlite');
define('CSS_UPLOAD_DIR', STORAGE_DIR . '/upload/css');
define('DB_FILE', DATA_DIR . '/tkr.sqlite');
// Janky autoloader function
// This is a bit more consistent with current frameworks

View File

@ -15,8 +15,7 @@ CREATE TABLE IF NOT EXISTS settings (
base_url TEXT NOT NULL,
base_path TEXT NOT NULL,
items_per_page INTEGER NOT NULL,
css_id INTEGER NULL,
strict_accessibility BOOLEAN DEFAULT TRUE
css_id INTEGER NULL
);
CREATE TABLE IF NOT EXISTS css (

View File

@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN strict_accessibility BOOLEAN DEFAULT TRUE;

0
config/migrations/002_add_show_tick_mood_setting.sql Normal file → Executable file
View File

View File

@ -1,2 +0,0 @@
ALTER TABLE settings
ADD COLUMN log_level INTEGER NULL;

View File

@ -18,10 +18,6 @@
--color-primary: gainsboro;
--color-required: crimson;
--color-text: black;
--color-log-info-bg: #f8f9fa;
--color-log-warning-bg: #fff8e1;
--color-log-error-bg: #ffebee;
--color-log-muted: #666;
--border-width: 2px;
--border-width-thin: 1px;
@ -501,53 +497,3 @@ time {
min-width: auto;
}
}
/* Log viewer styles */
.log-monospace {
font-family: 'Courier New', Consolas, Monaco, 'Lucida Console', monospace;
}
.log-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.log-table th,
.log-table td {
padding: 10px 12px;
text-align: left;
border-bottom: var(--border-width-thin) solid var(--color-border);
}
.log-table th {
background-color: var(--color-primary);
font-weight: 600;
color: var(--color-text);
}
.log-level-badge {
padding: 4px 8px;
border-radius: var(--border-radius);
font-size: 0.8em;
font-weight: 600;
}
/* Log level row colors - subtle background tints */
.log-debug { background-color: var(--color-bg); }
.log-info { background-color: var(--color-log-info-bg); }
.log-warning { background-color: var(--color-log-warning-bg); }
.log-error { background-color: var(--color-log-error-bg); }
.log-no-route {
color: var(--color-log-muted);
font-style: italic;
}
.log-info {
margin-top: 1rem;
padding: 10px 12px;
background-color: var(--color-primary);
border-radius: var(--border-radius);
font-size: 0.9em;
}

View File

@ -14,35 +14,24 @@ if (preg_match('/\.php$/', $path)) {
// Define base paths and load classes
include_once(dirname(dirname(__FILE__)) . "/config/bootstrap.php");
// Check prerequisites.
$prerequisites = new Prerequisites();
$results = $prerequisites->validate();
if (count($prerequisites->getErrors()) > 0) {
$prerequisites->generateWebSummary($results);
exit;
}
// Do any necessary database migrations
$dbMgr = new Database();
$dbMgr->migrate();
// Make sure the initial setup is complete
// unless we're already heading to setup
//
// TODO: Consider simplifying this.
// Might not need the custom exception now that the prereq checker is more robust.
if (!(preg_match('/setup$/', $path))) {
try {
// Make sure setup has been completed
$dbMgr->confirmSetup();
// filesystem validation
$fsMgr = new Filesystem();
$fsMgr->validate();
// database validation
$dbMgr = new Database();
$dbMgr->validate();
} catch (SetupException $e) {
$e->handle();
exit;
}
}
// Get a database connection
// TODO: Change from static function.
// initialize the database
global $db;
$db = Database::get();
@ -52,10 +41,8 @@ $db = Database::get();
global $config;
global $user;
$config = new ConfigModel($db);
$config = $config->loadFromDatabase();
$user = new UserModel($db);
$user = $user->loadFromDatabase();
$config = ConfigModel::load();
$user = UserModel::load();
// Start a session and generate a CSRF Token
// if there isn't already an active session
@ -70,10 +57,6 @@ if (strpos($path, $config->basePath) === 0) {
// strip the trailing slash from the resulting route
$path = trim($path, '/');
// Set route context for logging
Log::setRouteContext("$method $path");
Log::debug("Path requested: {$path}");
// if this is a POST and we aren't in setup,
// make sure there's a valid session
// if not, redirect to /login or die as appropriate
@ -81,14 +64,12 @@ if ($method === 'POST' && $path != 'setup') {
if ($path != 'login'){
if (!Session::isValid($_POST['csrf_token'])) {
// Invalid session - redirect to /login
Log::info('Attempt to POST with invalid session. Redirecting to login.');
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login'));
header('Location: ' . $config->basePath . '/login');
exit;
}
} else {
if (!Session::isValidCsrfToken($_POST['csrf_token'])) {
// Just die if the token is invalid on login
Log::error("Attempt to log in with invalid CSRF token.");
die('Invalid CSRF token');
exit;
}
@ -99,8 +80,7 @@ if ($method === 'POST' && $path != 'setup') {
header('Content-Type: text/html; charset=utf-8');
// Render the requested route or throw a 404
$router = new Router($db, $config, $user);
if (!$router->route($path, $method)){
if (!Router::route($path, $method)){
http_response_code(404);
echo "404 - Page Not Found";
exit;

View File

@ -3,167 +3,146 @@ class AdminController extends Controller {
// GET handler
// render the admin page
public function index(){
$data = $this->getAdminData(false);
$this->render("admin.php", $data);
global $config;
global $user;
$vars = [
'user' => $user,
'config' => $config,
'isSetup' => false,
];
$this->render("admin.php", $vars);
}
public function showSetup(){
$data = $this->getAdminData(true);
$this->render("admin.php", $data);
}
public function getAdminData(bool $isSetup): array {
Log::debug("Loading admin page" . ($isSetup ? " (setup mode)" : ""));
return [
'user' => $this->user,
'config' => $this->config,
'isSetup' => $isSetup,
global $config;
global $user;
$vars = [
'user' => $user,
'config' => $config,
'isSetup' => true,
];
$this->render("admin.php", $vars);
}
public function handleSave(){
if (!Session::isLoggedIn()){
header('Location: ' . Util::buildRelativeUrl($this->config->basePath, 'login'));
header('Location: ' . $config->basePath . '/login');
exit;
}
$result = $this->processSettingsSave($_POST, false);
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
$this->save();
}
public function handleSetup(){
// for setup, we don't care if they're logged in
// (because they can't be until setup is complete)
$result = $this->processSettingsSave($_POST, true);
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
$this->save();
}
public function processSettingsSave(array $postData, bool $isSetup): array {
$result = ['success' => false, 'errors' => []];
Log::debug("Processing settings save" . ($isSetup ? " (setup mode)" : ""));
// save updated settings
private function save(){
global $config;
global $user;
// handle form submission
if (empty($postData)) {
Log::warning("Settings save called with no POST data");
$result['errors'][] = 'No data provided';
return $result;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$errors = [];
$errors = [];
// User profile
$username = trim($_POST['username'] ?? '');
$displayName = trim($_POST['display_name'] ?? '');
$website = trim($_POST['website'] ?? '');
// User profile
$username = trim($postData['username'] ?? '');
$displayName = trim($postData['display_name'] ?? '');
$website = trim($postData['website'] ?? '');
// Site settings
$siteTitle = trim($_POST['site_title']) ?? '';
$siteDescription = trim($_POST['site_description']) ?? '';
$baseUrl = trim($_POST['base_url'] ?? '');
$basePath = trim($_POST['base_path'] ?? '/');
$itemsPerPage = (int) ($_POST['items_per_page'] ?? 25);
$strictAccessibility = isset($_POST['strict_accessibility']);
$showTickMood = isset($_POST['show_tick_mood']);
// Site settings
$siteTitle = trim($postData['site_title'] ?? '');
$siteDescription = trim($postData['site_description'] ?? '');
$baseUrl = trim($postData['base_url'] ?? '');
$basePath = trim($postData['base_path'] ?? '/');
$itemsPerPage = (int) ($postData['items_per_page'] ?? 25);
$strictAccessibility = isset($postData['strict_accessibility']);
$logLevel = (int) ($postData['log_level'] ?? 0);
// Password
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
// Password
$password = $postData['password'] ?? '';
$confirmPassword = $postData['confirm_password'] ?? '';
Log::info("Processing settings for user: $username");
// Validate user profile
if (!$username) {
$errors[] = "Username is required.";
}
if (!$displayName) {
$errors[] = "Display name is required.";
}
if (!$baseUrl) {
$errors[] = "Base URL is required.";
}
// Make sure the website looks like a URL and starts with a protocol
if ($website) {
if (!filter_var($website, FILTER_VALIDATE_URL)) {
$errors[] = "Please enter a valid URL (including http:// or https://).";
} elseif (!preg_match('/^https?:\/\//i', $website)) {
$errors[] = "URL must start with http:// or https://.";
// Validate user profile
if (!$username) {
$errors[] = "Username is required.";
}
}
// Validate site settings
if (!$siteTitle) {
$errors[] = "Site title is required.";
}
if (!preg_match('#^/[^?<>:"|\\*]*$#', $basePath)) {
$errors[] = "Base path must look like a valid URL path (e.g. / or /tkr/).";
}
if ($itemsPerPage < 1 || $itemsPerPage > 50) {
$errors[] = "Items per page must be a number between 1 and 50.";
}
// If a password was sent, make sure it matches the confirmation
if ($password && !($password === $confirmPassword)){
$errors[] = "Passwords do not match";
}
// Log validation results
if (!empty($errors)) {
Log::warning("Settings validation failed with " . count($errors) . " errors");
foreach ($errors as $error) {
Log::debug("Validation error: $error");
if (!$displayName) {
$errors[] = "Display name is required.";
}
if (!$baseUrl) {
$errors[] = "Base URL is required.";
}
// Make sure the website looks like a URL and starts with a protocol
if ($website) {
if (!filter_var($website, FILTER_VALIDATE_URL)) {
$errors[] = "Please enter a valid URL (including http:// or https://).";
} elseif (!preg_match('/^https?:\/\//i', $website)) {
$errors[] = "URL must start with http:// or https://.";
}
}
}
// Validation complete
if (empty($errors)) {
try {
// Validate site settings
if (!$siteTitle) {
$errors[] = "Site title is required.";
}
if (!preg_match('#^/[^?<>:"|\\*]*$#', $basePath)) {
$errors[] = "Base path must look like a valid URL path (e.g. / or /tkr/).";
}
if ($itemsPerPage < 1 || $itemsPerPage > 50) {
$errors[] = "Items per page must be a number between 1 and 50.";
}
// If a password was sent, make sure it matches the confirmation
if ($password && !($password === $confirmPassword)){
$errors[] = "Passwords do not match";
}
// Validation complete
if (empty($errors)) {
// Update site settings
$this->config->siteTitle = $siteTitle;
$this->config->siteDescription = $siteDescription;
$this->config->baseUrl = $baseUrl;
$this->config->basePath = $basePath;
$this->config->itemsPerPage = $itemsPerPage;
$this->config->strictAccessibility = $strictAccessibility;
$this->config->logLevel = $logLevel;
$config->siteTitle = $siteTitle;
$config->siteDescription = $siteDescription;
$config->baseUrl = $baseUrl;
$config->basePath = $basePath;
$config->itemsPerPage = $itemsPerPage;
$config->strictAccessibility = $strictAccessibility;
// Save site settings and reload config from database
$this->config = $this->config->save();
Log::info("Site settings updated");
// TODO - raise and handle exception on failure
$config = $config->save();
// Update user profile
$this->user->username = $username;
$this->user->displayName = $displayName;
$this->user->website = $website;
$user->username = $username;
$user->displayName = $displayName;
$user->website = $website;
// Save user profile and reload user from database
$this->user = $this->user->save();
Log::info("User profile updated");
// TODO - raise and handle exception on failure
$user = $user->save();
// Update the password if one was sent
// TODO - raise and handle exception on failure
if($password){
$this->user->setPassword($password);
Log::info("User password updated");
$user->set_password($password);
}
Session::setFlashMessage('success', 'Settings updated');
$result['success'] = true;
} catch (Exception $e) {
Log::error("Failed to save settings: " . $e->getMessage());
Session::setFlashMessage('error', 'Failed to save settings');
$result['errors'][] = 'Failed to save settings';
} else {
foreach($errors as $error){
Session::setFlashMessage('error', $error);
}
}
} else {
foreach($errors as $error){
Session::setFlashMessage('error', $error);
}
$result['errors'] = $errors;
}
return $result;
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
}

View File

@ -20,21 +20,21 @@ class AuthController extends Controller {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
Log::debug("Login attempt for user {$username}");
// TODO: move into user model
global $db;
$stmt = $db->prepare("SELECT id, username, password_hash FROM user WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
$userModel = new UserModel();
$user = $userModel->getByUsername($username);
//if ($user && password_verify($password, $user['password_hash'])) {
if ($user && password_verify($password, $user['password_hash'])) {
Log::info("Successful login for {$username}");
Session::newLoginSession($user);
header('Location: ' . Util::buildRelativeUrl($config->basePath));
session_regenerate_id(true);
// TODO: move into session.php
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
Session::generateCsrfToken(true);
header('Location: ' . $config->basePath);
exit;
} else {
Log::warning("Failed login for {$username}");
// Set a flash message and reload the login page
Session::setFlashMessage('error', 'Invalid username or password');
header('Location: ' . $_SERVER['PHP_SELF']);
@ -44,11 +44,9 @@ class AuthController extends Controller {
}
function handleLogout(){
Log::info("Logout from user " . $_SESSION['username']);
Session::end();
global $config;
header('Location: ' . Util::buildRelativeUrl($config->basePath));
header('Location: ' . $config->basePath);
exit;
}
}

View File

@ -1,7 +1,5 @@
<?php
class Controller {
public function __construct(protected PDO $db, protected ConfigModel $config, protected UserModel $user) {}
// Renders the requested template inside templates/main/php
protected function render(string $childTemplateFile, array $vars = []) {
$templatePath = TEMPLATES_DIR . "/main.php";

View File

@ -29,7 +29,7 @@
break;
}
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'admin/emoji'));
header('Location: ' . $config->basePath . 'admin/emoji');
exit;
}

View File

@ -1,29 +1,34 @@
<?php
class FeedController extends Controller {
private $ticks;
private array $vars;
public function __construct(PDO $db, ConfigModel $config, UserModel $user){
parent::__construct($db, $config, $user);
$tickModel = new TickModel($db, $config);
$this->ticks = $tickModel->getPage($config->itemsPerPage);
protected function render(string $templateFile, array $vars = []) {
$templatePath = TEMPLATES_DIR . "/" . $templateFile;
Log::debug("Loaded " . count($this->ticks) . " ticks for feeds");
if (!file_exists($templatePath)) {
throw new RuntimeException("Template not found: $templatePath");
}
extract($vars, EXTR_SKIP);
include $templatePath;
}
public function __construct(){
$config = ConfigModel::load();
$tickModel = new TickModel();
$ticks = iterator_to_array($tickModel->stream($config->itemsPerPage));
$this->vars = [
'config' => $config,
'ticks' => $ticks,
];
}
public function rss(){
$generator = new RssGenerator($this->config, $this->ticks);
Log::debug("Generating RSS feed with " . count($this->ticks) . " ticks");
header('Content-Type: ' . $generator->getContentType());
echo $generator->generate();
$this->render("feed/rss.php", $this->vars);
}
public function atom(){
$generator = new AtomGenerator($this->config, $this->ticks);
Log::debug("Generating Atom feed with " . count($this->ticks) . " ticks");
header('Content-Type: ' . $generator->getContentType());
echo $generator->generate();
$this->render("feed/atom.php", $this->vars);
}
}

View File

@ -4,68 +4,43 @@ class HomeController extends Controller {
// renders the homepage view.
public function index(){
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$data = $this->getHomeData($page);
$this->render("home.php", $data);
}
public function getHomeData(int $page): array {
Log::debug("Loading home page $page");
global $config;
global $user;
$tickModel = new TickModel($this->db, $this->config);
$limit = $this->config->itemsPerPage;
$tickModel = new TickModel();
$limit = $config->itemsPerPage;
$offset = ($page - 1) * $limit;
$ticks = $tickModel->getPage($limit, $offset);
$ticks = iterator_to_array($tickModel->stream($limit, $offset));
$view = new TicksView($this->config, $ticks, $page);
$tickList = $view->getHtml();
$view = new HomeView();
$tickList = $view->renderTicksSection($config->siteDescription, $ticks, $page, $limit);
Log::info("Home page loaded with " . count($ticks) . " ticks");
return [
'config' => $this->config,
'user' => $this->user,
$vars = [
'config' => $config,
'user' => $user,
'tickList' => $tickList,
];
$this->render("home.php", $vars);
}
// POST handler
// Saves the tick and reloads the homepage
public function handleTick(){
$result = $this->processTick($_POST);
if ($_SERVER['REQUEST_METHOD'] === 'POST' and isset($_POST['new_tick'])) {
// save the tick
if (trim($_POST['new_tick'])){
$tickModel = new TickModel();
$tickModel->insert($_POST['new_tick']);
}
}
// get the config
global $config;
// redirect to the index (will show the latest tick if one was sent)
header('Location: ' . Util::buildRelativeUrl($this->config->basePath));
header('Location: ' . $config->basePath);
exit;
}
public function processTick(array $postData): array {
$result = ['success' => false, 'message' => ''];
if (!isset($postData['new_tick'])) {
Log::warning("Tick submission without new_tick field");
$result['message'] = 'No tick content provided';
return $result;
}
$tickContent = trim($postData['new_tick']);
if (empty($tickContent)) {
Log::debug("Empty tick submission ignored");
$result['message'] = 'Empty tick ignored';
return $result;
}
try {
$tickModel = new TickModel($this->db, $this->config);
$tickModel->insert($tickContent);
Log::info("New tick created: " . substr($tickContent, 0, 50) . (strlen($tickContent) > 50 ? '...' : ''));
$result['success'] = true;
$result['message'] = 'Tick saved successfully';
} catch (Exception $e) {
Log::error("Failed to save tick: " . $e->getMessage());
$result['message'] = 'Failed to save tick';
}
return $result;
}
}

View File

@ -1,123 +0,0 @@
<?php
class LogController extends Controller {
private string $storageDir;
public function __construct(PDO $db, ConfigModel $config, UserModel $user, ?string $storageDir = null) {
parent::__construct($db, $config, $user);
$this->storageDir = $storageDir ?? STORAGE_DIR;
}
public function index() {
// Ensure user is logged in
if (!Session::isLoggedIn()) {
global $config;
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login'));
exit;
}
// Get filter parameters
$levelFilter = $_GET['level'] ?? '';
$routeFilter = $_GET['route'] ?? '';
// Get the data for the template
$data = $this->getLogData($levelFilter, $routeFilter);
$this->render('logs.php', $data);
}
public function getLogData(string $levelFilter = '', string $routeFilter = ''): array {
global $config;
$limit = 300; // Show last 300 log entries
// Read and parse log entries
$logEntries = $this->getLogEntries($limit, $levelFilter, $routeFilter);
// Get available routes and levels for filter dropdowns
$availableRoutes = $this->getAvailableRoutes();
$availableLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR'];
return [
'config' => $config,
'logEntries' => $logEntries,
'availableRoutes' => $availableRoutes,
'availableLevels' => $availableLevels,
'currentLevelFilter' => $levelFilter,
'currentRouteFilter' => $routeFilter,
];
}
private function getLogEntries(int $limit, string $levelFilter = '', string $routeFilter = ''): array {
$logFile = $this->storageDir . '/logs/tkr.log';
$entries = [];
// Read from current log file and rotated files
$logFiles = [$logFile];
for ($i = 1; $i <= 5; $i++) {
$rotatedFile = $logFile . '.' . $i;
if (file_exists($rotatedFile)) {
$logFiles[] = $rotatedFile;
}
}
foreach ($logFiles as $file) {
if (file_exists($file)) {
$lines = file($file, FILE_IGNORE_NEW_LINES);
foreach (array_reverse($lines) as $line) {
if (count($entries) >= $limit) break 2;
$entry = $this->parseLogLine($line);
if ($entry && $this->matchesFilters($entry, $levelFilter, $routeFilter)) {
$entries[] = $entry;
}
}
}
}
return $entries;
}
private function parseLogLine(string $line): ?array {
// Parse format: [2025-01-31 08:30:15] DEBUG: 192.168.1.100 [GET feed/rss] - message
$pattern = '/^\[([^\]]+)\] (\w+): ([^\s]+)(?:\s+\[([^\]]+)\])? - (.+)$/';
if (preg_match($pattern, $line, $matches)) {
return [
'timestamp' => $matches[1],
'level' => $matches[2],
'ip' => $matches[3],
'route' => $matches[4] ?? '',
'message' => $matches[5],
'raw' => $line
];
}
return null;
}
private function matchesFilters(array $entry, string $levelFilter, string $routeFilter): bool {
if ($levelFilter && $entry['level'] !== $levelFilter) {
return false;
}
if ($routeFilter && $entry['route'] !== $routeFilter) {
return false;
}
return true;
}
private function getAvailableRoutes(): array {
$routes = [];
$entries = $this->getLogEntries(1000); // Sample more entries to get route list
foreach ($entries as $entry) {
if ($entry['route'] && !in_array($entry['route'], $routes)) {
$routes[] = $entry['route'];
}
}
sort($routes);
return $routes;
}
}

View File

@ -35,7 +35,7 @@
$user = $user->save();
// go back to the index and show the updated mood
header('Location: ' . Util::buildRelativeUrl($config->basePath));
header('Location: ' . $config->basePath);
exit;
}
}

View File

@ -1,56 +0,0 @@
<?php
class AtomGenerator extends FeedGenerator {
public function generate(): string {
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= $this->buildFeed();
Log::debug("Generated Atom feed: " . strlen($xml) . " bytes");
return $xml;
}
public function getContentType(): string {
return 'application/atom+xml; charset=utf-8';
}
private function buildFeed(): string {
Log::debug("Building Atom feed for " . $this->config->siteTitle);
$feedTitle = Util::escape_xml($this->config->siteTitle . " Atom Feed");
$siteUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath));
$feedUrl = Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath, 'feed/atom'));
$updated = date(DATE_ATOM, strtotime($this->ticks[0]['timestamp'] ?? 'now'));
ob_start();
?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title><?php echo $feedTitle ?></title>
<link rel="self"
type="application/atom+xml"
title="<?php echo $feedTitle ?>"
href="<?php echo $feedUrl ?>" />
<link rel="alternate" href="<?php echo $siteUrl ?>"/>
<updated><?php echo $updated ?></updated>
<id><?php echo $siteUrl ?></id>
<author>
<name><?= Util::escape_xml($this->config->siteTitle) ?></name>
</author>
<?php foreach ($this->ticks as $tick):
// build the tick entry components
$tickPath = "tick/" . $tick['id'];
$tickUrl = Util::escape_xml($siteUrl . $tickPath);
$tickTime = date(DATE_ATOM, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']);
$tickContent = Util::linkify($tickTitle);
?>
<entry>
<title><?= $tickTitle ?></title>
<link href="<?= $tickUrl ?>"/>
<id><?= $tickUrl ?></id>
<updated><?= $tickTime ?></updated>
<content type="html"><?= $tickContent ?></content>
</entry>
<?php endforeach; ?>
</feed>
<?php
return ob_get_clean();
}
}

View File

@ -1,24 +0,0 @@
<?php
// Abstract base class for feeds.
// Specific feeds (RSS, Atom, etc.) will inherit from this.
// This will wrap the basic generator functionality.
abstract class FeedGenerator {
protected $config;
protected $ticks;
public function __construct(ConfigModel $config, array $ticks) {
$this->config = $config;
$this->ticks = $ticks;
}
abstract public function generate(): string;
abstract public function getContentType(): string;
protected function buildTickUrl(int $tickId): string {
return Util::buildUrl($this->config->baseUrl, $this->config->basePath, "tick/{$tickId}");
}
protected function getSiteUrl(): string {
return Util::buildUrl($this->config->baseUrl, $this->config->basePath);
}
}

View File

@ -1,50 +0,0 @@
<?php
class RssGenerator extends FeedGenerator {
public function generate(): string {
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$xml .= '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' . "\n";
$xml .= $this->buildChannel();
$xml .= '</rss>' . "\n";
Log::debug("Generated RSS feed: " . strlen($xml) . " bytes");
return $xml;
}
public function getContentType(): string {
return 'application/rss+xml; charset=utf-8';
}
private function buildChannel(): string {
Log::debug("Building RSS channel for " . $this->config->siteTitle);
ob_start();
?>
<channel>
<title><?php echo Util::escape_xml($this->config->siteTitle . ' RSS Feed') ?></title>
<link><?php echo Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath))?></link>
<atom:link href="<?php echo Util::escape_xml(Util::buildUrl($this->config->baseUrl, $this->config->basePath, 'feed/rss'))?>"
rel="self"
type="application/rss+xml" />
<description><?php echo Util::escape_xml($this->config->siteDescription) ?></description>
<language>en-us</language>
<lastBuildDate><?php echo date(DATE_RSS); ?></lastBuildDate>
<?php foreach ($this->ticks as $tick):
// build the tick entry components
$tickPath = "tick/" . $tick['id'];
$tickUrl = Util::escape_xml($this->buildTickUrl($tick['id']));
$tickDate = date(DATE_RSS, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']);
$tickDescription = Util::linkify($tickTitle);
?>
<item>
<title><?php echo $tickTitle ?></title>
<link><?php echo $tickUrl; ?></link>
<description><?php echo $tickDescription; ?></description>
<pubDate><?php echo $tickDate; ?></pubDate>
<guid><?php echo $tickUrl; ?></guid>
</item>
<?php endforeach; ?>
</channel>
<?php
return ob_get_clean();
}
}

View File

@ -1,6 +1,5 @@
<?php
class Database{
// TODO = Make this not static
public static function get(): PDO {
try {
// SQLite will just create this if it doesn't exist.
@ -20,16 +19,20 @@ class Database{
}
public function validate(): void{
$this->validateTables();
$this->validateTableContents();
$this->migrate();
}
// The database version will just be an int
// stored as PRAGMA user_version. It will
// correspond to the most recent migration file applied to the db.
//
// I'm starting from 0, so if the user_version is NULL, I'll return -1.
private function getVersion(): int {
$db = self::get();
return $db->query("PRAGMA user_version")->fetchColumn() ?? 0;
return $db->query("PRAGMA user_version")->fetchColumn() ?? -1;
}
private function migrationNumberFromFile(string $filename): int {
@ -60,7 +63,6 @@ class Database{
foreach ($files as $file) {
$version = $this->migrationNumberFromFile($file);
if ($version > $currentVersion) {
Log::debug("Found pending migration ({$version}): " . basename($file));
$pending[$version] = $file;
}
}
@ -69,15 +71,13 @@ class Database{
return $pending;
}
public function migrate(): void {
private function migrate(): void {
$migrations = $this->getPendingMigrations();
if (empty($migrations)) {
Log::debug("No pending migrations");
# TODO: log
return;
}
Log::info("Found " . count($migrations) . " pending migrations.");
Log::info("Updating database. Current Version: " . $this->getVersion());
$db = self::get();
$db->beginTransaction();
@ -85,7 +85,7 @@ class Database{
try {
foreach ($migrations as $version => $file) {
$filename = basename($file);
Log::debug("Starting migration: {$filename}");
// TODO: log properly
$sql = file_get_contents($file);
if ($sql === false) {
@ -99,20 +99,17 @@ class Database{
// Execute each statement
foreach ($statements as $statement){
if (!empty($statement)){
Log::debug("Migration statement: {$statement}");
$db->exec($statement);
}
}
Log::info("Applied migration {$filename}");
}
// Update db version
$db->commit();
$this->setVersion($version);
//TODO: log properly
//echo "All migrations completed successfully.\n";
Log::info("Applied " . count($migrations) . " migrations.");
Log::info("Updated database version to " . $this->getVersion());
} catch (Exception $e) {
$db->rollBack();
throw new SetupException(
@ -124,16 +121,88 @@ class Database{
}
}
private function createTables(): void {
$db = self::get();
try {
// user table
$db->exec("CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NULL,
about TEXT NULL,
website TEXT NULL,
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,
css_id INTEGER NULL
)");
// css table
$db->exec("CREATE TABLE IF NOT EXISTS css (
id INTEGER PRIMARY KEY,
filename TEXT UNIQUE NOT NULL,
description TEXT NULL
)");
// mood table
$db->exec("CREATE TABLE IF NOT EXISTS emoji(
id INTEGER PRIMARY KEY,
emoji TEXT UNIQUE NOT NULL,
description TEXT NOT NULL
)");
} catch (PDOException $e) {
throw new SetupException(
"Table creation failed: " . $e->getMessage(),
'table_creation',
0,
$e
);
}
}
// make sure all tables exist
// attempt to create them if they don't
private function validateTables(): void {
$appTables = array();
$appTables[] = "settings";
$appTables[] = "user";
$appTables[] = "css";
$appTables[] = "emoji";
$db = self::get();
foreach ($appTables as $appTable){
$stmt = $db->prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?");
$stmt->execute([$appTable]);
if (!$stmt->fetch()){
// At least one table doesn't exist.
// Try creating tables (hacky, but I have 4 total tables)
// Will throw an exception if it fails
$this->createTables();
}
}
}
// make sure tables that need to be seeded have been
public function confirmSetup(): void {
private function validateTableContents(): void {
$db = self::get();
// make sure required tables (user, settings) are populated
$user_count = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn();
$settings_count = (int) $db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
// If either required table has no records, throw an exception.
// This will be caught and redirect to setup.
// If either required table has no records and we aren't on /admin,
// redirect to /admin to complete setup
if ($user_count === 0 || $settings_count === 0){
throw new SetupException(
"Required tables aren't populated. Please complete setup",

View File

@ -12,17 +12,14 @@ class SetupException extends Exception {
// Exceptions don't generally define their own handlers,
// but this is a very specific case.
public function handle(){
// try to log the error, but keep going if it fails
try {
Log::error($this->setupIssue . ", " . $this->getMessage());
} catch (Exception $e) {
// Do nothing and move on to the normal error handling
// We don't want to short-circuit this if there's a problem logging
}
switch ($this->setupIssue){
case 'storage_missing':
case 'storage_permissions':
case 'directory_creation':
case 'directory_permissions':
case 'database_connection':
case 'db_migration':
case 'load_classes':
case 'table_creation':
// Unrecoverable errors.
// Show error message and exit
http_response_code(500);
@ -34,7 +31,7 @@ class SetupException extends Exception {
// Redirect to setup if we aren't already headed there.
// NOTE: Just read directly from init.php instead of
// trying to use the config object. This is the initial
// setup. It shouldn't assume any data can be loaded.
// setup. It shouldn't assume anything can be loaded.
$init = require APP_ROOT . '/config/init.php';
$currentPath = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');

View File

@ -0,0 +1,54 @@
<?php
// Validates that required directories exists
// and files have correct formats
class Filesystem {
public function validate(): void{
$this->validateStorageDir();
$this->validateStorageSubdirs();
}
// Make sure the storage/ directory exists and is writable
private function validateStorageDir(): void{
if (!is_dir(STORAGE_DIR)) {
throw new SetupException(
STORAGE_DIR . "does not exist. Please check your installation.",
'storage_missing'
);
}
if (!is_writable(STORAGE_DIR)) {
throw new SetupException(
STORAGE_DIR . "is not writable. Exiting.",
'storage_permissions'
);
}
}
// validate that the required storage subdirectories exist
// attempt to create them if they don't
private function validateStorageSubdirs(): void {
$storageSubdirs = array();
$storageSubdirs[] = CSS_UPLOAD_DIR;
$storageSubdirs[] = DATA_DIR;
foreach($storageSubdirs as $storageSubdir){
if (!is_dir($storageSubdir)) {
if (!mkdir($storageSubdir, 0770, true)) {
throw new SetupException(
"Failed to create required directory: $dir",
'directory_creation'
);
}
}
if (!is_writable($storageSubdir)) {
if (!chmod($storageSubdir, 0770)) {
throw new SetupException(
"Required directory is not writable: $dir",
'directory_permissions'
);
}
}
}
}
}

View File

@ -1,95 +0,0 @@
<?php
class Log {
const LEVELS = [
'DEBUG' => 1,
'INFO' => 2,
'WARNING' => 3,
'ERROR' => 4
];
private static $logFile;
private static $maxLines = 1000;
private static $maxFiles = 5;
private static $routeContext = '';
public static function init(?string $logFile = null) {
self::$logFile = $logFile ?? STORAGE_DIR . '/logs/tkr.log';
// Ensure log directory exists
// (should be handled by Prerequisites, but doesn't hurt)
$logDir = dirname(self::$logFile);
if (!is_dir($logDir)) {
mkdir($logDir, 0770, true);
}
}
public static function setRouteContext(string $route): void {
self::$routeContext = $route ? "[$route]" : '';
}
public static function debug($message) {
self::write('DEBUG', $message);
}
public static function info($message) {
self::write('INFO', $message);
}
public static function error($message) {
self::write('ERROR', $message);
}
public static function warning($message) {
self::write('WARNING', $message);
}
private static function write($level, $message) {
global $config;
$logLevel = $config->logLevel ?? self::LEVELS['INFO'];
// Only log messages if they're at or above the configured log level.
if (self::LEVELS[$level] < $logLevel){
return;
}
if (!self::$logFile) {
self::init();
}
$timestamp = date('Y-m-d H:i:s');
$context = self::$routeContext ? ' ' . self::$routeContext : '';
$logEntry = "[{$timestamp}] {$level}: " . Util::getClientIp() . "{$context} - {$message}\n";
// Rotate if we're at the max file size (1000 lines)
if (file_exists(self::$logFile)) {
$lineCount = count(file(self::$logFile));
if ($lineCount >= self::$maxLines) {
self::rotate();
Log::info("Log rotated at {$timestamp}");
}
}
file_put_contents(self::$logFile, $logEntry, FILE_APPEND | LOCK_EX);
}
private static function rotate() {
// Rotate existing history files: tkr.4.log -> tkr.5.log, etc.
for ($i = self::$maxFiles - 1; $i >= 1; $i--) {
$oldFile = self::$logFile . '.' . $i;
$newFile = self::$logFile . '.' . ($i + 1);
if (file_exists($oldFile)) {
if ($i == self::$maxFiles - 1) {
unlink($oldFile); // Delete oldest log if we already have 5 files of history
} else {
rename($oldFile, $newFile); // Bump the file number up by one
}
}
}
// Move current active log to .1
if (file_exists(self::$logFile)) {
rename(self::$logFile, self::$logFile . '.1');
}
}
}

View File

@ -1,570 +0,0 @@
<?php
/**
* tkr Prerequisites Checker
*
* This class checks all system requirements for tkr and provides
* detailed logging of any missing components or configuration issues.
*
* ZERO DEPENDENCIES - Uses only core PHP functions available since PHP 5.3
*/
class Prerequisites {
private $checks = array();
private $warnings = array();
private $errors = array();
private $baseDir;
private $logFile;
private $isCli;
private $isWeb;
public function __construct() {
$this->isCli = php_sapi_name() === 'cli';
$this->isWeb = !$this->isCli && isset($_SERVER['HTTP_HOST']);
$this->baseDir = APP_ROOT;
$this->logFile = $this->baseDir . '/storage/prerequisite-check.log';
if ($this->isWeb) {
header('Content-Type: text/html; charset=utf-8');
}
}
/** Log validation output
*
* This introduces a chicken-and-egg problem, because
* if the storage directory isn't writable, this will fail.
* In that case, I'll just write to stdout.
*
*/
private function log($message, $overwrite=false) {
$logDir = dirname($this->logFile);
if (!is_dir($logDir)) {
if (!@mkdir($logDir, 0770, true)) {
// Can't create storage dir - just output, don't log to file
if ($this->isCli) {
echo $message . "\n";
}
return;
}
}
// Overwrite the log if $overwrite is set
// I overwrite the log for each new validation run,
// because prior results are irrelevant.
// This keeps it from growing without bound.
$flags = LOCK_EX;
if (!$overwrite) {
$flags |= FILE_APPEND;
}
// Try to write to log file
if (@file_put_contents($this->logFile, $message . "\n", $flags) === false) {
// Logging failed, but continue - just output to CLI if possible
if ($this->isCli) {
echo "Warning: Could not write to log file\n";
}
}
if ($this->isCli) {
echo $message . "\n";
}
}
// Record the result of a validation check.
private function addCheck($name, $status, $message, $severity = 'info') {
$this->checks[] = array(
'name' => $name,
'status' => $status,
'message' => $message,
'severity' => $severity
);
if ($severity === 'error') {
$this->errors[] = $message;
} elseif ($severity === 'warning') {
$this->warnings[] = $message;
}
$statusIcon = $status ? '✓' : '✗';
$this->log("[{$statusIcon}] {$name}: {$message}");
}
private function checkPhpVersion() {
// TODO - move to bootstrap.php?
$minVersion = '8.2.0';
$currentVersion = PHP_VERSION;
$versionOk = version_compare($currentVersion, $minVersion, '>=');
if ($versionOk) {
$this->addCheck(
'PHP Version',
true,
"PHP {$currentVersion} (meets minimum requirement of {$minVersion})"
);
} else {
$this->addCheck(
'PHP Version',
false,
"PHP {$currentVersion} is below minimum requirement of {$minVersion}",
'error'
);
}
return $versionOk;
}
private function checkRequiredExtensions() {
$requiredExtensions = array('PDO', 'pdo_sqlite');
$allRequired = true;
foreach ($requiredExtensions as $ext) {
$loaded = extension_loaded($ext);
$this->addCheck(
"PHP Extension: {$ext}",
$loaded,
$loaded ? 'Available' : "Missing (REQUIRED) - {$ext}",
$loaded ? 'info' : 'error'
);
if (!$loaded) {
$allRequired = false;
}
}
return $allRequired;
}
private function checkRecommendedExtensions() {
$recommendedExtensions = array('mbstring', 'fileinfo', 'session');
foreach ($recommendedExtensions as $ext) {
$loaded = extension_loaded($ext);
$this->addCheck(
"PHP Extension: {$ext}",
$loaded,
$loaded ? 'Available' : "Missing (recommended) - {$ext}",
$loaded ? 'info' : 'warning'
);
}
}
private function checkDirectoryStructure() {
$baseDir = $this->baseDir;
$requiredDirs = array(
'config' => 'Configuration files',
'public' => 'Web server document root',
'src' => 'Application source code',
'storage' => 'Data storage (must be writable)',
'templates' => 'Template files'
);
$allPresent = true;
foreach ($requiredDirs as $dir => $description) {
$path = $baseDir . '/' . $dir;
$exists = is_dir($path);
$this->addCheck(
"Directory: {$dir}",
$exists,
$exists ? "Present - {$description}" : "Missing - {$description} at {$path}",
$exists ? 'info' : 'error'
);
if (!$exists) {
$allPresent = false;
}
}
return $allPresent;
}
private function checkStoragePermissions() {
// Issue a warning if running as root in CLI context
// Write out guidance for storage directory permissions
// if running the CLI script as root (since it will always appear to be writable)
if ($this->isCli && function_exists('posix_getuid') && posix_getuid() === 0) {
$this->addCheck(
'Root User Warning',
false,
'Running as root - permission checks may be inaccurate. After setup, ensure storage/ is owned by your web server user',
'warning'
);
} elseif ($this->isCli && !function_exists('posix_getuid')) {
$this->addCheck(
'POSIX Extension',
false,
'POSIX extension not available - cannot detect if running as root',
'warning'
);
}
$storageDirs = array(
'storage',
'storage/db',
'storage/logs',
'storage/upload',
'storage/upload/css'
);
$allWritable = true;
foreach ($storageDirs as $dir) {
$path = $this->baseDir . '/' . $dir;
if (!is_dir($path)) {
// Try to create the directory
$created = @mkdir($path, 0770, true);
if ($created) {
$this->addCheck(
"Storage Directory: {$dir}",
true,
"Created with correct permissions (0770)"
);
} else {
$this->addCheck(
"Storage Directory: {$dir}",
false,
"Could not create directory: {$dir}",
'error'
);
$allWritable = false;
continue;
}
}
$writable = is_writable($path);
$permissions = substr(sprintf('%o', fileperms($path)), -4);
$this->addCheck(
"Storage Permissions: {$dir}",
$writable,
$writable ? "Writable (permissions: {$permissions})" : "Not writable (permissions: {$permissions})",
$writable ? 'info' : 'error'
);
if (!$writable) {
$allWritable = false;
}
}
return $allWritable;
}
private function checkWebServerConfig() {
if ($this->isCli) {
$this->addCheck(
'Web Server Test',
false,
'Cannot test web server configuration from CLI - run via web browser',
'warning'
);
return false;
}
// Check if we're being served from the correct document root
$documentRoot = isset($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : '';
$expectedPath = realpath($this->baseDir . '/public');
$correctRoot = ($documentRoot === $expectedPath);
$this->addCheck(
'Document Root',
$correctRoot,
$correctRoot ?
"Correctly set to {$expectedPath}" :
"Should be {$expectedPath}, currently {$documentRoot}",
$correctRoot ? 'info' : 'warning'
);
// Check for URL rewriting
$rewriteWorking = isset($_SERVER['REQUEST_URI']);
$this->addCheck(
'URL Rewriting',
$rewriteWorking,
$rewriteWorking ? 'Available' : 'May not be properly configured',
$rewriteWorking ? 'info' : 'warning'
);
return true;
}
private function checkConfiguration() {
$configFile = $this->baseDir . '/config/init.php';
$configExists = file_exists($configFile);
if (!$configExists) {
$this->addCheck(
'Configuration File',
false,
'config/init.php not found',
'error'
);
return false;
}
try {
$config = include $configFile;
$hasBaseUrl = isset($config['base_url']) && !empty($config['base_url']);
$hasBasePath = isset($config['base_path']) && !empty($config['base_path']);
$this->addCheck(
'Configuration File',
true,
'config/init.php exists and is readable'
);
$this->addCheck(
'Base URL Configuration',
$hasBaseUrl,
$hasBaseUrl ? "Set to: {$config['base_url']}" : 'Not configured',
$hasBaseUrl ? 'info' : 'warning'
);
$this->addCheck(
'Base Path Configuration',
$hasBasePath,
$hasBasePath ? "Set to: {$config['base_path']}" : 'Not configured',
$hasBasePath ? 'info' : 'warning'
);
return $hasBaseUrl && $hasBasePath;
} catch (Exception $e) {
$this->addCheck(
'Configuration File',
false,
'Error reading config/init.php: ' . $e->getMessage(),
'error'
);
return false;
}
}
private function checkDatabase() {
$dbFile = $this->baseDir . '/storage/db/tkr.sqlite';
$dbDir = dirname($dbFile);
if (!is_dir($dbDir)) {
$created = @mkdir($dbDir, 0770, true);
if (!$created) {
$this->addCheck(
'Database Directory',
false,
'Could not create storage/db directory',
'error'
);
return false;
}
}
$canCreateDb = is_writable($dbDir);
$this->addCheck(
'Database Directory',
$canCreateDb,
$canCreateDb ? 'Writable - can create database' : 'Not writable - cannot create database',
$canCreateDb ? 'info' : 'error'
);
if (file_exists($dbFile)) {
$dbReadable = is_readable($dbFile);
$dbWritable = is_writable($dbFile);
$this->addCheck(
'Database File',
$dbReadable && $dbWritable,
$dbReadable && $dbWritable ? 'Exists and is accessible' : 'Exists but has permission issues',
$dbReadable && $dbWritable ? 'info' : 'error'
);
} else {
$this->addCheck(
'Database File',
true,
'Will be created on first run'
);
}
return $canCreateDb;
}
// validate prereqs
// runs on each request and can be run from CLI
public function validate() {
$this->log("=== tkr prerequisites check started at " . date('Y-m-d H:i:s') . " ===", true);
if ($this->isCli) {
$this->log("\n🔍 Validating prerequisites...\n");
}
$results = array(
'php_version' => $this->checkPhpVersion(),
'critical_extensions' => $this->checkRequiredExtensions(),
'directory_structure' => $this->checkDirectoryStructure(),
'storage_permissions' => $this->checkStoragePermissions(),
'web_server' => $this->checkWebServerConfig(),
'configuration' => $this->checkConfiguration(),
'database' => $this->checkDatabase()
);
// Check recommended extensions too
$this->checkRecommendedExtensions();
if ($this->isCli) {
$this->generateCliSummary($results);
}
return $results;
}
/**
* Display web-friendly error page when minimum requirements aren't met
*/
public function generateWebSummary() {
echo '<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>tkr - Setup Required</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 2rem; line-height: 1.6; background: #f8f9fa; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { text-align: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 2px solid #dee2e6; }
.header h1 { color: #dc3545; margin: 0; }
.header p { color: #6c757d; margin: 0.5rem 0 0 0; }
.error-item { margin: 1rem 0; padding: 1rem; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; border-left: 4px solid #dc3545; }
.warning-item { margin: 1rem 0; padding: 1rem; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 4px; border-left: 4px solid #ffc107; }
.error-title { font-weight: 600; color: #721c24; margin-bottom: 0.5rem; }
.warning-title { font-weight: 600; color: #856404; margin-bottom: 0.5rem; }
.resolution { margin-top: 2rem; padding: 1rem; background: #e9ecef; border-radius: 4px; }
.resolution h3 { margin-top: 0; color: #495057; }
.resolution ul { margin: 0; }
.resolution li { margin: 0.5rem 0; }
.log-info { margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 4px; font-size: 0.9em; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚠️ Setup Required</h1>
<p>tkr cannot start due to system configuration issues</p>
</div>';
$hasErrors = false;
$hasWarnings = false;
// Display errors
foreach ($this->checks as $check) {
if (!$check['status'] && $check['severity'] === 'error') {
if (!$hasErrors) {
echo '<h2>Critical Issues</h2>';
$hasErrors = true;
}
echo '<div class="error-item">
<div class="error-title"> ' . htmlspecialchars($check['name']) . '</div>
' . htmlspecialchars($check['message']) . '
</div>';
}
}
// Display warnings
foreach ($this->checks as $check) {
if (!$check['status'] && $check['severity'] === 'warning') {
if (!$hasWarnings) {
echo '<h2>Warnings</h2>';
$hasWarnings = true;
}
echo '<div class="warning-item">
<div class="warning-title"> ' . htmlspecialchars($check['name']) . '</div>
' . htmlspecialchars($check['message']) . '
</div>';
}
}
// Resolution steps
echo '<div class="resolution">
<h3>How to Fix These Issues</h3>
<ul>';
if (!version_compare(PHP_VERSION, '8.2.0', '>=')) {
echo '<li><strong>PHP Version:</strong> Contact your hosting provider to upgrade PHP to version 8.2 or higher</li>';
}
if (!extension_loaded('PDO') || !extension_loaded('pdo_sqlite')) {
echo '<li><strong>SQLite Support:</strong> Contact your hosting provider to enable PDO and PDO_SQLITE extensions</li>';
}
if (count($this->errors) > 0) {
echo '<li><strong>File Permissions:</strong> Ensure the storage directory and subdirectories are writable by the web server</li>';
echo '<li><strong>Missing Directories:</strong> Upload the complete tkr application with all required directories</li>';
}
echo ' </ul>
<p><strong>Need Help?</strong> Check the tkr documentation or contact your hosting provider with the error details above.</p>
</div>
<div class="log-info">
<p><strong>Technical Details:</strong> Full diagnostic information has been logged to ' . htmlspecialchars($this->logFile) . '</p>
<p><strong>Check Time:</strong> ' . date('Y-m-d H:i:s') . '</p>
</div>
</div>
</body>
</html>';
}
private function generateCliSummary($results) {
$this->log("\n" . str_repeat("=", 60));
$this->log("PREREQUISITE CHECK SUMMARY");
$this->log(str_repeat("=", 60));
$totalChecks = count($this->checks);
$passedChecks = 0;
foreach ($this->checks as $check) {
if ($check['status']) {
$passedChecks++;
}
}
$this->log("Total checks: {$totalChecks}");
$this->log("Passed: {$passedChecks}");
$this->log("Errors: " . count($this->errors));
$this->log("Warnings: " . count($this->warnings));
if (count($this->errors) === 0) {
$this->log("\n✅ ALL PREREQUISITES SATISFIED");
$this->log("tkr should install and run successfully.");
} else {
$this->log("\n❌ CRITICAL ISSUES FOUND");
$this->log("The following issues must be resolved before installing tkr:");
foreach ($this->errors as $error) {
$this->log("{$error}");
}
}
if (count($this->warnings) > 0) {
$this->log("\n⚠️ WARNINGS:");
foreach ($this->warnings as $warning) {
$this->log("{$warning}");
}
}
if ($this->isCli && function_exists('posix_getuid') && posix_getuid() === 0) {
$this->log("\n📋 ROOT USER SETUP RECOMMENDATIONS:");
$this->log("After uploading to your web server,");
$this->log("make sure the storage directory is writable by the web server user by running:");
$this->log(" chown -R www-data:www-data storage/ # Debian/Ubuntu");
$this->log(" chown -R apache:apache storage/ # RHEL/CentOS/Fedora");
$this->log(" chmod -R 770 storage/ # Ensure writability");
}
$this->log("\n📝 Full log saved to: " . $this->logFile);
$this->log("=== Check completed at " . date('Y-m-d H:i:s') . " ===");
}
/**
* Get array of errors for external use
*/
public function getErrors() {
return $this->errors;
}
/**
* Get array of warnings for external use
*/
public function getWarnings() {
return $this->warnings;
}
}

View File

@ -1,8 +1,6 @@
<?php
// Very simple router class
class Router {
public function __construct(private PDO $db, private ConfigModel $config, private UserModel $user) {}
// Define the recognized routes.
// Anything else will 404.
private static $routeHandlers = [
@ -14,7 +12,6 @@ class Router {
['admin/css', 'CssController@handlePost', ['POST']],
['admin/emoji', 'EmojiController'],
['admin/emoji', 'EmojiController@handlePost', ['POST']],
['admin/logs', 'LogController'],
['feed/rss', 'FeedController@rss'],
['feed/atom', 'FeedController@atom'],
['login', 'AuthController@showLogin'],
@ -30,7 +27,7 @@ class Router {
// Main router function
public function route(string $requestPath, string $requestMethod): bool {
public static function route(string $requestPath, string $requestMethod): bool {
foreach (self::$routeHandlers as $routeHandler) {
$routePattern = $routeHandler[0];
$controller = $routeHandler[1];
@ -42,33 +39,27 @@ class Router {
$routePattern = '#^' . $routePattern . '$#';
if (preg_match($routePattern, $requestPath, $matches)) {
Log::debug("Request path: '{$requestPath}', Controller {$controller}, Methods: ". implode(',' , $methods));
if (in_array($requestMethod, $methods)){
// Save any path elements we're interested in
// (but discard the match on the entire path)
array_shift($matches);
Log::debug("Captured path elements: " . implode(',', $matches));
if (strpos($controller, '@')) {
// Get the controller and method that handle this route
[$controllerName, $functionName] = explode('@', $controller);
[$controllerName, $methodName] = explode('@', $controller);
} else {
// Default to 'index' if no method specified
$controllerName = $controller;
$functionName = 'index';
$methodName = 'index';
}
Log::debug("Handling request with Controller {$controllerName} and function {$functionName}");
$instance = new $controllerName($this->db, $this->config, $this->user);
call_user_func_array([$instance, $functionName], $matches);
$instance = new $controllerName();
call_user_func_array([$instance, $methodName], $matches);
return true;
}
}
}
Log::warning("No route found for path '{$requestPath}'");
return false;
}

View File

@ -6,30 +6,10 @@ class Session {
// global $_SESSION associative array
public static function start(): void{
if (session_status() === PHP_SESSION_NONE) {
$existingSessionId = $_COOKIE['PHPSESSID'] ?? null;
session_start();
if ($existingSessionId && session_id() === $existingSessionId) {
Log::debug("Resumed existing login session: " . session_id());
} else {
Log::debug("Created new login session: " . session_id());
}
} else {
Log::debug('Session already active in this request: ' . session_id());
}
}
public static function newLoginSession(Array $user){
Log::debug("Starting new login session for {$user['username']}");
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
self::generateCsrfToken(true);
Log::debug("Started new login session for {$user['username']}");
}
public static function generateCsrfToken(bool $regenerate = false): void{
if (!isset($_SESSION['csrf_token']) || $regenerate) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
@ -79,7 +59,6 @@ class Session {
}
public static function end(): void {
Log::debug("Ending session: " . session_id());
$_SESSION = [];
session_destroy();
}

View File

@ -1,13 +1,5 @@
<?php
class Util {
public static function getClientIp() {
return $_SERVER['HTTP_CLIENT_IP'] ??
$_SERVER['HTTP_X_FORWARDED_FOR'] ??
$_SERVER['HTTP_X_REAL_IP'] ??
$_SERVER['REMOTE_ADDR'] ??
'unknown';
}
public static function escape_html(string $text): string {
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
@ -62,45 +54,4 @@ class Util {
}
return $diff->s . ' second' . ($diff->s != 1 ? 's' : '') . ' ago';
}
public static function buildUrl(string $baseUrl, string $basePath, string $path = ''): string {
// Normalize baseUrl (remove trailing slash)
$baseUrl = rtrim($baseUrl, '/');
// Normalize basePath (ensure leading slash, remove trailing slash unless it's just '/')
if ($basePath === '' || $basePath === '/') {
$basePath = '/';
} else {
$basePath = '/' . trim($basePath, '/') . '/';
}
// Normalize path (remove leading slash if present)
$path = ltrim($path, '/');
return $baseUrl . $basePath . $path;
}
public static function buildRelativeUrl(string $basePath, string $path = ''): string {
// Ensure basePath starts with / for relative URLs
$basePath = '/' . ltrim($basePath, '/');
// Remove trailing slash unless it's just '/'
if ($basePath !== '/') {
$basePath = rtrim($basePath, '/');
}
// Add path
$path = ltrim($path, '/');
if ($path === '') {
return $basePath;
}
// If basePath is root, don't add extra slash
if ($basePath === '/') {
return '/' . $path;
}
return $basePath . '/' . $path;
}
}

View File

@ -9,32 +9,22 @@ class ConfigModel {
public string $timezone = 'relative';
public ?int $cssId = null;
public bool $strictAccessibility = true;
public ?int $logLevel = null;
public function __construct(private PDO $db) {}
// load config from sqlite database (backward compatibility)
// load config from sqlite database
public static function load(): self {
global $db;
$instance = new self($db);
return $instance->loadFromDatabase();
}
// Instance method that uses injected database
public function loadFromDatabase(): self {
$init = require APP_ROOT . '/config/init.php';
$c = new self($this->db);
$c = new self();
$c->baseUrl = ($c->baseUrl === '') ? $init['base_url'] : $c->baseUrl;
$c->basePath = ($c->basePath === '') ? $init['base_path'] : $c->basePath;
$stmt = $this->db->query("SELECT site_title,
global $db;
$stmt = $db->query("SELECT site_title,
site_description,
base_url,
base_path,
items_per_page,
css_id,
strict_accessibility,
log_level
strict_accessibility
FROM settings WHERE id=1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
@ -47,7 +37,6 @@ class ConfigModel {
$c->itemsPerPage = (int) $row['items_per_page'];
$c->cssId = (int) $row['css_id'];
$c->strictAccessibility = (bool) $row['strict_accessibility'];
$c->logLevel = $row['log_level'];
}
return $c;
@ -66,10 +55,11 @@ class ConfigModel {
}
public function save(): self {
$settingsCount = (int) $this->db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
global $db;
$settingsCount = (int) $db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
if ($settingsCount === 0){
$stmt = $this->db->prepare("INSERT INTO settings (
$stmt = $db->prepare("INSERT INTO settings (
id,
site_title,
site_description,
@ -78,22 +68,19 @@ class ConfigModel {
items_per_page,
css_id,
strict_accessibility,
log_level
)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)");
VALUES (1, ?, ?, ?, ?, ?, ?, ?)");
} else {
$stmt = $this->db->prepare("UPDATE settings SET
$stmt = $db->prepare("UPDATE settings SET
site_title=?,
site_description=?,
base_url=?,
base_path=?,
items_per_page=?,
css_id=?,
strict_accessibility=?,
log_level=?
strict_accessibility=?
WHERE id=1");
}
$stmt->execute([$this->siteTitle,
$this->siteDescription,
$this->baseUrl,
@ -101,9 +88,8 @@ class ConfigModel {
$this->itemsPerPage,
$this->cssId,
$this->strictAccessibility,
$this->logLevel
]);
return $this->loadFromDatabase();
return self::load();
}
}

View File

@ -1,24 +1,33 @@
<?php
class TickModel {
public function __construct(private PDO $db, private ConfigModel $config) {}
public function getPage(int $limit, int $offset = 0): array {
$stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?");
public function stream(int $limit, int $offset = 0): Generator {
global $db;
$stmt = $db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?");
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield [
'id' => $row['id'],
'timestamp' => $row['timestamp'],
'tick' => $row['tick'],
];
}
}
public function insert(string $tick, ?DateTimeImmutable $datetime = null): void {
global $db;
$datetime ??= new DateTimeImmutable('now', new DateTimeZone('UTC'));
$timestamp = $datetime->format('Y-m-d H:i:s');
$stmt = $this->db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)");
$stmt = $db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)");
$stmt->execute([$timestamp, $tick]);
}
public function get(int $id): array {
$stmt = $this->db->prepare("SELECT timestamp, tick FROM tick WHERE id=?");
global $db;
$stmt = $db->prepare("SELECT timestamp, tick FROM tick WHERE id=?");
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
@ -26,7 +35,7 @@ class TickModel {
return [
'tickTime' => $row['timestamp'],
'tick' => $row['tick'],
'config' => $this->config,
'config' => ConfigModel::load(),
];
}
}

View File

@ -6,21 +6,14 @@ class UserModel {
public string $website = '';
public string $mood = '';
public function __construct(private PDO $db) {}
// load user settings from sqlite database (backward compatibility)
// load user settings from sqlite database
public static function load(): self {
global $db;
$instance = new self($db);
return $instance->loadFromDatabase();
}
// Instance method that uses injected database
public function loadFromDatabase(): self {
// There's only ever one user. I'm just leaning into that.
$stmt = $this->db->query("SELECT username, display_name, website, mood FROM user WHERE id=1");
$stmt = $db->query("SELECT username, display_name, website, mood FROM user WHERE id=1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$u = new self($this->db);
$u = new self();
if ($row) {
$u->username = $row['username'];
@ -33,32 +26,27 @@ class UserModel {
}
public function save(): self {
$userCount = (int) $this->db->query("SELECT COUNT(*) FROM user")->fetchColumn();
global $db;
$userCount = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn();
if ($userCount === 0){
$stmt = $this->db->prepare("INSERT INTO user (id, username, display_name, website, mood) VALUES (1, ?, ?, ?, ?)");
$stmt = $db->prepare("INSERT INTO user (id, username, display_name, website, mood) VALUES (1, ?, ?, ?, ?, ?)");
} else {
$stmt = $this->db->prepare("UPDATE user SET username=?, display_name=?, website=?, mood=? WHERE id=1");
$stmt = $db->prepare("UPDATE user SET username=?, display_name=?, website=?, mood=? WHERE id=1");
}
$stmt->execute([$this->username, $this->displayName, $this->website, $this->mood]);
return $this->loadFromDatabase();
return self::load();
}
// Making this a separate function to avoid
// loading the password into memory
public function setPassword(string $password): void {
public function set_password(string $password): void {
global $db;
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $this->db->prepare("UPDATE user SET password_hash=? WHERE id=1");
$stmt = $db->prepare("UPDATE user SET password_hash=? WHERE id=1");
$stmt->execute([$hash]);
}
public function getByUsername($username){
$stmt = $this->db->prepare("SELECT id, username, password_hash FROM user WHERE username = ?");
$stmt->execute([$username]);
$record = $stmt->fetch();
return $record;
}
}

View File

@ -1,16 +1,7 @@
<?php
class TicksView {
private $html;
public function __construct(ConfigModel $config, array $ticks, int $page){
$this->html = $this->render($config, $ticks, $page);
}
public function getHtml(): string {
return $this->html;
}
private function render(ConfigModel $config, array $ticks, int $page): string{
class HomeView {
public function renderTicksSection(string $siteDescription, array $ticks, int $page, int $limit){
global $config;
ob_start();
?>
@ -31,7 +22,7 @@ class TicksView {
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="?page=<?php echo $page - 1 ?>">&laquo; Newer</a>
<?php endif; ?>
<?php if (count($ticks) === $config->itemsPerPage): ?>
<?php if (count($ticks) === $limit): ?>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="?page=<?php echo $page + 1 ?>">Older &raquo;</a>
<?php endif; ?>

40
templates/feed/atom.php Normal file
View File

@ -0,0 +1,40 @@
<?php /** @var ConfigModel $config */ ?>
<?php /** @var array $ticks */ ?>
<?php
$feedTitle = Util::escape_xml("$config->siteTitle Atom Feed");
$siteUrl = Util::escape_xml($config->baseUrl . $config->basePath);
$feedUrl = Util::escape_xml($config->baseUrl . $config->basePath . 'feed/atom');
$updated = date(DATE_ATOM, strtotime($ticks[0]['timestamp'] ?? 'now'));
header('Content-Type: application/atom+xml; charset=utf-8');
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n";
?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title><?php echo $feedTitle ?></title>
<link rel="self"
type="application/atom+xml"
title="<?php echo $feedTitle ?>"
href="<?php echo $feedUrl ?>" />
<link rel="alternate" href="<?php echo $siteUrl ?>"/>
<updated><?php echo $updated ?></updated>
<id><?php echo $siteUrl ?></id>
<author>
<name><?= Util::escape_xml($config->siteTitle) ?></name>
</author>
<?php foreach ($ticks as $tick):
// build the tick entry components
$tickPath = "tick/" . $tick['id'];
$tickUrl = Util::escape_xml($siteUrl . $basePath . $tickPath);
$tickTime = date(DATE_ATOM, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']);
$tickContent = Util::linkify($tickTitle);
?>
<entry>
<title><?= $tickTitle ?></title>
<link href="<?= $tickUrl ?>"/>
<id><?= $tickUrl ?></id>
<updated><?= $tickTime ?></updated>
<content type="html"><?= $tickContent ?></content>
</entry>
<?php endforeach; ?>
</feed>

39
templates/feed/rss.php Normal file
View File

@ -0,0 +1,39 @@
<?php /** @var ConfigModel $config */ ?>
<?php /** @var array $ticks */ ?>
<?php
// Need to have a little php here because the starting xml tag
// will mess up the PHP parser.
// TODO - I think short php tags can be disabled to prevent that.
header('Content-Type: application/rss+xml; charset=utf-8');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title><?php echo Util::escape_xml($config->siteTitle . 'RSS Feed') ?></title>
<link><?php echo Util::escape_xml($config->baseUrl . $config->basePath)?></link>
<atom:link href="<?php echo Util::escape_xml($config->baseUrl . $config->basePath. 'feed/rss')?>"
rel="self"
type="application/rss+xml" />
<description><?php echo Util::escape_xml($config->siteDescription) ?></description>
<language>en-us</language>
<lastBuildDate><?php echo date(DATE_RSS); ?></lastBuildDate>
<?php foreach ($ticks as $tick):
// build the tick entry components
//$tickPath = "tick/$year/$month/$day/$hour/$minute/$second";
$tickPath = "tick/" . $tick['id'];
$tickUrl = Util::escape_xml($config->baseUrl . $config->basePath . $tickPath);
$tickDate = date(DATE_RSS, strtotime($tick['timestamp']));
$tickTitle = Util::escape_xml($tick['tick']);
$tickDescription = Util::linkify($tickTitle);
?>
<item>
<title><?php echo $tickTitle ?></title>
<link><?php echo $tickUrl; ?></link>
<description><?php echo $tickDescription; ?></description>
<pubDate><?php echo $tickDate; ?></pubDate>
<guid><?php echo $tickUrl; ?></guid>
</item>
<?php endforeach; ?>
</channel>
</rss>

View File

@ -10,10 +10,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet"
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/default.css')) ?>">
href="<?= Util::escape_html($config->basePath) ?>css/default.css">
<?php if (!empty($config->cssId)): ?>
<link rel="stylesheet"
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/custom/' . $config->customCssFilename())) ?>">
href="<?= Util::escape_html($config->basePath) ?>css/custom/<?= Util::escape_html($config->customCssFilename()) ?>">
<?php endif; ?>
<link rel="alternate"
type="application/rss+xml"

View File

@ -4,7 +4,7 @@
<h1><?php if ($isSetup): ?>Setup<?php else: ?>Admin<?php endif; ?></h1>
<main>
<form
action="<?php echo Util::buildRelativeUrl($config->basePath, ($isSetup ? 'setup' : 'admin')) ?>"
action="<?php echo $config->basePath . ($isSetup ? 'setup' : 'admin') ?>"
method="post">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset>
@ -67,13 +67,6 @@
name="strict_accessibility"
value="1"
<?php if ($config->strictAccessibility): ?> checked <?php endif; ?>>
<label for="strict_accessibility">Log Level</label>
<select id="log_level" name="log_level">
<option value="1" <?= ($config->logLevel ?? 2) == 1 ? 'selected' : '' ?>>DEBUG</option>
<option value="2" <?= ($config->logLevel ?? 2) == 2 ? 'selected' : '' ?>>INFO</option>
<option value="3" <?= ($config->logLevel ?? 2) == 3 ? 'selected' : '' ?>>WARNING</option>
<option value="4" <?= ($config->logLevel ?? 2) == 4 ? 'selected' : '' ?>>ERROR</option>
</select>
</div>
</fieldset>
<fieldset>

View File

@ -2,7 +2,7 @@
<?php /** @var Array $customCss */ ?>
<h1>CSS Management</h1>
<main>
<form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/css') ?>" method="post" enctype="multipart/form-data">
<form action="<?= $config->basePath ?>admin/css" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset>
<legend>Manage</legend>

View File

@ -2,7 +2,7 @@
<?php /** @var array $emojiList */ ?>
<h1>Emoji Management</h1>
<main>
<form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data">
<form action="<?= $config->basePath ?>admin/emoji" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset>
<legend>Add Emoji</legend>
@ -24,7 +24,7 @@
</fieldset>
</form>
<?php if (!empty($emojiList)): ?>
<form action="<?= Util::buildRelativeUrl($config->basePath, 'admin/emoji') ?>" method="post" enctype="multipart/form-data">
<form action="<?= $config->basePath ?>admin/emoji" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset class="delete-emoji-fieldset">
<legend>Delete Emoji</legend>

View File

@ -14,7 +14,7 @@
<?php if (Session::isLoggedIn()): ?>
<a
<?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'mood')) ?>"
href="<?= Util::escape_html($config->basePath) ?>mood"
class="change-mood">Change mood</a>
<?php endif ?>
</dd>

View File

@ -2,7 +2,7 @@
<?php /** @var string $csrf_token */ ?>
<?php /** @var string $error */ ?>
<h2>Login</h2>
<form method="post" action="<?= Util::buildRelativeUrl($config->basePath, 'login') ?>">
<form method="post" action="<?= $config->basePath ?>login">
<div class="fieldset-items">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($csrf_token) ?>">
<label for="username">Username:</label>

View File

@ -1,90 +0,0 @@
<?php /** @var ConfigModel $config */ ?>
<?php /** @var array $logEntries */ ?>
<?php /** @var array $availableRoutes */ ?>
<?php /** @var array $availableLevels */ ?>
<?php /** @var string $currentLevelFilter */ ?>
<?php /** @var string $currentRouteFilter */ ?>
<h1>System Logs</h1>
<main>
<!-- Filters -->
<div class="log-filters">
<form method="get" action="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>">
<fieldset>
<legend>Filter Logs</legend>
<div class="fieldset-items">
<label for="level-filter">Level:</label>
<select id="level-filter" name="level">
<option value="">All Levels</option>
<?php foreach ($availableLevels as $level): ?>
<option value="<?= Util::escape_html($level) ?>"
<?= $currentLevelFilter === $level ? 'selected' : '' ?>>
<?= Util::escape_html($level) ?>
</option>
<?php endforeach; ?>
</select>
<label for="route-filter">Route:</label>
<select id="route-filter" name="route">
<option value="">All Routes</option>
<?php foreach ($availableRoutes as $route): ?>
<option value="<?= Util::escape_html($route) ?>"
<?= $currentRouteFilter === $route ? 'selected' : '' ?>>
<?= Util::escape_html($route) ?>
</option>
<?php endforeach; ?>
</select>
<div></div><button type="submit">Filter</button>
<div></div><a href="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>">Clear</a>
</div>
</fieldset>
</form>
</div>
<!-- Log entries table -->
<div class="log-entries">
<?php if (empty($logEntries)): ?>
<p>No log entries found matching the current filters.</p>
<?php else: ?>
<table class="log-table">
<thead>
<tr>
<th>Time</th>
<th>Level</th>
<th>IP</th>
<th>Route</th>
<th>Message</th>
</tr>
</thead>
<tbody>
<?php foreach ($logEntries as $entry): ?>
<tr class="log-entry log-<?= strtolower($entry['level']) ?>">
<td class="log-timestamp log-monospace">
<time datetime="<?= Util::escape_html($entry['timestamp']) ?>">
<?= Util::escape_html($entry['timestamp']) ?>
</time>
</td>
<td class="log-level">
<span class="log-level-badge"><?= Util::escape_html($entry['level']) ?></span>
</td>
<td class="log-ip log-monospace"><?= Util::escape_html($entry['ip']) ?></td>
<td class="log-route log-monospace">
<?php if ($entry['route']): ?>
<?= Util::escape_html($entry['route']) ?>
<?php else: ?>
<span class="log-no-route">-</span>
<?php endif; ?>
</td>
<td class="log-message log-monospace"><?= Util::escape_html($entry['message']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<div class="log-info">
<p>Showing <?= count($logEntries) ?> recent log entries.
Log files are automatically rotated when they reach 1000 lines.</p>
</div>
</main>

View File

@ -2,34 +2,32 @@
<?php /* https://www.w3schools.com/howto/howto_css_dropdown.asp */ ?>
<nav aria-label="Main navigation">
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath)) ?>">home</a>
href="<?= Util::escape_html($config->basePath) ?>">home</a>
<details>
<summary aria-haspopup="true">feeds</summary>
<div class="dropdown-items">
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/rss')) ?>">rss</a>
href="<?= Util::escape_html($config->basePath) ?>feed/rss">rss</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/atom')) ?>">atom</a>
href="<?= Util::escape_html($config->basePath) ?>feed/atom">atom</a>
</div>
</details>
<?php if (!Session::isLoggedIn()): ?>
<a tabindex="0"
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'login')) ?>">login</a>
href="<?= Util::escape_html($config->basePath) ?>login">login</a>
<?php else: ?>
<details>
<summary aria-haspopup="true">admin</summary>
<div class="dropdown-items">
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin')) ?>">settings</a>
href="<?= Util::escape_html($config->basePath) ?>admin">settings</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a>
href="<?= Util::escape_html($config->basePath) ?>admin/css">css</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/emoji')) ?>">emoji</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/logs')) ?>">logs</a>
href="<?= Util::escape_html($config->basePath) ?>admin/emoji">emoji</a>
</div>
</details>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'logout')) ?>">logout</a>
href="<?= Util::escape_html($config->basePath) ?>logout">logout</a>
<?php endif; ?>
</nav>

View File

@ -1,367 +0,0 @@
<?php
require_once dirname(dirname(dirname(__DIR__))) . "/config/bootstrap.php";
use PHPUnit\Framework\TestCase;
class AdminControllerTest extends TestCase
{
private PDO $mockPdo;
private ConfigModel $config;
private UserModel $user;
private string $tempLogDir;
protected function setUp(): void
{
// Set up temporary logging
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
mkdir($this->tempLogDir . '/logs', 0777, true);
Log::init($this->tempLogDir . '/logs/tkr.log');
// Set up global config for logging level (DEBUG = 1)
global $config;
$config = new stdClass();
$config->logLevel = 1; // Allow DEBUG level logs
// Create mock PDO (needed for base constructor)
$this->mockPdo = $this->createMock(PDO::class);
// Create real config and user objects with mocked PDO
$this->config = new ConfigModel($this->mockPdo);
$this->config->siteTitle = 'Test Site';
$this->config->siteDescription = 'Test Description';
$this->config->baseUrl = 'https://example.com';
$this->config->basePath = '/tkr';
$this->config->itemsPerPage = 10;
$this->user = new UserModel($this->mockPdo);
$this->user->username = 'testuser';
$this->user->displayName = 'Test User';
$this->user->website = 'https://example.com';
}
protected function tearDown(): void
{
// Clean up temp directory
if (is_dir($this->tempLogDir)) {
$this->deleteDirectory($this->tempLogDir);
}
}
private function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
public function testGetAdminDataRegularMode(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$data = $controller->getAdminData(false);
// Should return proper structure
$this->assertArrayHasKey('config', $data);
$this->assertArrayHasKey('user', $data);
$this->assertArrayHasKey('isSetup', $data);
// Should be the injected instances
$this->assertSame($this->config, $data['config']);
$this->assertSame($this->user, $data['user']);
$this->assertFalse($data['isSetup']);
}
public function testGetAdminDataSetupMode(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$data = $controller->getAdminData(true);
// Should return proper structure
$this->assertArrayHasKey('config', $data);
$this->assertArrayHasKey('user', $data);
$this->assertArrayHasKey('isSetup', $data);
// Should be the injected instances
$this->assertSame($this->config, $data['config']);
$this->assertSame($this->user, $data['user']);
$this->assertTrue($data['isSetup']);
}
public function testProcessSettingsSaveWithEmptyData(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$result = $controller->processSettingsSave([], false);
$this->assertFalse($result['success']);
$this->assertContains('No data provided', $result['errors']);
}
public function testProcessSettingsSaveValidationErrors(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
// Test data with multiple validation errors
$postData = [
'username' => '', // Missing username
'display_name' => '', // Missing display name
'website' => 'invalid-url', // Invalid URL
'site_title' => '', // Missing site title
'base_url' => '', // Missing base URL
'base_path' => 'invalid', // Invalid base path
'items_per_page' => 100, // Too high
'password' => 'test123',
'confirm_password' => 'different' // Passwords don't match
];
$result = $controller->processSettingsSave($postData, false);
$this->assertFalse($result['success']);
$this->assertNotEmpty($result['errors']);
// Should have multiple validation errors
$this->assertGreaterThan(5, count($result['errors']));
}
public function testProcessSettingsSaveValidData(): void
{
// Mock PDO to simulate successful database operations
$mockStatement = $this->createMock(PDOStatement::class);
$mockStatement->method('execute')->willReturn(true);
$mockStatement->method('fetchColumn')->willReturn(1); // Existing record count
$mockStatement->method('fetch')->willReturnOnConsecutiveCalls(
[
'site_title' => 'Updated Site',
'site_description' => 'Updated Description',
'base_url' => 'https://updated.com',
'base_path' => '/updated',
'items_per_page' => 15,
'css_id' => null,
'strict_accessibility' => true,
'log_level' => 2
],
[
'username' => 'newuser',
'display_name' => 'New User',
'website' => 'https://example.com',
'mood' => ''
]
);
$this->mockPdo->method('prepare')->willReturn($mockStatement);
$this->mockPdo->method('query')->willReturn($mockStatement);
// Create models with mocked PDO
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
$postData = [
'username' => 'newuser',
'display_name' => 'New User',
'website' => 'https://example.com',
'site_title' => 'Updated Site',
'site_description' => 'Updated Description',
'base_url' => 'https://updated.com',
'base_path' => '/updated',
'items_per_page' => 15,
'strict_accessibility' => 'on',
'log_level' => 2
];
$result = $controller->processSettingsSave($postData, false);
$this->assertTrue($result['success']);
$this->assertEmpty($result['errors']);
}
public function testProcessSettingsSaveWithPassword(): void
{
// Mock PDO for successful save operations
$mockStatement = $this->createMock(PDOStatement::class);
$mockStatement->method('execute')->willReturn(true);
$mockStatement->method('fetchColumn')->willReturn(1);
$mockStatement->method('fetch')->willReturnOnConsecutiveCalls(
[
'site_title' => 'Test Site',
'site_description' => 'Test Description',
'base_url' => 'https://example.com',
'base_path' => '/tkr',
'items_per_page' => 10,
'css_id' => null,
'strict_accessibility' => true,
'log_level' => 2
],
[
'username' => 'testuser',
'display_name' => 'Test User',
'website' => '',
'mood' => ''
]
);
// Verify password hash is called
$this->mockPdo->expects($this->atLeastOnce())
->method('prepare')
->willReturn($mockStatement);
$this->mockPdo->method('query')->willReturn($mockStatement);
// Create models with mocked PDO
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
$postData = [
'username' => 'testuser',
'display_name' => 'Test User',
'site_title' => 'Test Site',
'site_description' => 'Test Description',
'base_url' => 'https://example.com',
'base_path' => '/tkr',
'items_per_page' => 10,
'password' => 'newpassword',
'confirm_password' => 'newpassword'
];
$result = $controller->processSettingsSave($postData, false);
$this->assertTrue($result['success']);
}
public function testProcessSettingsSaveDatabaseError(): void
{
// Mock PDO to throw exception on save
$this->mockPdo->method('query')
->willThrowException(new PDOException("Database error"));
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
$postData = [
'username' => 'testuser',
'display_name' => 'Test User',
'site_title' => 'Test Site',
'site_description' => 'Test Description',
'base_url' => 'https://example.com',
'base_path' => '/tkr',
'items_per_page' => 10
];
$result = $controller->processSettingsSave($postData, false);
$this->assertFalse($result['success']);
$this->assertContains('Failed to save settings', $result['errors']);
}
public function testLoggingOnAdminPageLoad(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller->getAdminData(false);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Loading admin page', $logContent);
}
public function testLoggingOnSetupPageLoad(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$controller->getAdminData(true);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Loading admin page (setup mode)', $logContent);
}
public function testLoggingOnValidationErrors(): void
{
$controller = new AdminController($this->mockPdo, $this->config, $this->user);
$postData = [
'username' => '', // Will cause validation error
'display_name' => 'Test User',
'site_title' => 'Test Site',
'base_url' => 'https://example.com',
'base_path' => '/tkr',
'items_per_page' => 10
];
$controller->processSettingsSave($postData, false);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Settings validation failed', $logContent);
$this->assertStringContainsString('Validation error: Username is required', $logContent);
}
public function testLoggingOnSuccessfulSave(): void
{
// Mock successful database operations
$mockStatement = $this->createMock(PDOStatement::class);
$mockStatement->method('execute')->willReturn(true);
$mockStatement->method('fetchColumn')->willReturn(1);
$mockStatement->method('fetch')->willReturnOnConsecutiveCalls(
[
'site_title' => 'Test Site',
'site_description' => 'Test Description',
'base_url' => 'https://example.com',
'base_path' => '/tkr',
'items_per_page' => 10,
'css_id' => null,
'strict_accessibility' => true,
'log_level' => 2
],
[
'username' => 'testuser',
'display_name' => 'Test User',
'website' => '',
'mood' => ''
]
);
$this->mockPdo->method('prepare')->willReturn($mockStatement);
$this->mockPdo->method('query')->willReturn($mockStatement);
$config = new ConfigModel($this->mockPdo);
$user = new UserModel($this->mockPdo);
$controller = new AdminController($this->mockPdo, $config, $user);
$postData = [
'username' => 'testuser',
'display_name' => 'Test User',
'site_title' => 'Test Site',
'site_description' => 'Test Description',
'base_url' => 'https://example.com',
'base_path' => '/tkr',
'items_per_page' => 10
];
$controller->processSettingsSave($postData, false);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Processing settings for user: testuser', $logContent);
$this->assertStringContainsString('Site settings updated', $logContent);
$this->assertStringContainsString('User profile updated', $logContent);
}
}

View File

@ -1,175 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
class FeedControllerTest extends TestCase
{
private PDO $mockPdo;
private PDOStatement $mockStatement;
private ConfigModel $mockConfig;
private UserModel $mockUser;
private string $tempLogDir;
protected function setUp(): void
{
// Set up temporary logging
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
mkdir($this->tempLogDir . '/logs', 0777, true);
Log::init($this->tempLogDir . '/logs/tkr.log');
// Set up global config for logging level (DEBUG = 1)
global $config;
$config = new stdClass();
$config->logLevel = 1; // Allow DEBUG level logs
// Create mock PDO and PDOStatement
$this->mockStatement = $this->createMock(PDOStatement::class);
$this->mockPdo = $this->createMock(PDO::class);
// Mock config with feed-relevant properties
$this->mockConfig = new ConfigModel($this->mockPdo);
$this->mockConfig->itemsPerPage = 10;
$this->mockConfig->basePath = '/tkr';
$this->mockConfig->siteTitle = 'Test Site';
$this->mockConfig->siteDescription = 'Test Description';
$this->mockConfig->baseUrl = 'https://test.example.com';
// Mock user
$this->mockUser = new UserModel($this->mockPdo);
$this->mockUser->displayName = 'Test User';
}
protected function tearDown(): void
{
// Clean up temp directory
if (is_dir($this->tempLogDir)) {
$this->deleteDirectory($this->tempLogDir);
}
}
private function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
private function setupMockDatabase(array $tickData): void
{
// Mock PDO prepare method to return our mock statement
$this->mockPdo->method('prepare')
->willReturn($this->mockStatement);
// Mock statement execute method
$this->mockStatement->method('execute')
->willReturn(true);
// Mock statement fetchAll to return our test data
$this->mockStatement->method('fetchAll')
->willReturn($tickData);
}
public function testControllerInstantiationWithNoTicks(): void
{
$this->setupMockDatabase([]);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
// Verify it was created successfully
$this->assertInstanceOf(FeedController::class, $controller);
// Check logs
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Loaded 0 ticks for feeds', $logContent);
}
public function testControllerInstantiationWithTicks(): void
{
$testTicks = [
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'First tick'],
['id' => 2, 'timestamp' => '2025-01-31 13:00:00', 'tick' => 'Second tick'],
];
$this->setupMockDatabase($testTicks);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
// Verify it was created successfully
$this->assertInstanceOf(FeedController::class, $controller);
// Check logs
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Loaded 2 ticks for feeds', $logContent);
}
public function testControllerCallsDatabaseCorrectly(): void
{
$this->setupMockDatabase([]);
// Verify that PDO prepare is called with the correct SQL for tick loading
$this->mockPdo->expects($this->once())
->method('prepare')
->with('SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?')
->willReturn($this->mockStatement);
// Verify that execute is called with correct parameters (page 1, offset 0)
$this->mockStatement->expects($this->once())
->method('execute')
->with([10, 0]); // itemsPerPage=10, page 1 = offset 0
new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
}
public function testRssMethodLogsCorrectly(): void
{
$testTicks = [
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'Test tick']
];
$this->setupMockDatabase($testTicks);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
// Capture output to prevent headers/content from affecting test
ob_start();
$controller->rss();
ob_end_clean();
// Check logs for RSS generation
$logFile = $this->tempLogDir . '/logs/tkr.log';
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Generating RSS feed with 1 ticks', $logContent);
}
public function testAtomMethodLogsCorrectly(): void
{
$testTicks = [
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'Test tick'],
['id' => 2, 'timestamp' => '2025-01-31 13:00:00', 'tick' => 'Another tick']
];
$this->setupMockDatabase($testTicks);
$controller = new FeedController($this->mockPdo, $this->mockConfig, $this->mockUser);
// Capture output to prevent headers/content from affecting test
ob_start();
$controller->atom();
ob_end_clean();
// Check logs for Atom generation
$logFile = $this->tempLogDir . '/logs/tkr.log';
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Generating Atom feed with 2 ticks', $logContent);
}
}

View File

@ -1,312 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
class HomeControllerTest extends TestCase
{
private PDO $mockPdo;
private PDOStatement $mockStatement;
private ConfigModel $mockConfig;
private UserModel $mockUser;
private string $tempLogDir;
protected function setUp(): void
{
// Set up temporary logging
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
mkdir($this->tempLogDir . '/logs', 0777, true);
Log::init($this->tempLogDir . '/logs/tkr.log');
// Set up global config for logging level (DEBUG = 1)
global $config;
$config = new stdClass();
$config->logLevel = 1; // Allow DEBUG level logs
// Create mock PDO and PDOStatement
$this->mockStatement = $this->createMock(PDOStatement::class);
$this->mockPdo = $this->createMock(PDO::class);
// Mock config
$this->mockConfig = new ConfigModel($this->mockPdo);
$this->mockConfig->itemsPerPage = 10;
$this->mockConfig->basePath = '/tkr';
// Mock user
$this->mockUser = new UserModel($this->mockPdo);
$this->mockUser->displayName = 'Test User';
$this->mockUser->mood = '😊';
}
protected function tearDown(): void
{
// Clean up temp directory
if (is_dir($this->tempLogDir)) {
$this->deleteDirectory($this->tempLogDir);
}
}
private function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
private function setupMockDatabase(array $tickData): void
{
// Mock PDO prepare method to return our mock statement
$this->mockPdo->method('prepare')
->willReturn($this->mockStatement);
// Mock statement execute method
$this->mockStatement->method('execute')
->willReturn(true);
// Mock statement fetchAll to return our test data
$this->mockStatement->method('fetchAll')
->willReturn($tickData);
}
private function setupMockDatabaseForInsert(bool $shouldSucceed = true): void
{
if ($shouldSucceed) {
// Mock successful insert
$this->mockPdo->method('prepare')
->willReturn($this->mockStatement);
$this->mockStatement->method('execute')
->willReturn(true);
} else {
// Mock database error
$this->mockPdo->method('prepare')
->willThrowException(new PDOException("Database error"));
}
}
public function testGetHomeDataWithNoTicks(): void
{
$this->setupMockDatabase([]); // Empty array = no ticks
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$data = $controller->getHomeData(1);
// Should return proper structure
$this->assertArrayHasKey('config', $data);
$this->assertArrayHasKey('user', $data);
$this->assertArrayHasKey('tickList', $data);
// Config and user should be the injected instances
$this->assertSame($this->mockConfig, $data['config']);
$this->assertSame($this->mockUser, $data['user']);
// Should have tick list HTML (even if empty)
$this->assertIsString($data['tickList']);
}
public function testGetHomeDataWithTicks(): void
{
// Set up test tick data that the database would return
$testTicks = [
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'First tick'],
['id' => 2, 'timestamp' => '2025-01-31 13:00:00', 'tick' => 'Second tick'],
['id' => 3, 'timestamp' => '2025-01-31 14:00:00', 'tick' => 'Third tick'],
];
$this->setupMockDatabase($testTicks);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$data = $controller->getHomeData(1);
// Should return proper structure
$this->assertArrayHasKey('config', $data);
$this->assertArrayHasKey('user', $data);
$this->assertArrayHasKey('tickList', $data);
// Should contain tick content in HTML
$this->assertStringContainsString('First tick', $data['tickList']);
$this->assertStringContainsString('Second tick', $data['tickList']);
$this->assertStringContainsString('Third tick', $data['tickList']);
}
public function testGetHomeDataCallsDatabaseCorrectly(): void
{
$this->setupMockDatabase([]);
// Verify that PDO prepare is called with the correct SQL
$this->mockPdo->expects($this->once())
->method('prepare')
->with('SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?')
->willReturn($this->mockStatement);
// Verify that execute is called with correct parameters for page 2
$this->mockStatement->expects($this->once())
->method('execute')
->with([10, 10]); // itemsPerPage=10, page 2 = offset 10
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller->getHomeData(2); // Page 2
}
public function testProcessTickSuccess(): void
{
$this->setupMockDatabaseForInsert(true);
// Verify the INSERT SQL is called correctly
$this->mockPdo->expects($this->once())
->method('prepare')
->with('INSERT INTO tick(timestamp, tick) values (?, ?)')
->willReturn($this->mockStatement);
// Verify execute is called with timestamp and content
$this->mockStatement->expects($this->once())
->method('execute')
->with($this->callback(function($params) {
// First param should be a timestamp, second should be the tick content
return count($params) === 2
&& is_string($params[0])
&& $params[1] === 'This is a test tick';
}));
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = ['new_tick' => 'This is a test tick'];
$result = $controller->processTick($postData);
$this->assertTrue($result['success']);
$this->assertEquals('Tick saved successfully', $result['message']);
}
public function testProcessTickEmptyContent(): void
{
// PDO shouldn't be called at all for empty content
$this->mockPdo->expects($this->never())->method('prepare');
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = ['new_tick' => ' ']; // Just whitespace
$result = $controller->processTick($postData);
$this->assertFalse($result['success']);
$this->assertEquals('Empty tick ignored', $result['message']);
}
public function testProcessTickMissingField(): void
{
// PDO shouldn't be called at all for missing field
$this->mockPdo->expects($this->never())->method('prepare');
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = []; // No new_tick field
$result = $controller->processTick($postData);
$this->assertFalse($result['success']);
$this->assertEquals('No tick content provided', $result['message']);
}
public function testProcessTickTrimsWhitespace(): void
{
$this->setupMockDatabaseForInsert(true);
// Verify execute is called with trimmed content
$this->mockStatement->expects($this->once())
->method('execute')
->with($this->callback(function($params) {
return $params[1] === 'This has whitespace'; // Should be trimmed
}));
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = ['new_tick' => ' This has whitespace '];
$result = $controller->processTick($postData);
$this->assertTrue($result['success']);
}
public function testProcessTickHandlesDatabaseError(): void
{
$this->setupMockDatabaseForInsert(false); // Will throw exception
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = ['new_tick' => 'This will fail'];
$result = $controller->processTick($postData);
$this->assertFalse($result['success']);
$this->assertEquals('Failed to save tick', $result['message']);
}
public function testLoggingOnHomePageLoad(): void
{
$testTicks = [
['id' => 1, 'timestamp' => '2025-01-31 12:00:00', 'tick' => 'Test tick']
];
$this->setupMockDatabase($testTicks);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$controller->getHomeData(1);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Loading home page 1', $logContent);
$this->assertStringContainsString('Home page loaded with 1 ticks', $logContent);
}
public function testLoggingOnTickCreation(): void
{
$this->setupMockDatabaseForInsert(true);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = ['new_tick' => 'Test tick for logging'];
$controller->processTick($postData);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('New tick created: Test tick for logging', $logContent);
}
public function testLoggingOnEmptyTick(): void
{
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = ['new_tick' => ''];
$controller->processTick($postData);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
// The log file should exist (Log::init creates it) and contain the debug message
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Empty tick submission ignored', $logContent);
}
public function testLoggingOnDatabaseError(): void
{
$this->setupMockDatabaseForInsert(false);
$controller = new HomeController($this->mockPdo, $this->mockConfig, $this->mockUser);
$postData = ['new_tick' => 'This will fail'];
$controller->processTick($postData);
// Check that logs were written
$logFile = $this->tempLogDir . '/logs/tkr.log';
$this->assertFileExists($logFile);
$logContent = file_get_contents($logFile);
$this->assertStringContainsString('Failed to save tick: Database error', $logContent);
}
}

View File

@ -1,262 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
class LogControllerTest extends TestCase
{
private string $tempLogDir;
private string $testLogFile;
private $originalGet;
protected function setUp(): void
{
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
mkdir($this->tempLogDir, 0777, true);
$this->testLogFile = $this->tempLogDir . '/logs/tkr.log';
mkdir(dirname($this->testLogFile), 0777, true);
// Store original $_GET and clear it
$this->originalGet = $_GET;
$_GET = [];
// Mock global config
global $config;
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->baseUrl = 'https://example.com';
$config->basePath = '/tkr/';
}
protected function tearDown(): void
{
// Restore original $_GET
$_GET = $this->originalGet;
if (is_dir($this->tempLogDir)) {
$this->deleteDirectory($this->tempLogDir);
}
}
private function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
public function testGetLogDataWithNoLogFiles(): void
{
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData();
// Should return empty log entries but valid structure
$this->assertArrayHasKey('logEntries', $data);
$this->assertArrayHasKey('availableRoutes', $data);
$this->assertArrayHasKey('availableLevels', $data);
$this->assertArrayHasKey('currentLevelFilter', $data);
$this->assertArrayHasKey('currentRouteFilter', $data);
$this->assertEmpty($data['logEntries']);
$this->assertEmpty($data['availableRoutes']);
$this->assertEquals(['DEBUG', 'INFO', 'WARNING', 'ERROR'], $data['availableLevels']);
$this->assertEquals('', $data['currentLevelFilter']);
$this->assertEquals('', $data['currentRouteFilter']);
}
public function testGetLogDataWithValidEntries(): void
{
// Create test log content with various scenarios
$logContent = implode("\n", [
'[2025-01-31 12:00:00] DEBUG: 127.0.0.1 [GET /] - Debug home page',
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Info admin page',
'[2025-01-31 12:02:00] WARNING: 127.0.0.1 [POST /admin] - Warning admin save',
'[2025-01-31 12:03:00] ERROR: 127.0.0.1 [GET /feed/rss] - Error feed generation',
'[2025-01-31 12:04:00] INFO: 127.0.0.1 - Info without route',
'Invalid log line that should be ignored'
]);
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData();
// Should parse all valid entries and ignore invalid ones
$this->assertCount(5, $data['logEntries']);
// Verify entries are in reverse chronological order (newest first)
$entries = $data['logEntries'];
$this->assertEquals('Info without route', $entries[0]['message']);
$this->assertEquals('Debug home page', $entries[4]['message']);
// Verify entry structure
$firstEntry = $entries[0];
$this->assertArrayHasKey('timestamp', $firstEntry);
$this->assertArrayHasKey('level', $firstEntry);
$this->assertArrayHasKey('ip', $firstEntry);
$this->assertArrayHasKey('route', $firstEntry);
$this->assertArrayHasKey('message', $firstEntry);
// Test route extraction
$adminEntry = array_filter($entries, fn($e) => $e['message'] === 'Info admin page');
$adminEntry = array_values($adminEntry)[0];
$this->assertEquals('GET /admin', $adminEntry['route']);
$this->assertEquals('INFO', $adminEntry['level']);
// Test entry without route
$noRouteEntry = array_filter($entries, fn($e) => $e['message'] === 'Info without route');
$noRouteEntry = array_values($noRouteEntry)[0];
$this->assertEquals('', $noRouteEntry['route']);
}
public function testGetLogDataWithLevelFilter(): void
{
$logContent = implode("\n", [
'[2025-01-31 12:00:00] DEBUG: 127.0.0.1 - Debug message',
'[2025-01-31 12:01:00] INFO: 127.0.0.1 - Info message',
'[2025-01-31 12:02:00] ERROR: 127.0.0.1 - Error message'
]);
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData('ERROR');
// Should only include ERROR entries
$this->assertCount(1, $data['logEntries']);
$this->assertEquals('ERROR', $data['logEntries'][0]['level']);
$this->assertEquals('Error message', $data['logEntries'][0]['message']);
$this->assertEquals('ERROR', $data['currentLevelFilter']);
}
public function testGetLogDataWithRouteFilter(): void
{
$logContent = implode("\n", [
'[2025-01-31 12:00:00] INFO: 127.0.0.1 [GET /] - Home page',
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin page',
'[2025-01-31 12:02:00] INFO: 127.0.0.1 [POST /admin] - Admin save'
]);
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData('', 'GET /admin');
// Should only include GET /admin entries
$this->assertCount(1, $data['logEntries']);
$this->assertEquals('GET /admin', $data['logEntries'][0]['route']);
$this->assertEquals('Admin page', $data['logEntries'][0]['message']);
$this->assertEquals('GET /admin', $data['currentRouteFilter']);
}
public function testGetLogDataWithBothFilters(): void
{
$logContent = implode("\n", [
'[2025-01-31 12:00:00] ERROR: 127.0.0.1 [GET /admin] - Admin error',
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin info',
'[2025-01-31 12:02:00] ERROR: 127.0.0.1 [GET /] - Home error'
]);
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData('ERROR', 'GET /admin');
// Should only include entries matching both filters
$this->assertCount(1, $data['logEntries']);
$this->assertEquals('ERROR', $data['logEntries'][0]['level']);
$this->assertEquals('GET /admin', $data['logEntries'][0]['route']);
$this->assertEquals('Admin error', $data['logEntries'][0]['message']);
}
public function testGetLogDataWithRotatedLogs(): void
{
// Create main log file
$mainLogContent = '[2025-01-31 14:00:00] INFO: 127.0.0.1 - Current log entry';
file_put_contents($this->testLogFile, $mainLogContent);
// Create rotated log files
$rotatedLog1 = '[2025-01-31 13:00:00] ERROR: 127.0.0.1 - Rotated log entry 1';
file_put_contents($this->testLogFile . '.1', $rotatedLog1);
$rotatedLog2 = '[2025-01-31 12:00:00] WARNING: 127.0.0.1 - Rotated log entry 2';
file_put_contents($this->testLogFile . '.2', $rotatedLog2);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData();
// Should read from all log files, newest first
$this->assertCount(3, $data['logEntries']);
$this->assertEquals('Current log entry', $data['logEntries'][0]['message']);
$this->assertEquals('Rotated log entry 1', $data['logEntries'][1]['message']);
$this->assertEquals('Rotated log entry 2', $data['logEntries'][2]['message']);
}
public function testGetLogDataExtractsAvailableRoutes(): void
{
$logContent = implode("\n", [
'[2025-01-31 12:00:00] INFO: 127.0.0.1 [GET /] - Home',
'[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin',
'[2025-01-31 12:02:00] INFO: 127.0.0.1 [POST /admin] - Admin post',
'[2025-01-31 12:03:00] INFO: 127.0.0.1 [GET /admin] - Admin again',
'[2025-01-31 12:04:00] INFO: 127.0.0.1 - No route'
]);
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData();
// Should extract unique routes, sorted
$expectedRoutes = ['GET /', 'GET /admin', 'POST /admin'];
$this->assertEquals($expectedRoutes, $data['availableRoutes']);
}
public function testGetLogDataHandlesInvalidLogLines(): void
{
$logContent = implode("\n", [
'[2025-01-31 12:00:00] INFO: 127.0.0.1 - Valid entry',
'This is not a valid log line',
'Neither is this one',
'[2025-01-31 12:01:00] ERROR: 127.0.0.1 - Another valid entry'
]);
file_put_contents($this->testLogFile, $logContent);
$mockPdo = $this->createMock(PDO::class);
$mockConfig = new ConfigModel($mockPdo);
$mockUser = new UserModel($mockPdo);
$controller = new LogController($mockPdo, $mockConfig, $mockUser, $this->tempLogDir);
$data = $controller->getLogData();
// Should only include valid entries, ignore invalid ones
$this->assertCount(2, $data['logEntries']);
$this->assertEquals('Another valid entry', $data['logEntries'][0]['message']);
$this->assertEquals('Valid entry', $data['logEntries'][1]['message']);
}
}

View File

@ -1,140 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
class AtomGeneratorTest extends TestCase
{
private function createMockConfig() {
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->siteTitle = 'Test Site';
$config->siteDescription = 'Test Description';
$config->baseUrl = 'https://example.com';
$config->basePath = '/tkr/';
return $config;
}
private function createSampleTicks() {
return [
['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'],
['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick']
];
}
public function testCanGenerateValidAtom() {
$config = $this->createMockConfig();
$ticks = $this->createSampleTicks();
$generator = new AtomGenerator($config, $ticks);
$xml = $generator->generate();
// Test XML structure
$this->assertStringStartsWith('<?xml version="1.0"', $xml);
$this->assertStringContainsString('<feed xmlns="http://www.w3.org/2005/Atom">', $xml);
$this->assertStringContainsString('<title>Test Site Atom Feed</title>', $xml);
$this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml);
$this->assertStringContainsString('<link rel="self"', $xml);
$this->assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml);
$this->assertStringContainsString('<id>https://example.com/tkr/</id>', $xml);
$this->assertStringContainsString('<author>', $xml);
$this->assertStringContainsString('<name>Test Site</name>', $xml);
$this->assertStringContainsString('<entry>', $xml);
$this->assertStringContainsString('</entry>', $xml);
$this->assertStringEndsWith('</feed>' . "\n", $xml);
// Test tick content
$this->assertStringContainsString('First test tick', $xml);
$this->assertStringContainsString('Second test tick', $xml);
// Ensure the XML is still valid
$doc = new DOMDocument();
$this->assertTrue($doc->loadXML($xml), 'Valid Atom should load into an XML document');
}
public function testReturnsCorrectContentType() {
$generator = new AtomGenerator($this->createMockConfig(), []);
$this->assertEquals('application/atom+xml; charset=utf-8', $generator->getContentType());
}
public function testCanHandleEmptyTickList() {
$config = $this->createMockConfig();
$generator = new AtomGenerator($config, []);
$xml = $generator->generate();
// Should still be valid Atom with no entries
// Test XML structure
$this->assertStringStartsWith('<?xml version="1.0"', $xml);
$this->assertStringContainsString('<feed xmlns="http://www.w3.org/2005/Atom">', $xml);
$this->assertStringContainsString('<title>Test Site Atom Feed</title>', $xml);
$this->assertStringContainsString('<link rel="alternate" href="https://example.com/tkr/"/>', $xml);
$this->assertStringContainsString('<link rel="self"', $xml);
$this->assertStringContainsString('href="https://example.com/tkr/feed/atom"', $xml);
$this->assertStringContainsString('<id>https://example.com/tkr/</id>', $xml);
$this->assertStringContainsString('<author>', $xml);
$this->assertStringContainsString('<name>Test Site</name>', $xml);
$this->assertStringEndsWith('</feed>' . "\n", $xml);
// Test tick content
$this->assertStringNotContainsString('<entry>', $xml);
$this->assertStringNotContainsString('</entry>', $xml);
// Ensure the XML is still valid
$doc = new DOMDocument();
$this->assertTrue($doc->loadXML($xml), 'XML with no entries should still be valid');
}
public function testCanHandleSpecialCharactersAndUnicode() {
$config = $this->createMockConfig();
// Test various challenging characters
$ticks = [
[
'id' => 1,
'timestamp' => '2025-01-15 12:00:00',
'tick' => 'Testing emojis 🎉🔥💯 and unicode characters'
],
[
'id' => 2,
'timestamp' => '2025-01-15 13:00:00',
'tick' => 'XML entities: <tag> & "quotes" & \'apostrophes\''
],
[
'id' => 3,
'timestamp' => '2025-01-15 14:00:00',
'tick' => 'International: café naïve résumé 北京 москва'
],
[
'id' => 4,
'timestamp' => '2025-01-15 15:00:00',
'tick' => 'Math symbols: ∑ ∆ π ∞ ≠ ≤ ≥'
]
];
$generator = new AtomGenerator($config, $ticks);
$xml = $generator->generate();
// Test that emojis are preserved
$this->assertStringContainsString('🎉🔥💯', $xml);
// Test that XML entities are properly escaped
$this->assertStringContainsString('&lt;tag&gt;', $xml);
$this->assertStringContainsString('&amp;', $xml);
$this->assertStringContainsString('&quot;quotes&quot;', $xml);
$this->assertStringContainsString('&apos;apostrophes&apos;', $xml);
// Test that international characters are preserved
$this->assertStringContainsString('café naïve résumé', $xml);
$this->assertStringContainsString('北京', $xml);
$this->assertStringContainsString('москва', $xml);
// Test that math symbols are preserved
$this->assertStringContainsString('∑ ∆ π ∞', $xml);
// Ensure no raw < > & characters (security)
$this->assertStringNotContainsString('<tag>', $xml);
$this->assertStringNotContainsString(' & "', $xml);
// Ensure the XML is still valid
$doc = new DOMDocument();
$this->assertTrue($doc->loadXML($xml), 'XML with Unicode should still be valid');
}
}

View File

@ -1,118 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
class FeedGeneratorTest extends TestCase
{
private function createMockConfig() {
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->siteTitle = 'Test Site';
$config->siteDescription = 'Test Description';
$config->baseUrl = 'https://example.com';
$config->basePath = '/tkr/';
return $config;
}
private function createSampleTicks() {
return [
['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'],
['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick']
];
}
private function createTestGenerator($config = null, $ticks = null) {
$config = $config ?? $this->createMockConfig();
$ticks = $ticks ?? $this->createSampleTicks();
return new class($config, $ticks) extends FeedGenerator {
public function generate(): string {
return '<test>content</test>';
}
public function getContentType(): string {
return 'application/test+xml';
}
// Expose protected methods for testing
public function testBuildTickUrl(int $tickId): string {
return $this->buildTickUrl($tickId);
}
public function testGetSiteUrl(): string {
return $this->getSiteUrl();
}
};
}
public function testConstructorStoresConfigAndTicks() {
$generator = $this->createTestGenerator();
$this->assertEquals('<test>content</test>', $generator->generate());
$this->assertEquals('application/test+xml', $generator->getContentType());
}
public function testBuildTickUrlGeneratesCorrectUrl() {
$generator = $this->createTestGenerator();
$tickUrl = $generator->testBuildTickUrl(123);
$this->assertEquals('https://example.com/tkr/tick/123', $tickUrl);
}
public function testGetSiteUrlGeneratesCorrectUrl() {
$generator = $this->createTestGenerator();
$siteUrl = $generator->testGetSiteUrl();
$this->assertEquals('https://example.com/tkr/', $siteUrl);
}
public function testUrlMethodsHandleSubdomainConfiguration() {
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->siteTitle = 'Test Site';
$config->baseUrl = 'https://tkr.example.com';
$config->basePath = '/';
$generator = $this->createTestGenerator($config, []);
$this->assertEquals('https://tkr.example.com/', $generator->testGetSiteUrl());
$this->assertEquals('https://tkr.example.com/tick/456', $generator->testBuildTickUrl(456));
}
public function testUrlMethodsHandleEmptyBasePath() {
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->siteTitle = 'Test Site';
$config->baseUrl = 'https://example.com';
$config->basePath = '';
$generator = $this->createTestGenerator($config, []);
$this->assertEquals('https://example.com/', $generator->testGetSiteUrl());
$this->assertEquals('https://example.com/tick/789', $generator->testBuildTickUrl(789));
}
public function testUrlMethodsHandleVariousBasePathFormats() {
$testCases = [
// [basePath, expectedSiteUrl, expectedTickUrl]
['', 'https://example.com/', 'https://example.com/tick/123'],
['/', 'https://example.com/', 'https://example.com/tick/123'],
['tkr', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
['/tkr', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
['tkr/', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
['/tkr/', 'https://example.com/tkr/', 'https://example.com/tkr/tick/123'],
];
foreach ($testCases as [$basePath, $expectedSiteUrl, $expectedTickUrl]) {
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->siteTitle = 'Test Site';
$config->baseUrl = 'https://example.com';
$config->basePath = $basePath;
$generator = $this->createTestGenerator($config, []);
$this->assertEquals($expectedSiteUrl, $generator->testGetSiteUrl(), "Failed for basePath: '$basePath'");
$this->assertEquals($expectedTickUrl, $generator->testBuildTickUrl(123), "Failed for basePath: '$basePath'");
}
}
}

View File

@ -1,136 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
class RssGeneratorTest extends TestCase
{
private function createMockConfig() {
$mockPdo = $this->createMock(PDO::class);
$config = new ConfigModel($mockPdo);
$config->siteTitle = 'Test Site';
$config->siteDescription = 'Test Description';
$config->baseUrl = 'https://example.com';
$config->basePath = '/tkr/';
return $config;
}
private function createSampleTicks() {
return [
['id' => 1, 'timestamp' => '2025-01-15 12:00:00', 'tick' => 'First test tick'],
['id' => 2, 'timestamp' => '2025-01-15 13:00:00', 'tick' => 'Second test tick']
];
}
public function testCanGenerateValidRss() {
$config = $this->createMockConfig();
$ticks = $this->createSampleTicks();
$generator = new RssGenerator($config, $ticks);
$xml = $generator->generate();
// Test XML structure
$this->assertStringStartsWith('<?xml version="1.0"', $xml);
$this->assertStringContainsString('<rss version="2.0"', $xml);
$this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml);
$this->assertStringContainsString('<link>https://example.com/tkr/</link>', $xml);
$this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/rss"', $xml);
$this->assertStringContainsString('<channel>', $xml);
$this->assertStringContainsString('<item>', $xml);
$this->assertStringContainsString('</item>', $xml);
$this->assertStringContainsString('</channel>', $xml);
$this->assertStringEndsWith('</rss>' . "\n", $xml);
// Test tick content
$this->assertStringContainsString('First test tick', $xml);
$this->assertStringContainsString('Second test tick', $xml);
// Ensure the XML is still valid
$doc = new DOMDocument();
$this->assertTrue($doc->loadXML($xml), 'Valid RSS should load into an XML document');
}
public function testReturnsCorrectContentType() {
$generator = new RssGenerator($this->createMockConfig(), []);
$this->assertEquals('application/rss+xml; charset=utf-8', $generator->getContentType());
}
public function testCanHandleEmptyTickList() {
$config = $this->createMockConfig();
$generator = new RssGenerator($config, []);
$xml = $generator->generate();
// Should still be valid RSS with no items
// Test XML structure
$this->assertStringStartsWith('<?xml version="1.0"', $xml);
$this->assertStringContainsString('<rss version="2.0"', $xml);
$this->assertStringContainsString('<title>Test Site RSS Feed</title>', $xml);
$this->assertStringContainsString('<link>https://example.com/tkr/</link>', $xml);
$this->assertStringContainsString('<atom:link href="https://example.com/tkr/feed/rss"', $xml);
$this->assertStringContainsString('<channel>', $xml);
$this->assertStringContainsString('</channel>', $xml);
$this->assertStringEndsWith('</rss>' . "\n", $xml);
// Test tick content
$this->assertStringNotContainsString('<item>', $xml);
$this->assertStringNotContainsString('</item>', $xml);
// Ensure the XML is still valid
$doc = new DOMDocument();
$this->assertTrue($doc->loadXML($xml), 'XML with no items should still be valid');
}
public function testCanHandleSpecialCharactersAndUnicode() {
$config = $this->createMockConfig();
// Test various challenging characters
$ticks = [
[
'id' => 1,
'timestamp' => '2025-01-15 12:00:00',
'tick' => 'Testing emojis 🎉🔥💯 and unicode characters'
],
[
'id' => 2,
'timestamp' => '2025-01-15 13:00:00',
'tick' => 'XML entities: <tag> & "quotes" & \'apostrophes\''
],
[
'id' => 3,
'timestamp' => '2025-01-15 14:00:00',
'tick' => 'International: café naïve résumé 北京 москва'
],
[
'id' => 4,
'timestamp' => '2025-01-15 15:00:00',
'tick' => 'Math symbols: ∑ ∆ π ∞ ≠ ≤ ≥'
]
];
$generator = new RssGenerator($config, $ticks);
$xml = $generator->generate();
// Test that emojis are preserved
$this->assertStringContainsString('🎉🔥💯', $xml);
// Test that XML entities are properly escaped
$this->assertStringContainsString('&lt;tag&gt;', $xml);
$this->assertStringContainsString('&amp;', $xml);
$this->assertStringContainsString('&quot;quotes&quot;', $xml);
$this->assertStringContainsString('&apos;apostrophes&apos;', $xml);
// Test that international characters are preserved
$this->assertStringContainsString('café naïve résumé', $xml);
$this->assertStringContainsString('北京', $xml);
$this->assertStringContainsString('москва', $xml);
// Test that math symbols are preserved
$this->assertStringContainsString('∑ ∆ π ∞', $xml);
// Ensure no raw < > & characters (security)
$this->assertStringNotContainsString('<tag>', $xml);
$this->assertStringNotContainsString(' & "', $xml);
// Ensure the XML is still valid
$doc = new DOMDocument();
$this->assertTrue($doc->loadXML($xml), 'XML with Unicode should still be valid');
}
}

View File

@ -1,169 +0,0 @@
<?php
use PHPUnit\Framework\TestCase;
class LogTest extends TestCase
{
private string $tempLogDir;
private string $testLogFile;
protected function setUp(): void
{
// Create a temporary directory for test logs
$this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid();
mkdir($this->tempLogDir, 0777, true);
$this->testLogFile = $this->tempLogDir . '/tkr.log';
// Initialize Log with test file and reset route context
Log::init($this->testLogFile);
Log::setRouteContext('');
}
protected function tearDown(): void
{
// Clean up temp directory
if (is_dir($this->tempLogDir)) {
$this->deleteDirectory($this->tempLogDir);
}
}
private function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) return;
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
public function testSetRouteContext(): void
{
Log::setRouteContext('GET /admin');
// Create a mock config for log level
global $config;
$config = new stdClass();
$config->logLevel = 1; // DEBUG level
Log::debug('Test message');
$this->assertFileExists($this->testLogFile);
$logContent = file_get_contents($this->testLogFile);
$this->assertStringContainsString('[GET /admin]', $logContent);
$this->assertStringContainsString('Test message', $logContent);
}
public function testEmptyRouteContext(): void
{
Log::setRouteContext('');
global $config;
$config = new stdClass();
$config->logLevel = 1;
Log::info('Test without route');
$logContent = file_get_contents($this->testLogFile);
// Should match format without route context: [timestamp] LEVEL: IP - message
$this->assertMatchesRegularExpression(
'/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: .+ - Test without route/',
$logContent
);
}
public function testLogLevelFiltering(): void
{
global $config;
$config = new stdClass();
$config->logLevel = 3; // WARNING level
Log::debug('Debug message'); // Should be filtered out
Log::info('Info message'); // Should be filtered out
Log::warning('Warning message'); // Should be logged
Log::error('Error message'); // Should be logged
$logContent = file_get_contents($this->testLogFile);
$this->assertStringNotContainsString('Debug message', $logContent);
$this->assertStringNotContainsString('Info message', $logContent);
$this->assertStringContainsString('Warning message', $logContent);
$this->assertStringContainsString('Error message', $logContent);
}
public function testLogMessageFormat(): void
{
Log::setRouteContext('POST /admin');
global $config;
$config = new stdClass();
$config->logLevel = 1;
Log::error('Test error message');
$logContent = file_get_contents($this->testLogFile);
// Check log format: [timestamp] LEVEL: IP [route] - message
$this->assertMatchesRegularExpression(
'/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] ERROR: .+ \[POST \/admin\] - Test error message/',
$logContent
);
}
public function testInitCreatesLogDirectory(): void
{
$newLogFile = $this->tempLogDir . '/nested/logs/test.log';
// Directory doesn't exist yet
$this->assertDirectoryDoesNotExist(dirname($newLogFile));
Log::init($newLogFile);
// init() should create the directory
$this->assertDirectoryExists(dirname($newLogFile));
}
public function testLogRotation(): void
{
global $config;
$config = new stdClass();
$config->logLevel = 1;
// Create a log file with exactly 1000 lines (the rotation threshold)
$logLines = str_repeat("[2025-01-31 12:00:00] INFO: 127.0.0.1 - Test line\n", 1000);
file_put_contents($this->testLogFile, $logLines);
// This should trigger rotation
Log::info('This should trigger rotation');
// Original log should be rotated to .1
$this->assertFileExists($this->testLogFile . '.1');
// New log should contain the new message
$newLogContent = file_get_contents($this->testLogFile);
$this->assertStringContainsString('This should trigger rotation', $newLogContent);
// Rotated log should contain old content
$rotatedContent = file_get_contents($this->testLogFile . '.1');
$this->assertStringContainsString('Test line', $rotatedContent);
}
public function testDefaultLogLevelWhenConfigMissing(): void
{
// Clear global config
global $config;
$config = null;
// Should not throw errors and should default to INFO level
Log::debug('Debug message'); // Should be filtered out (default INFO level = 2)
Log::info('Info message'); // Should be logged
$logContent = file_get_contents($this->testLogFile);
$this->assertStringNotContainsString('Debug message', $logContent);
$this->assertStringContainsString('Info message', $logContent);
}
}

View File

@ -26,51 +26,4 @@ final class UtilTest extends TestCase
$this->assertSame($relativeTime, $display);
}
public static function buildUrlProvider(): array {
return [
'basic path' => ['https://example.com', 'tkr', 'admin', 'https://example.com/tkr/admin'],
'baseUrl with trailing slash' => ['https://example.com/', 'tkr', 'admin', 'https://example.com/tkr/admin'],
'empty basePath' => ['https://example.com', '', 'admin', 'https://example.com/admin'],
'root basePath' => ['https://example.com', '/', 'admin', 'https://example.com/admin'],
'basePath no leading slash' => ['https://example.com', 'tkr', 'admin', 'https://example.com/tkr/admin'],
'basePath with leading slash' => ['https://example.com', '/tkr', 'admin', 'https://example.com/tkr/admin'],
'basePath with trailing slash' => ['https://example.com', 'tkr/', 'admin', 'https://example.com/tkr/admin'],
'basePath with both slashes' => ['https://example.com', '/tkr/', 'admin', 'https://example.com/tkr/admin'],
'complex path' => ['https://example.com', 'tkr', 'admin/css/upload', 'https://example.com/tkr/admin/css/upload'],
'path with leading slash' => ['https://example.com', 'tkr', '/admin', 'https://example.com/tkr/admin'],
'no path - empty basePath' => ['https://example.com', '', '', 'https://example.com/'],
'no path - root basePath' => ['https://example.com', '/', '', 'https://example.com/'],
'no path - tkr basePath' => ['https://example.com', 'tkr', '', 'https://example.com/tkr/'],
];
}
#[DataProvider('buildUrlProvider')]
public function testBuildUrl(string $baseUrl, string $basePath, string $path, string $expected): void {
$result = Util::buildUrl($baseUrl, $basePath, $path);
$this->assertEquals($expected, $result);
}
public static function buildRelativeUrlProvider(): array {
return [
'empty basePath with path' => ['', 'admin', '/admin'],
'root basePath with path' => ['/', 'admin', '/admin'],
'tkr basePath with path' => ['tkr', 'admin', '/tkr/admin'],
'tkr with leading slash' => ['/tkr', 'admin', '/tkr/admin'],
'tkr with trailing slash' => ['tkr/', 'admin', '/tkr/admin'],
'tkr with both slashes' => ['/tkr/', 'admin', '/tkr/admin'],
'complex path' => ['tkr', 'admin/css/upload', '/tkr/admin/css/upload'],
'path with leading slash' => ['tkr', '/admin', '/tkr/admin'],
'no path - empty basePath' => ['', '', '/'],
'no path - root basePath' => ['/', '', '/'],
'no path - tkr basePath' => ['tkr', '', '/tkr'],
'no path - tkr with slashes' => ['/tkr/', '', '/tkr'],
];
}
#[DataProvider('buildRelativeUrlProvider')]
public function testBuildRelativeUrl(string $basePath, string $path, string $expected): void {
$result = Util::buildRelativeUrl($basePath, $path);
$this->assertEquals($expected, $result);
}
}