diff --git a/Dockerfile b/Dockerfile index b9a078e..4a7b236 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,8 +30,13 @@ RUN mkdir -p /var/www/data \ # Apache configuration COPY apache-vhost.conf /etc/apache2/sites-available/000-default.conf +# Entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + USER www-data ENV KITTYSHARE_FILE_SERVER=x-sendfile ENV KITTYSHARE_DATABASE_PATH=/var/www/data/database.sqlite ENV KITTYSHARE_ROOT=/data/files + +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/README.md b/README.md index d58f81c..7ffc5b3 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ from releases are tagged with the release version, such as `v1.2.0`. The `latest` tag always points to the latest release and **not** to the latest commit. +The container migrates the SQLite database schema automatically at startup (via +`docker-entrypoint.sh` running `php bin/migrate`) before Apache starts. A failed +migration aborts container startup. + For configuring the application look at the section [Environment Variables](#environment-variables). ### Running directly on a PHP server @@ -46,6 +50,14 @@ cd KittyShare composer install --no-dev --optimize-autoloader ``` +Then migrate the SQLite database schema (also required after updating to a new +release, before serving traffic): + +```sh +php bin/migrate +# or: composer migrate +``` + Configure your web server with `public/` as the document root: ```text @@ -159,6 +171,7 @@ Internal PHP files can be found in the [`src/`](src)-folder. It is divided into: - `Http` - Everything that has to do with the request and response. As such the Router and the response classes are in here. - `Manager` - The classes that do not directly access resources, but manage these based on the repositories. - `Repository` - A simple abstraction of a specific resource. For example the SQLite database. +- `Database` - Connection and migrations for the databases. The glue code of the database and the application repositories/migration scripts. - `Controller` - The classes that decide on what to do with the request. They call the correct repositories, managers and return some response object. - `Model` - The classes that model the data that can be found in the project - `Template` - Templates that return HTML based on the data. They are also PHP files. diff --git a/bin/migrate b/bin/migrate new file mode 100755 index 0000000..3d6bc6d --- /dev/null +++ b/bin/migrate @@ -0,0 +1,51 @@ +#!/usr/bin/env php +databasePath; + +$parentDir = dirname($databasePath); + +if (!is_dir($parentDir) && !@mkdir($parentDir, 0777, true) && !is_dir($parentDir)) { + fwrite(STDERR, "Could not create database directory: {$parentDir}\n"); + exit(1); +} + +try { + $pdo = SQLiteDatabase::connect($databasePath); + $applied = SQLiteMigrator::migrate($pdo); +} catch (Throwable $e) { + fwrite(STDERR, 'Migration failed: ' . $e->getMessage() . "\n"); + exit(1); +} + +if ($applied === []) { + echo "Database is up to date (version " . (new SQLiteDBVersionRepository($pdo))->currentVersion() . ").\n"; +} else { + echo 'Applied migrations: V' . implode(', V', $applied) . "\n"; +} + +exit(0); diff --git a/bin/validate-migrations b/bin/validate-migrations new file mode 100755 index 0000000..6aa1d69 --- /dev/null +++ b/bin/validate-migrations @@ -0,0 +1,86 @@ +#!/usr/bin/env php +getMessage(); + + exit; +} $router = new Router($dependencies); diff --git a/src/Database/DatabaseVersionException.php b/src/Database/DatabaseVersionException.php new file mode 100644 index 0000000..f2c1b08 --- /dev/null +++ b/src/Database/DatabaseVersionException.php @@ -0,0 +1,29 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $instance->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + $instance->exec('PRAGMA journal_mode=WAL'); + $instance->exec('PRAGMA foreign_keys = ON'); + $instance->exec('PRAGMA busy_timeout = 3000'); + + return $instance; + } +} diff --git a/src/Database/SQLiteMigrator.php b/src/Database/SQLiteMigrator.php new file mode 100644 index 0000000..d89f272 --- /dev/null +++ b/src/Database/SQLiteMigrator.php @@ -0,0 +1,96 @@ + The applied migration versions, in order (empty when up to date). + */ + public static function migrate(PDO $pdo): array + { + $migrationsDir = self::migrationsDir(); + $versions = new SQLiteDBVersionRepository($pdo); + $version = $versions->currentVersion(); + $applied = []; + + foreach (self::migrationFiles($migrationsDir) as $file) { + preg_match('/V(\d+)_/', basename($file), $m); + assert(count($m) === 2, 'The matched values should not be empty'); + + $fileVersion = (int) $m[1]; + + if ($fileVersion <= $version) { + continue; + } + + if ($fileVersion > SQLiteDBVersionRepository::EXPECTED_VERSION) { + throw new MigrationMismatchException($fileVersion, SQLiteDBVersionRepository::EXPECTED_VERSION); + } + + require_once $file; + $className = self::MIGRATION_NAMESPACE . "\\MigrationV{$fileVersion}"; + + if (!is_subclass_of($className, Migration::class)) { + continue; + } + + $pdo->exec('BEGIN IMMEDIATE TRANSACTION'); + try { + $className::up($pdo); + $pdo->exec("PRAGMA user_version = {$fileVersion}"); + $pdo->exec('COMMIT'); + } catch (Exception $e) { + $pdo->exec('ROLLBACK'); + throw $e; + } + + $version = $versions->currentVersion(); + $applied[] = $fileVersion; + } + + return $applied; + } + + /** + * @return list Sorted migration file paths. + */ + private static function migrationFiles(string $migrationsDir): array + { + $files = glob("{$migrationsDir}/V*_*.php"); + + if (!$files) { + return []; + } + + // Ensure that the items are sorted by their "natural" sorting order (e.g. 9 before 10). + // ["V10_a.php", "V9_b.php", "V11_c.php"] => ["V9_b.php", "V10_a.php", "V11_c.php"] + sort($files, SORT_NATURAL); + + return $files; + } + + private static function migrationsDir(): string + { + return __DIR__ . '/Migration/sqlite'; + } +} diff --git a/src/Manager/DependencyManager.php b/src/Manager/DependencyManager.php index 7b78e41..c051270 100644 --- a/src/Manager/DependencyManager.php +++ b/src/Manager/DependencyManager.php @@ -3,7 +3,14 @@ namespace KittyShare\Manager; use KittyShare\Model\Dependencies; -use KittyShare\Repository\{SQLiteUserRepository, SQLiteDatabase, SQLiteSessionRepository, SQLiteSetupRepository, SQLiteShareRepository}; +use KittyShare\Database\SQLiteDatabase; +use KittyShare\Repository\{ + SQLiteUserRepository, + SQLiteSessionRepository, + SQLiteSetupRepository, + SQLiteShareRepository, + SQLiteDBVersionRepository +}; class DependencyManager { @@ -22,12 +29,15 @@ private static function constructDependencies(): Dependencies { $config = ConfigManager::get(); - $database = new SQLiteDatabase($config->databasePath); + $pdo = SQLiteDatabase::connect($config->databasePath); - $userRepository = new SQLiteUserRepository($database->getInstance()); - $setupRepository = new SQLiteSetupRepository($database->getInstance()); - $sessionRepository = new SQLiteSessionRepository($database->getInstance(), $config->sessionLifetime); - $shareRepository = new SQLiteShareRepository($database->getInstance()); + // Ensure database has correct version + (new SQLiteDBVersionRepository($pdo))->ensureValid($config->databasePath); + + $userRepository = new SQLiteUserRepository($pdo); + $setupRepository = new SQLiteSetupRepository($pdo); + $sessionRepository = new SQLiteSessionRepository($pdo, $config->sessionLifetime); + $shareRepository = new SQLiteShareRepository($pdo); $authenticationManager = new AuthenticationManager($userRepository, $sessionRepository); return new Dependencies( diff --git a/src/Repository/DBVersionRepository.php b/src/Repository/DBVersionRepository.php new file mode 100644 index 0000000..497de2c --- /dev/null +++ b/src/Repository/DBVersionRepository.php @@ -0,0 +1,33 @@ +pdo->query('PRAGMA user_version'); + + assert($versionQuery !== false, 'Could not execute query to check version.'); + + return (int) $versionQuery->fetchColumn(); + } + + #[Override] + public function isValid(): bool + { + return $this->currentVersion() == self::EXPECTED_VERSION; + } + + #[Override] + public function ensureValid(string $path): void + { + $current = $this->currentVersion(); + + if (!$this->isValid()) { + throw new DatabaseVersionException($current, self::EXPECTED_VERSION, $path); + } + } +} diff --git a/src/Repository/SQLiteDatabase.php b/src/Repository/SQLiteDatabase.php deleted file mode 100644 index 66ba8f1..0000000 --- a/src/Repository/SQLiteDatabase.php +++ /dev/null @@ -1,91 +0,0 @@ -instance = self::loadConnection($path); - } - - public function getInstance(): PDO - { - return $this->instance; - } - - private static function loadConnection(string $path): PDO - { - $instance = new PDO("sqlite:$path"); - $instance->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $instance->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); - $instance->exec('PRAGMA journal_mode=WAL'); - $instance->exec('PRAGMA foreign_keys = ON'); - $instance->exec('PRAGMA busy_timeout = 3000'); - - self::migrateSchema($instance); - - return $instance; - } - - private static function migrateSchema(PDO $pdo): void - { - $migrationsDir = __DIR__ . '/Migration/sqlite'; - - $versionQuery = $pdo->query('PRAGMA user_version'); - assert($versionQuery !== false, "Could not execute query to check version."); - - $version = (int) $versionQuery->fetchColumn(); - - $files = glob("$migrationsDir/V*_*.php"); - - // Currently it should only fail silently. Maybe this should change later on. - if (!$files) { - $files = []; - } - - sort($files); - - foreach ($files as $file) { - preg_match('/V(\d+)_/', basename($file), $m); - assert(count($m) === 2, "The matched values should not be empty"); - - $fileVersion = (int) $m[1]; - - if ($fileVersion <= $version) { - continue; - } - - require_once $file; - $className = self::$migration_namespace . "\\MigrationV{$fileVersion}"; - - if (!is_subclass_of($className, Migration::class)) { - continue; - } - - $pdo->exec('BEGIN IMMEDIATE TRANSACTION'); - try { - $className::up($pdo); - $pdo->exec("PRAGMA user_version = {$fileVersion}"); - $pdo->exec('COMMIT'); - } catch (Exception $e) { - $pdo->exec('ROLLBACK'); - throw $e; - } - - $versionQuery = $pdo->query('PRAGMA user_version'); - assert($versionQuery !== false, "Could not execute query to check version."); - $version = (int) $versionQuery->fetchColumn(); - } - } -}