From 5998e7f7d38dcb53883b3bf7867d3e072855b848 Mon Sep 17 00:00:00 2001 From: Greg Sarjeant <1686767+gsarjeant@users.noreply.github.com> Date: Mon, 30 Jun 2025 08:43:45 -0400 Subject: [PATCH] simplify bootstrap. move validation to classes. --- config/bootstrap.php | 338 +-------------------- public/index.php | 16 +- src/Framework/Database/Database.php | 203 +++++++++++++ src/Framework/Exception/SetupException.php | 46 +++ src/Framework/Filesystem/Filesystem.php | 91 ++++++ storage/db/tkr.sqlite.bak | Bin 28672 -> 0 bytes 6 files changed, 352 insertions(+), 342 deletions(-) create mode 100644 src/Framework/Database/Database.php create mode 100644 src/Framework/Exception/SetupException.php create mode 100644 src/Framework/Filesystem/Filesystem.php delete mode 100755 storage/db/tkr.sqlite.bak diff --git a/config/bootstrap.php b/config/bootstrap.php index 1b7ae91..555521f 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -1,9 +1,7 @@ setupIssue = $setupIssue; - } - - public function getSetupIssue(): string { - return $this->setupIssue; - } -} - -// Exception handler -function handle_setup_exception(SetupException $e){ - switch ($e->getSetupIssue()){ - case 'storage_missing': - case 'storage_permissions': - case 'directory_creation': - case 'directory_permissions': - case 'database_connection': - case 'load_classes': - case 'table_creation': - // Unrecoverable errors. - // Show error message and exit - http_response_code(500); - echo "

Configuration Error

"; - echo "

" . Util::escape_html($e->getSetupIssue) . '-' . Util::escape_html($e->getMessage()) . "

