Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
9593a43cc0 | |||
879bd9ff9f | |||
a7e79796fa | |||
dc0abf8c7c | |||
8b5a249450 | |||
a9f610fc60 | |||
dc63d70944 | |||
bb58e09cbf | |||
659808f724 | |||
681f64c527 | |||
dc44d51479 | |||
3c0f2a2ca5 | |||
64e2ff56da | |||
fb0b58dcbf | |||
53ed66dce9 | |||
b53d58df8c | |||
6337fa2dfb | |||
4255f46fc7 |
@ -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 \
|
||||
|
163
.gitea/workflows/prerequisites.yaml
Normal file
163
.gitea/workflows/prerequisites.yaml
Normal 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
6
.gitignore
vendored
@ -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
|
39
README.md
39
README.md
@ -1,4 +1,5 @@
|
||||
# tkr
|
||||

|
||||

|
||||
|
||||
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
21
check-prerequisites.php
Normal 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);
|
||||
}
|
@ -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
|
||||
|
@ -1,2 +0,0 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN strict_accessibility BOOLEAN DEFAULT TRUE;
|
@ -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
0
config/migrations/002_add_show_tick_mood_setting.sql
Executable file → Normal file
2
config/migrations/006_add_log_level_setting.sql
Normal file
2
config/migrations/006_add_log_level_setting.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN log_level INTEGER NULL;
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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";
|
||||
|
@ -29,7 +29,7 @@
|
||||
break;
|
||||
}
|
||||
|
||||
header('Location: ' . $config->basePath . 'admin/emoji');
|
||||
header('Location: ' . Util::buildRelativeUrl($config->basePath, 'admin/emoji'));
|
||||
exit;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
123
src/Controller/LogController/LogController.php
Normal file
123
src/Controller/LogController/LogController.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
56
src/Feed/AtomGenerator.php
Normal file
56
src/Feed/AtomGenerator.php
Normal 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();
|
||||
}
|
||||
}
|
24
src/Feed/FeedGenerator.php
Normal file
24
src/Feed/FeedGenerator.php
Normal 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
50
src/Feed/RssGenerator.php
Normal 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();
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
@ -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), '/');
|
||||
|
||||
|
@ -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
95
src/Framework/Log/Log.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
570
src/Framework/Prerequisites/Prerequisites.php
Normal file
570
src/Framework/Prerequisites/Prerequisites.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 ?>">« 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 »</a>
|
||||
<?php endif; ?>
|
@ -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>
|
@ -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>
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
90
templates/partials/logs.php
Normal file
90
templates/partials/logs.php
Normal 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>
|
@ -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>
|
367
tests/Controller/AdminController/AdminControllerTest.php
Normal file
367
tests/Controller/AdminController/AdminControllerTest.php
Normal 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);
|
||||
}
|
||||
}
|
175
tests/Controller/FeedController/FeedControllerTest.php
Normal file
175
tests/Controller/FeedController/FeedControllerTest.php
Normal 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);
|
||||
}
|
||||
}
|
312
tests/Controller/HomeController/HomeControllerTest.php
Normal file
312
tests/Controller/HomeController/HomeControllerTest.php
Normal 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);
|
||||
}
|
||||
}
|
262
tests/Controller/LogController/LogControllerTest.php
Normal file
262
tests/Controller/LogController/LogControllerTest.php
Normal 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']);
|
||||
}
|
||||
}
|
140
tests/Feed/AtomGeneratorTest.php
Normal file
140
tests/Feed/AtomGeneratorTest.php
Normal 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('<tag>', $xml);
|
||||
$this->assertStringContainsString('&', $xml);
|
||||
$this->assertStringContainsString('"quotes"', $xml);
|
||||
$this->assertStringContainsString(''apostrophes'', $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');
|
||||
}
|
||||
}
|
118
tests/Feed/FeedGeneratorTest.php
Normal file
118
tests/Feed/FeedGeneratorTest.php
Normal 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'");
|
||||
}
|
||||
}
|
||||
}
|
136
tests/Feed/RssGeneratorTest.php
Normal file
136
tests/Feed/RssGeneratorTest.php
Normal 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('<tag>', $xml);
|
||||
$this->assertStringContainsString('&', $xml);
|
||||
$this->assertStringContainsString('"quotes"', $xml);
|
||||
$this->assertStringContainsString(''apostrophes'', $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');
|
||||
}
|
||||
}
|
169
tests/Framework/Log/LogTest.php
Normal file
169
tests/Framework/Log/LogTest.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user