diff --git a/.gitea/workflows/prerequisites.yml b/.gitea/workflows/prerequisites.yml new file mode 100644 index 0000000..dd42d3f --- /dev/null +++ b/.gitea/workflows/prerequisites.yml @@ -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 fnd git or actions + 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 config + + # Should fail + if php check-prerequisites.php; then + echo "ERROR: Should have failed with missing directories" + exit 1 + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c0188b..a54800f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ storage/upload/css scratch # Build artifacts -tkr.tgz \ No newline at end of file +tkr.tgz + +# Test logs +storage/prerequisite-check.log \ No newline at end of file diff --git a/README.md b/README.md index dabcef2..b53c5eb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # tkr +![Prerequisite tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/prerequisites.yaml/badge.svg) ![Unit tests status](https://gitea.subcultureofone.org/greg/tkr/actions/workflows/unit_tests.yaml/badge.svg) A lightweight, HTML-only status feed for self-hosted personal websites. Written in PHP. Heavily inspired by [status.cafe](https://status.cafe). diff --git a/check-prerequisites.php b/check-prerequisites.php new file mode 100644 index 0000000..81fbd78 --- /dev/null +++ b/check-prerequisites.php @@ -0,0 +1,21 @@ +#!/usr/bin/env php +validate(); + +// Exit with appropriate code for shell scripts +if (php_sapi_name() === 'cli') { + exit(count($prerequisites->getErrors()) > 0 ? 1 : 0); +} \ No newline at end of file diff --git a/config/bootstrap.php b/config/bootstrap.php index 3a0ed9e..c65e441 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -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 diff --git a/public/index.php b/public/index.php index 1ac97c4..a94c73f 100644 --- a/public/index.php +++ b/public/index.php @@ -14,20 +14,27 @@ if (preg_match('/\.php$/', $path)) { // Define base paths and load classes include_once(dirname(dirname(__FILE__)) . "/config/bootstrap.php"); -// validate that necessary directories exist and are writable -$fsMgr = new Filesystem(); -$fsMgr->validate(); +// Check prerequisites. +$prerequisites = new Prerequisites(); +$results = $prerequisites->validate(); +if (count($prerequisites->getErrors()) > 0) { + $prerequisites->generateWebSummary($results); + exit; +} -// do any necessary database migrations +// 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 { - // database validation - $dbMgr->validate(); + // Make sure setup has been completed + $dbMgr->confirmSetup(); } catch (SetupException $e) { $e->handle(); exit; diff --git a/src/Framework/Database/Database.php b/src/Framework/Database/Database.php index 9e4127c..b503d23 100644 --- a/src/Framework/Database/Database.php +++ b/src/Framework/Database/Database.php @@ -119,7 +119,7 @@ class Database{ } // 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 diff --git a/src/Framework/Exception/SetupException.php b/src/Framework/Exception/SetupException.php index dda82a8..439157d 100644 --- a/src/Framework/Exception/SetupException.php +++ b/src/Framework/Exception/SetupException.php @@ -13,13 +13,8 @@ class SetupException extends Exception { // but this is a very specific case. public function handle(){ 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 +26,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), '/'); diff --git a/src/Framework/Filesystem/Filesystem.php b/src/Framework/Filesystem/Filesystem.php deleted file mode 100644 index fbd1816..0000000 --- a/src/Framework/Filesystem/Filesystem.php +++ /dev/null @@ -1,54 +0,0 @@ -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' - ); - } - } - } - } -} \ No newline at end of file diff --git a/src/Framework/Prerequisites/Prerequisites.php b/src/Framework/Prerequisites/Prerequisites.php new file mode 100644 index 0000000..9c4cba2 --- /dev/null +++ b/src/Framework/Prerequisites/Prerequisites.php @@ -0,0 +1,536 @@ +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); + //print("Log dir: {$logDir}"); + 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; + } + } + + $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"; + } + } + + 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() { + $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', 'curl', '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() { + $storageDirs = array( + 'storage', + 'storage/db', + '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 ' + + + + + tkr - Setup Required + + + +
+
+

⚠️ Setup Required

+

tkr cannot start due to system configuration issues

+
'; + + $hasErrors = false; + $hasWarnings = false; + + // Display errors + foreach ($this->checks as $check) { + if (!$check['status'] && $check['severity'] === 'error') { + if (!$hasErrors) { + echo '

Critical Issues

'; + $hasErrors = true; + } + echo '
+
✗ ' . htmlspecialchars($check['name']) . '
+ ' . htmlspecialchars($check['message']) . ' +
'; + } + } + + // Display warnings + foreach ($this->checks as $check) { + if (!$check['status'] && $check['severity'] === 'warning') { + if (!$hasWarnings) { + echo '

Warnings

'; + $hasWarnings = true; + } + echo '
+
⚠ ' . htmlspecialchars($check['name']) . '
+ ' . htmlspecialchars($check['message']) . ' +
'; + } + } + + // Resolution steps + echo '
+

How to Fix These Issues

+ +

Need Help? Check the tkr documentation or contact your hosting provider with the error details above.

+
+ +
+

Technical Details: Full diagnostic information has been logged to ' . htmlspecialchars($this->logFile) . '

+

Check Time: ' . date('Y-m-d H:i:s') . '

+
+
+ +'; + } + + 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}"); + } + } + + $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; + } +} \ No newline at end of file