"; - exit; - case 'table_contents': - // Recoverable error. - // 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. - $init = require APP_ROOT . '/config/init.php'; - $currentPath = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/'); - - if (strpos($currentPath, 'setup') === false) { - header('Location: ' . $init['base_path'] . 'setup'); - exit; - } - } -} - // Janky autoloader function // This is a bit more consistent with current frameworks function autoloader($className) { $classFilename = $className . '.php'; - $iterator = new RecursiveIteratorIterator( + $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(SRC_DIR, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); - foreach ($iterator as $file) { + foreach ($files as $file) { if ($file->getFilename() === $classFilename) { include_once $file->getPathname(); return; @@ -87,287 +39,3 @@ function autoloader($className) { // Register the autoloader spl_autoload_register('autoloader'); - -// Main validation function -// Any failures will throw a SetupException -function confirm_setup(): void { - validate_storage_dir(); - validate_storage_subdirs(); - validate_tables(); - validate_table_contents(); - migrate_db(); - migrate_tick_files(); -} - -// Make sure the storage/ directory exists and is writable -function validate_storage_dir(): 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 -function validate_storage_subdirs(): void { - $storageSubdirs = array(); - $storageSubdirs[] = CSS_UPLOAD_DIR; - $storageSubdirs[] = DATA_DIR; - $storageSubdirs[] = TICKS_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' - ); - } - } - } -} - -function migrate_tick_files() { - $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator(TICKS_DIR, RecursiveDirectoryIterator::SKIP_DOTS) - ); - - foreach ($files as $file) { - if ($file->isFile() && str_ends_with($file->getFilename(), '.txt')) { - migrate_tick_file($file->getPathname()); - } - } -} - -function migrate_tick_file($filepath) { - $lines = file($filepath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - $modified = false; - - foreach ($lines as &$line) { - $fields = explode('|', $line); - if (count($fields) === 2) { - // Convert id|text to id|emoji|text - $line = $fields[0] . '||' . $fields[1]; - $modified = true; - } - } - - if ($modified) { - file_put_contents($filepath, implode("\n", $lines) . "\n"); - // TODO: log properly - //echo "Migrated: " . basename($filepath) . "\n"; - } -} - -function get_db(): PDO { - try { - // SQLite will just create this if it doesn't exist. - $db = new PDO("sqlite:" . DB_FILE); - $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); - } catch (PDOException $e) { - throw new SetupException( - "Database connection failed: " . $e->getMessage(), - 'database_connection', - 0, - $e - ); - } - - return $db; -} - -// 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. -function get_db_version(): int { - $db = get_db(); - - return $db->query("PRAGMA user_version")->fetchColumn() ?? 0; -} - -function migration_number_from_file(string $filename): int { - $basename = basename($filename, '.sql'); - $parts = explode('_', $basename); - return (int) $parts[0]; -} - -function set_db_version(int $newVersion): void { - $currentVersion = get_db_version(); - - if ($newVersion <= $currentVersion){ - throw new SetupException( - "New version ($newVersion) must be greater than current version ($currentVersion)", - 'db_migration' - ); - } - - $db = get_db(); - $db->exec("PRAGMA user_version = $newVersion"); -} - -function get_pending_migrations(): array { - $currentVersion = get_db_version(); - $files = glob(CONFIG_DIR . '/migrations/*.sql'); - - $pending = []; - foreach ($files as $file) { - $version = migration_number_from_file($file); - if ($version > $currentVersion) { - $pending[$version] = $file; - } - } - - ksort($pending); - return $pending; -} - -function migrate_db(): void { - $migrations = get_pending_migrations(); - - if (empty($migrations)) { - # TODO: log - return; - } - - $db = get_db(); - $db->beginTransaction(); - - try { - foreach ($migrations as $version => $file) { - $filename = basename($file); - // TODO: log properly - - $sql = file_get_contents($file); - if ($sql === false) { - throw new Exception("Could not read migration file: $file"); - } - - // Execute the migration SQL - $db->exec($sql); - } - - // Update db version - $db->commit(); - set_db_version($version); - //TODO: log properly - //echo "All migrations completed successfully.\n"; - - } catch (Exception $e) { - $db->rollBack(); - throw new SetupException( - "Migration failed: $filename", - 'db_migration', - 0, - $e - ); - } -} - -function create_tables(): void { - $db = get_db(); - - 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 -function validate_tables(): void { - $appTables = array(); - $appTables[] = "settings"; - $appTables[] = "user"; - $appTables[] = "css"; - $appTables[] = "emoji"; - - $db = get_db(); - - 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 - create_tables(); - } - } -} - -// make sure tables that need to be seeded have been -function validate_table_contents(): void { - $db = get_db(); - - // 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 ($user_count === 0 || $settings_count === 0){ - throw new SetupException( - "Required tables aren't populated. Please complete setup", - 'table_contents', - ); - }; -} diff --git a/public/index.php b/public/index.php index b0b8471..cc5f91f 100644 --- a/public/index.php +++ b/public/index.php @@ -13,25 +13,27 @@ if (preg_match('/\.php$/', $path)) { // Define base paths and load classes include_once(dirname(dirname(__FILE__)) . "/config/bootstrap.php"); -//load_classes(); // Make sure the initial setup is complete // unless we're already heading to setup if (!(preg_match('/setup$/', $path))) { try { - confirm_setup(); + // filesystem validation + $fsMgr = new Filesystem(); + $fsMgr->validate(); + + // database validation + $dbMgr = new Database(); + $dbMgr->validate(); } catch (SetupException $e) { - handle_setup_exception($e); + $e->handle(); exit; } } // initialize the database global $db; -$db = get_db(); - -// Everything's loaded and setup is confirmed. -// Let's start ticking. +$db = Database::get(); // Initialize core entities // Defining these as globals isn't great practice, diff --git a/src/Framework/Database/Database.php b/src/Framework/Database/Database.php new file mode 100644 index 0000000..544116f --- /dev/null +++ b/src/Framework/Database/Database.php @@ -0,0 +1,203 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + } catch (PDOException $e) { + throw new SetupException( + "Database connection failed: " . $e->getMessage(), + 'database_connection', + 0, + $e + ); + } + + return $db; + } + + 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. + private function getVersion(): int { + $db = self::get(); + + return $db->query("PRAGMA user_version")->fetchColumn() ?? 0; + } + + private function migrationNumberFromFile(string $filename): int { + $basename = basename($filename, '.sql'); + $parts = explode('_', $basename); + return (int) $parts[0]; + } + + private function setVersion(int $newVersion): void { + $currentVersion = $this->getVersion(); + + if ($newVersion <= $currentVersion){ + throw new SetupException( + "New version ($newVersion) must be greater than current version ($currentVersion)", + 'db_migration' + ); + } + + $db = self::get(); + $db->exec("PRAGMA user_version = $newVersion"); + } + + private function getPendingMigrations(): array { + $currentVersion = $this->getVersion(); + $files = glob(CONFIG_DIR . '/migrations/*.sql'); + + $pending = []; + foreach ($files as $file) { + $version = $this->migrationNumberFromFile($file); + if ($version > $currentVersion) { + $pending[$version] = $file; + } + } + + ksort($pending); + return $pending; + } + + private function migrate(): void { + $migrations = $this->getPendingMigrations(); + + if (empty($migrations)) { + # TODO: log + return; + } + + $db = get_db(); + $db->beginTransaction(); + + try { + foreach ($migrations as $version => $file) { + $filename = basename($file); + // TODO: log properly + + $sql = file_get_contents($file); + if ($sql === false) { + throw new Exception("Could not read migration file: $file"); + } + + // Execute the migration SQL + $db->exec($sql); + } + + // Update db version + $db->commit(); + $this->setVersion($version); + //TODO: log properly + //echo "All migrations completed successfully.\n"; + + } catch (Exception $e) { + $db->rollBack(); + throw new SetupException( + "Migration failed: $filename", + 'db_migration', + 0, + $e + ); + } + } + + 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 { + $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 ($user_count === 0 || $settings_count === 0){ + throw new SetupException( + "Required tables aren't populated. Please complete setup", + 'table_contents', + ); + }; + } +} \ No newline at end of file diff --git a/src/Framework/Exception/SetupException.php b/src/Framework/Exception/SetupException.php new file mode 100644 index 0000000..cc362e0 --- /dev/null +++ b/src/Framework/Exception/SetupException.php @@ -0,0 +1,46 @@ +setupIssue = $setupIssue; + } + + // Exception handler + // Exceptions don't generally define their own handlers, + // 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': + // Unrecoverable errors. + // Show error message and exit + http_response_code(500); + echo "

Configuration Error

"; + echo "

" . Util::escape_html($this->setupIssue) . '-' . Util::escape_html($this->getMessage()) . "

"; + exit; + case 'table_contents': + // Recoverable error. + // 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. + $init = require APP_ROOT . '/config/init.php'; + $currentPath = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/'); + + if (strpos($currentPath, 'setup') === false) { + header('Location: ' . $init['base_path'] . 'setup'); + exit; + } + } + } + + +} \ No newline at end of file diff --git a/src/Framework/Filesystem/Filesystem.php b/src/Framework/Filesystem/Filesystem.php new file mode 100644 index 0000000..39a5087 --- /dev/null +++ b/src/Framework/Filesystem/Filesystem.php @@ -0,0 +1,91 @@ +validateStorageDir(); + $this->validateStorageSubdirs(); + $this->migrateTickFiles(); + } + + // 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; + $storageSubdirs[] = TICKS_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' + ); + } + } + } + } + + // TODO: Delete this sometime before 1.0 + // Add mood to tick files + private function migrateTickFiles(): void { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(TICKS_DIR, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($files as $file) { + if ($file->isFile() && str_ends_with($file->getFilename(), '.txt')) { + $this->migrateTickFile($file->getPathname()); + } + } + } + + // TODO: Delete this sometime before 1.0 + // Add mood field to tick files if necessary + private function migrateTickFile($filepath): void { + $lines = file($filepath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $modified = false; + + foreach ($lines as &$line) { + $fields = explode('|', $line); + if (count($fields) === 2) { + // Convert id|text to id|emoji|text + $line = $fields[0] . '||' . $fields[1]; + $modified = true; + } + } + + if ($modified) { + file_put_contents($filepath, implode("\n", $lines) . "\n"); + // TODO: log properly + } + } +} \ No newline at end of file diff --git a/storage/db/tkr.sqlite.bak b/storage/db/tkr.sqlite.bak deleted file mode 100755 index e677bd9b57eb442b9349d2ce354a584e221784cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI)&u`mg7zc1WX`9luxKasG4}}*|eiT^RF);+vG`cD)Wm(#$sn(s0W50>rIDS39 zuW57OkZMAlc0i27E1^yWlvDjU{Tu=ALm_A)2N=l50+`y+s`l^q$DQ-gkW5;Ulg1)jMOOb#3U< zL+!<1$G#i=dvx>C!_g0qI|1zwfB*y_009U<00I!WI0D@_2Xlo&LEm{-SaqM$Hrw=) z&Co)%G+!%`+WgIOi6l*LkC48_bI4MqR$44o$x3zU&V2PAxm~*VUMeOzjnqmTHL_k= zT3s)Z%5sfV*30E|oI|-Cd7fkU_XeP50tOe+>Fd2S|u z$h?y5)Tm)t^2%1$!-xZbx3+qIK0z$Gx1~SjnxQ1{#xhrfy7W@__{c5P$##AOHaf zKmY;|fB*y_a4`kCpX(!9KHnYe@`ZdeqRmCQPQTxozA`txw({v`vHekJIow#dUtPOh zxmyotygC;w-&$L|X0Noi=Zc~C#b)_~XzS);Yh(MWb#39UD?em+tuUkkCtibiq;0h* z5iU*52t}6fcZkn|<_)q$+SCe2L=W1Er0gXpdGb9t5sW3F#0umbAmJ{HeJ6NJ5Qh;@ z6kQ=g{_*TAkLz~qi#VdJ!2()jQFCwio3HoB|4%jJ>Ba0~R1E57(Z*q?{b3$0uX=z z1Rwwb2tWV=5P$##AOL}LBru*C(DGAzyFYyEQm08>)_hr)Ei-$&-~Z@%ycv6(I+q)k zP1X26Yy6}ce;B{W9To^c00Izz00bZa0SG_<0uX=z1fEZUiL4yjzpnn7zivf!`BR1= z^XW@!2H=go-N#RC7H|>8w)#2GaWxB&nim+6HD!%wn(@r|)A;N8ED%{i00Izz00bZa l0SG_<0uX=z1R(H#1ctMDT}=>V_YWRF$>g&67u5Fy{sB&7%g6u#