Compare commits

...

18 Commits
v0.7.4 ... main

Author SHA1 Message Date
9593a43cc0 make-homepage-testable (#42)
Some checks are pending
Run unit tests / run-unit-tests (push) Waiting to run
Add logging and tests for the homepage and settings page. Make both support dependency injection.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/42
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-02 01:43:48 +00:00
879bd9ff9f Move route logging to router and log missing routes as warnings. 2025-08-01 14:21:28 -04:00
a7e79796fa Add log viewer and tests for the logs and the viewer (#41)
Now that I'm adding more logging, I wanted to add a log viewer so people don't have to ssh to their servers to inspect logs. Also added tests around logging and the viewer.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/41
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-08-01 01:52:45 +00:00
dc0abf8c7c Add debug logging to feeds. Add route info to all debug logs. (#39)
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/39
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-31 13:19:35 +00:00
8b5a249450 Make URL building more resilient and add tests. (#38)
Some checks failed
Run unit tests / run-unit-tests (push) Has been cancelled
Since the base URL and base path are user inputs, I'd like tkr to be resilient to any combination of leading and trailing slashes so people don't have to worry about that. This adds some helper functions to normalize URLs and adds tests to confirm that all combinations are handled correctly.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/38
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-31 02:39:09 +00:00
a9f610fc60 Make home page similar to feeds. Simplify tick retrieval. (#37)
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/37
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-31 01:30:25 +00:00
dc63d70944 Refactor feeds to be more testable and to remove templates. (#36)
Some checks are pending
Run unit tests / run-unit-tests (push) Waiting to run
Move feed generation into generator classes and out of templates. Remove feed templates, since they don't have any UI elements.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/36
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-31 00:05:46 +00:00
bb58e09cbf add-runtime-logging (#35)
Some checks failed
Run unit tests / run-unit-tests (push) Waiting to run
Prerequisites Testing / test-php-version-requirements (7.4) (push) Has been cancelled
Prerequisites Testing / test-php-version-requirements (8.1) (push) Has been cancelled
Prerequisites Testing / test-php-version-requirements (8.2) (push) Has been cancelled
Prerequisites Testing / test-php-version-requirements (8.3) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (alpine:latest, 8.2) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (alpine:latest, 8.3) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (debian:bookworm, 8.2) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (debian:bookworm, 8.3) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (fedora:39, 8.2) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (fedora:39, 8.3) (push) Has been cancelled
Prerequisites Testing / test-permission-scenarios (push) Has been cancelled
Set up logging framework and add runtime logging to foundational operations (database, sessions, auth).

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/35
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-29 22:45:17 +00:00
659808f724 Add prereq script to package build. (#32)
Some checks failed
Prerequisites Testing / test-php-version-requirements (7.4) (push) Has been cancelled
Prerequisites Testing / test-php-version-requirements (8.1) (push) Has been cancelled
Prerequisites Testing / test-php-version-requirements (8.2) (push) Has been cancelled
Prerequisites Testing / test-php-version-requirements (8.3) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (alpine:latest, 8.2) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (alpine:latest, 8.3) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (debian:bookworm, 8.2) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (debian:bookworm, 8.3) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (fedora:39, 8.2) (push) Has been cancelled
Prerequisites Testing / test-extension-progression (fedora:39, 8.3) (push) Has been cancelled
Prerequisites Testing / test-permission-scenarios (push) Has been cancelled
Run unit tests / run-unit-tests (push) Has been cancelled
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/32
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-28 01:45:39 +00:00
681f64c527 Add prereq check to README. Remove installation by cloning. Fix CI typos. (#31)
Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/31
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-28 01:04:46 +00:00
dc44d51479 Add posix_getuid detection to the right place.
Some checks are pending
Prerequisites Testing / test-php-version-requirements (7.4) (push) Waiting to run
Prerequisites Testing / test-php-version-requirements (8.1) (push) Waiting to run
Prerequisites Testing / test-php-version-requirements (8.2) (push) Waiting to run
Prerequisites Testing / test-php-version-requirements (8.3) (push) Waiting to run
Prerequisites Testing / test-extension-progression (alpine:latest, 8.2) (push) Waiting to run
Prerequisites Testing / test-extension-progression (alpine:latest, 8.3) (push) Waiting to run
Prerequisites Testing / test-extension-progression (debian:bookworm, 8.2) (push) Waiting to run
Prerequisites Testing / test-extension-progression (debian:bookworm, 8.3) (push) Waiting to run
Prerequisites Testing / test-extension-progression (fedora:39, 8.2) (push) Waiting to run
Prerequisites Testing / test-extension-progression (fedora:39, 8.3) (push) Waiting to run
Prerequisites Testing / test-permission-scenarios (push) Waiting to run
Run unit tests / run-unit-tests (push) Waiting to run
2025-07-27 15:33:15 -04:00
3c0f2a2ca5 Skip root test if POSIX PHP extension not installed (e.g. alpine) 2025-07-27 15:14:34 -04:00
64e2ff56da add instructions for setting permissions when CLI is run as root. 2025-07-27 14:50:42 -04:00
fb0b58dcbf Don't recomment curl php extension. 2025-07-27 14:20:32 -04:00
53ed66dce9 Name .yaml files consistently. 2025-07-27 12:48:49 -04:00
b53d58df8c Run prereq check on push to catch PR merge. 2025-07-27 12:45:18 -04:00
6337fa2dfb Add Prerequisite class and cli script to confirm prereqs. (#30)
Add a Prerequisite class to manage all initilaization prerequisites. Invoke before each request. Provide a CLI script to allow checking Prereqs from command line. Add CI  workflow to confirm prereq validation behavior.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/30
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-27 16:43:09 +00:00
4255f46fc7 Fix database migrations for first-time setup. (#29)
Some checks failed
Run unit tests / run-unit-tests (push) Has been cancelled
The database initialization had a number of bugs for the first-time setup. This PR fixes them.

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/29
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
2025-07-26 15:46:06 +00:00
54 changed files with 3476 additions and 509 deletions

View File

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

View File

@ -0,0 +1,163 @@
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,10 +9,14 @@ phpunit
*.sqlite
*.txt
storage/upload/css
storage/logs
# Testing stuff
/docker-compose.yml
scratch
# Build artifacts
tkr.tgz
tkr.tgz
# Test logs
storage/prerequisite-check.log

View File

@ -1,4 +1,5 @@
# 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).
@ -64,22 +65,19 @@ 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)
```
@ -145,6 +143,19 @@ 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:

21
check-prerequisites.php Normal file
View File

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

View File

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

View File

@ -15,7 +15,8 @@ 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
css_id INTEGER NULL,
strict_accessibility BOOLEAN DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS css (

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

View File

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

View File

@ -18,6 +18,10 @@
--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;
@ -497,3 +501,53 @@ 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,24 +14,35 @@ 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 {
// filesystem validation
$fsMgr = new Filesystem();
$fsMgr->validate();
// database validation
$dbMgr = new Database();
$dbMgr->validate();
// Make sure setup has been completed
$dbMgr->confirmSetup();
} catch (SetupException $e) {
$e->handle();
exit;
}
}
// initialize the database
// Get a database connection
// TODO: Change from static function.
global $db;
$db = Database::get();
@ -41,8 +52,10 @@ $db = Database::get();
global $config;
global $user;
$config = ConfigModel::load();
$user = UserModel::load();
$config = new ConfigModel($db);
$config = $config->loadFromDatabase();
$user = new UserModel($db);
$user = $user->loadFromDatabase();
// Start a session and generate a CSRF Token
// if there isn't already an active session
@ -57,6 +70,10 @@ 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
@ -64,12 +81,14 @@ if ($method === 'POST' && $path != 'setup') {
if ($path != 'login'){
if (!Session::isValid($_POST['csrf_token'])) {
// Invalid session - redirect to /login
header('Location: ' . $config->basePath . '/login');
Log::info('Attempt to POST with invalid session. Redirecting to login.');
header('Location: ' . Util::buildRelativeUrl($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;
}
@ -80,7 +99,8 @@ if ($method === 'POST' && $path != 'setup') {
header('Content-Type: text/html; charset=utf-8');
// Render the requested route or throw a 404
if (!Router::route($path, $method)){
$router = new Router($db, $config, $user);
if (!$router->route($path, $method)){
http_response_code(404);
echo "404 - Page Not Found";
exit;

View File

@ -3,146 +3,167 @@ class AdminController extends Controller {
// GET handler
// render the admin page
public function index(){
global $config;
global $user;
$vars = [
'user' => $user,
'config' => $config,
'isSetup' => false,
];
$this->render("admin.php", $vars);
$data = $this->getAdminData(false);
$this->render("admin.php", $data);
}
public function showSetup(){
global $config;
global $user;
$vars = [
'user' => $user,
'config' => $config,
'isSetup' => true,
$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,
];
$this->render("admin.php", $vars);
}
public function handleSave(){
if (!Session::isLoggedIn()){
header('Location: ' . $config->basePath . '/login');
header('Location: ' . Util::buildRelativeUrl($this->config->basePath, 'login'));
exit;
}
$this->save();
$result = $this->processSettingsSave($_POST, false);
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
public function handleSetup(){
// for setup, we don't care if they're logged in
// (because they can't be until setup is complete)
$this->save();
}
// save updated settings
private function save(){
global $config;
global $user;
// handle form submission
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$errors = [];
// User profile
$username = trim($_POST['username'] ?? '');
$displayName = trim($_POST['display_name'] ?? '');
$website = trim($_POST['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']);
// Password
$password = $_POST['password'] ?? '';
$confirmPassword = $_POST['confirm_password'] ?? '';
// 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 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
$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
// TODO - raise and handle exception on failure
$config = $config->save();
// Update user profile
$user->username = $username;
$user->displayName = $displayName;
$user->website = $website;
// Save user profile and reload user from database
// 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){
$user->set_password($password);
}
Session::setFlashMessage('success', 'Settings updated');
} else {
foreach($errors as $error){
Session::setFlashMessage('error', $error);
}
}
}
$result = $this->processSettingsSave($_POST, true);
header('Location: ' . $_SERVER['PHP_SELF']);
exit;
}
public function processSettingsSave(array $postData, bool $isSetup): array {
$result = ['success' => false, 'errors' => []];
Log::debug("Processing settings save" . ($isSetup ? " (setup mode)" : ""));
// handle form submission
if (empty($postData)) {
Log::warning("Settings save called with no POST data");
$result['errors'][] = 'No data provided';
return $result;
}
$errors = [];
// User profile
$username = trim($postData['username'] ?? '');
$displayName = trim($postData['display_name'] ?? '');
$website = trim($postData['website'] ?? '');
// 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 = $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 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");
}
}
// Validation complete
if (empty($errors)) {
try {
// 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;
// Save site settings and reload config from database
$this->config = $this->config->save();
Log::info("Site settings updated");
// Update user profile
$this->user->username = $username;
$this->user->displayName = $displayName;
$this->user->website = $website;
// Save user profile and reload user from database
$this->user = $this->user->save();
Log::info("User profile updated");
// Update the password if one was sent
if($password){
$this->user->setPassword($password);
Log::info("User password updated");
}
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);
}
$result['errors'] = $errors;
}
return $result;
}
}

View File

@ -20,21 +20,21 @@ class AuthController extends Controller {
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
// 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();
Log::debug("Login attempt for user {$username}");
$userModel = new UserModel();
$user = $userModel->getByUsername($username);
//if ($user && password_verify($password, $user['password_hash'])) {
if ($user && password_verify($password, $user['password_hash'])) {
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);
Log::info("Successful login for {$username}");
Session::newLoginSession($user);
header('Location: ' . Util::buildRelativeUrl($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,9 +44,11 @@ class AuthController extends Controller {
}
function handleLogout(){
Log::info("Logout from user " . $_SESSION['username']);
Session::end();
global $config;
header('Location: ' . $config->basePath);
header('Location: ' . Util::buildRelativeUrl($config->basePath));
exit;
}
}

View File

@ -1,5 +1,7 @@
<?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: ' . $config->basePath . 'admin/emoji');
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'admin/emoji'));
exit;
}

View File

@ -1,34 +1,29 @@
<?php
class FeedController extends Controller {
private array $vars;
private $ticks;
protected function render(string $templateFile, array $vars = []) {
$templatePath = TEMPLATES_DIR . "/" . $templateFile;
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);
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,
];
Log::debug("Loaded " . count($this->ticks) . " ticks for feeds");
}
public function rss(){
$this->render("feed/rss.php", $this->vars);
$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();
}
public function atom(){
$this->render("feed/atom.php", $this->vars);
$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();
}
}

View File

@ -4,43 +4,68 @@ class HomeController extends Controller {
// renders the homepage view.
public function index(){
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
global $config;
global $user;
$data = $this->getHomeData($page);
$this->render("home.php", $data);
}
public function getHomeData(int $page): array {
Log::debug("Loading home page $page");
$tickModel = new TickModel();
$limit = $config->itemsPerPage;
$tickModel = new TickModel($this->db, $this->config);
$limit = $this->config->itemsPerPage;
$offset = ($page - 1) * $limit;
$ticks = iterator_to_array($tickModel->stream($limit, $offset));
$ticks = $tickModel->getPage($limit, $offset);
$view = new HomeView();
$tickList = $view->renderTicksSection($config->siteDescription, $ticks, $page, $limit);
$view = new TicksView($this->config, $ticks, $page);
$tickList = $view->getHtml();
$vars = [
'config' => $config,
'user' => $user,
Log::info("Home page loaded with " . count($ticks) . " ticks");
return [
'config' => $this->config,
'user' => $this->user,
'tickList' => $tickList,
];
$this->render("home.php", $vars);
}
// POST handler
// Saves the tick and reloads the homepage
public function handleTick(){
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;
$result = $this->processTick($_POST);
// redirect to the index (will show the latest tick if one was sent)
header('Location: ' . $config->basePath);
header('Location: ' . Util::buildRelativeUrl($this->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

@ -0,0 +1,123 @@
<?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: ' . $config->basePath);
header('Location: ' . Util::buildRelativeUrl($config->basePath));
exit;
}
}

View File

@ -0,0 +1,56 @@
<?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

@ -0,0 +1,24 @@
<?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);
}
}

50
src/Feed/RssGenerator.php Normal file
View File

@ -0,0 +1,50 @@
<?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,5 +1,6 @@
<?php
class Database{
// TODO = Make this not static
public static function get(): PDO {
try {
// SQLite will just create this if it doesn't exist.
@ -19,20 +20,16 @@ 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() ?? -1;
return $db->query("PRAGMA user_version")->fetchColumn() ?? 0;
}
private function migrationNumberFromFile(string $filename): int {
@ -63,6 +60,7 @@ class Database{
foreach ($files as $file) {
$version = $this->migrationNumberFromFile($file);
if ($version > $currentVersion) {
Log::debug("Found pending migration ({$version}): " . basename($file));
$pending[$version] = $file;
}
}
@ -71,13 +69,15 @@ class Database{
return $pending;
}
private function migrate(): void {
public function migrate(): void {
$migrations = $this->getPendingMigrations();
if (empty($migrations)) {
# TODO: log
Log::debug("No pending migrations");
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);
// TODO: log properly
Log::debug("Starting migration: {$filename}");
$sql = file_get_contents($file);
if ($sql === false) {
@ -99,17 +99,20 @@ 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(
@ -121,88 +124,16 @@ 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
private function validateTableContents(): void {
public function confirmSetup(): 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 and we aren't on /admin,
// redirect to /admin to complete setup
// If either required table has no records, throw an exception.
// This will be caught and redirect to setup.
if ($user_count === 0 || $settings_count === 0){
throw new SetupException(
"Required tables aren't populated. Please complete setup",

View File

@ -12,14 +12,17 @@ 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 'load_classes':
case 'table_creation':
case 'db_migration':
// Unrecoverable errors.
// Show error message and exit
http_response_code(500);
@ -31,7 +34,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 anything can be loaded.
// setup. It shouldn't assume any data can be loaded.
$init = require APP_ROOT . '/config/init.php';
$currentPath = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');

View File

@ -1,54 +0,0 @@
<?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'
);
}
}
}
}
}

95
src/Framework/Log/Log.php Normal file
View File

@ -0,0 +1,95 @@
<?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

@ -0,0 +1,570 @@
<?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,6 +1,8 @@
<?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 = [
@ -12,6 +14,7 @@ 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'],
@ -27,7 +30,7 @@ class Router {
// Main router function
public static function route(string $requestPath, string $requestMethod): bool {
public function route(string $requestPath, string $requestMethod): bool {
foreach (self::$routeHandlers as $routeHandler) {
$routePattern = $routeHandler[0];
$controller = $routeHandler[1];
@ -39,27 +42,33 @@ 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, $methodName] = explode('@', $controller);
[$controllerName, $functionName] = explode('@', $controller);
} else {
// Default to 'index' if no method specified
$controllerName = $controller;
$methodName = 'index';
$functionName = 'index';
}
$instance = new $controllerName();
call_user_func_array([$instance, $methodName], $matches);
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);
return true;
}
}
}
Log::warning("No route found for path '{$requestPath}'");
return false;
}

View File

@ -6,10 +6,30 @@ 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));
@ -59,6 +79,7 @@ class Session {
}
public static function end(): void {
Log::debug("Ending session: " . session_id());
$_SESSION = [];
session_destroy();
}

View File

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

View File

@ -1,33 +1,24 @@
<?php
class TickModel {
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 ?");
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 ?");
$stmt->execute([$limit, $offset]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield [
'id' => $row['id'],
'timestamp' => $row['timestamp'],
'tick' => $row['tick'],
];
}
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
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 = $db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)");
$stmt = $this->db->prepare("INSERT INTO tick(timestamp, tick) values (?, ?)");
$stmt->execute([$timestamp, $tick]);
}
public function get(int $id): array {
global $db;
$stmt = $db->prepare("SELECT timestamp, tick FROM tick WHERE id=?");
$stmt = $this->db->prepare("SELECT timestamp, tick FROM tick WHERE id=?");
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
@ -35,7 +26,7 @@ class TickModel {
return [
'tickTime' => $row['timestamp'],
'tick' => $row['tick'],
'config' => ConfigModel::load(),
'config' => $this->config,
];
}
}

View File

@ -6,14 +6,21 @@ class UserModel {
public string $website = '';
public string $mood = '';
// load user settings from sqlite database
public function __construct(private PDO $db) {}
// load user settings from sqlite database (backward compatibility)
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 = $db->query("SELECT username, display_name, website, mood FROM user WHERE id=1");
$stmt = $this->db->query("SELECT username, display_name, website, mood FROM user WHERE id=1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$u = new self();
$u = new self($this->db);
if ($row) {
$u->username = $row['username'];
@ -26,27 +33,32 @@ class UserModel {
}
public function save(): self {
global $db;
$userCount = (int) $db->query("SELECT COUNT(*) FROM user")->fetchColumn();
$userCount = (int) $this->db->query("SELECT COUNT(*) FROM user")->fetchColumn();
if ($userCount === 0){
$stmt = $db->prepare("INSERT INTO user (id, username, display_name, website, mood) VALUES (1, ?, ?, ?, ?, ?)");
$stmt = $this->db->prepare("INSERT INTO user (id, username, display_name, website, mood) VALUES (1, ?, ?, ?, ?)");
} else {
$stmt = $db->prepare("UPDATE user SET username=?, display_name=?, website=?, mood=? WHERE id=1");
$stmt = $this->db->prepare("UPDATE user SET username=?, display_name=?, website=?, mood=? WHERE id=1");
}
$stmt->execute([$this->username, $this->displayName, $this->website, $this->mood]);
return self::load();
return $this->loadFromDatabase();
}
// Making this a separate function to avoid
// loading the password into memory
public function set_password(string $password): void {
global $db;
public function setPassword(string $password): void {
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare("UPDATE user SET password_hash=? WHERE id=1");
$stmt = $this->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,7 +1,16 @@
<?php
class HomeView {
public function renderTicksSection(string $siteDescription, array $ticks, int $page, int $limit){
global $config;
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{
ob_start();
?>
@ -22,7 +31,7 @@ class HomeView {
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="?page=<?php echo $page - 1 ?>">&laquo; Newer</a>
<?php endif; ?>
<?php if (count($ticks) === $limit): ?>
<?php if (count($ticks) === $config->itemsPerPage): ?>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="?page=<?php echo $page + 1 ?>">Older &raquo;</a>
<?php endif; ?>

View File

@ -1,40 +0,0 @@
<?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>

View File

@ -1,39 +0,0 @@
<?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($config->basePath) ?>css/default.css">
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/default.css')) ?>">
<?php if (!empty($config->cssId)): ?>
<link rel="stylesheet"
href="<?= Util::escape_html($config->basePath) ?>css/custom/<?= Util::escape_html($config->customCssFilename()) ?>">
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'css/custom/' . $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 $config->basePath . ($isSetup ? 'setup' : 'admin') ?>"
action="<?php echo Util::buildRelativeUrl($config->basePath, ($isSetup ? 'setup' : 'admin')) ?>"
method="post">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<fieldset>
@ -67,6 +67,13 @@
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="<?= $config->basePath ?>admin/css" method="post" enctype="multipart/form-data">
<form action="<?= Util::buildRelativeUrl($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="<?= $config->basePath ?>admin/emoji" method="post" enctype="multipart/form-data">
<form action="<?= Util::buildRelativeUrl($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="<?= $config->basePath ?>admin/emoji" method="post" enctype="multipart/form-data">
<form action="<?= Util::buildRelativeUrl($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($config->basePath) ?>mood"
href="<?= Util::escape_html(Util::buildRelativeUrl($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="<?= $config->basePath ?>login">
<form method="post" action="<?= Util::buildRelativeUrl($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

@ -0,0 +1,90 @@
<?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,32 +2,34 @@
<?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($config->basePath) ?>">home</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($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($config->basePath) ?>feed/rss">rss</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/rss')) ?>">rss</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>feed/atom">atom</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'feed/atom')) ?>">atom</a>
</div>
</details>
<?php if (!Session::isLoggedIn()): ?>
<a tabindex="0"
href="<?= Util::escape_html($config->basePath) ?>login">login</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($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($config->basePath) ?>admin">settings</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin')) ?>">settings</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>admin/css">css</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>admin/emoji">emoji</a>
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>
</div>
</details>
<a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
href="<?= Util::escape_html($config->basePath) ?>logout">logout</a>
href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'logout')) ?>">logout</a>
<?php endif; ?>
</nav>

View File

@ -0,0 +1,367 @@
<?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

@ -0,0 +1,175 @@
<?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

@ -0,0 +1,312 @@
<?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

@ -0,0 +1,262 @@
<?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

@ -0,0 +1,140 @@
<?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

@ -0,0 +1,118 @@
<?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

@ -0,0 +1,136 @@
<?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

@ -0,0 +1,169 @@
<?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,4 +26,51 @@ 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);
}
}