362 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			362 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| // This is the initialization code that needs to be run before anything else.
 | |
| // - define paths
 | |
| // - confirm /storage directory exists and is writable
 | |
| // - make sure database is ready
 | |
| // - load classes
 | |
| 
 | |
| // Define all the important paths
 | |
| define('APP_ROOT', dirname(dirname(__FILE__)));
 | |
| 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 an exception for validation errors
 | |
| class SetupException extends Exception {
 | |
|     private $setupIssue;
 | |
| 
 | |
|     public function __construct(string $message, string $setupIssue = '', int $code = 0, Throwable $previous = null) {
 | |
|         parent::__construct($message, $code, $previous);
 | |
|         $this->setupIssue = $setupIssue;
 | |
|     }
 | |
| 
 | |
|     public function getSetupIssue(): string {
 | |
|         return $this->setupIssue;
 | |
|     }
 | |
| }
 | |
| 
 | |
| 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 'table_creation':
 | |
|             // Unrecoverable errors.
 | |
|             // Show error message and exit
 | |
|             http_response_code(500);
 | |
|             echo "<h1>Configuration Error</h1>";
 | |
|             echo "<p>" . Util::escape_html($e->getSetupIssue) . '-' . Util::escape_html($e->getMessage()) . "</p>";
 | |
|             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;
 | |
|             }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // 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(DATA_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',
 | |
|         );
 | |
|     };
 | |
| }
 | |
| 
 | |
| // Load all classes from the src/ directory
 | |
| function load_classes(): void {
 | |
|     $iterator = new RecursiveIteratorIterator(
 | |
|         new RecursiveDirectoryIterator(SRC_DIR)
 | |
|     );
 | |
| 
 | |
|     // load base classes first
 | |
|     require_once SRC_DIR . '/Controller/Controller.php';
 | |
| 
 | |
|     // load everything else
 | |
|     foreach ($iterator as $file) {
 | |
|         if ($file->isFile() && fnmatch('*.php', $file->getFilename())) {
 | |
|             require_once $file;
 | |
|         }
 | |
|     }
 | |
| }
 |