From 22010080fa2bf1a986cae606d702ce78e1cda38c Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 23 Sep 2026 22:28:18 +0200 Subject: [PATCH 1/7] Refactor: Move the migration script out of the request path On every request the migration system tried a migration to ensure that the database is correct. However this problematic in terms of perfomance and control. As such the migration logic is now inside of an external script that has to be called outside of the main request path to migrate the SQLite database. This script can be found as bin/migrate. To ensure that the website does not work with an invalid schema the version is still checked in the request path and returns a 500 internal reponse when invalid to ensure no invalid queries. --- Dockerfile | 5 + README.md | 13 +++ bin/migrate | 51 ++++++++++ composer.json | 3 +- docker-entrypoint.sh | 9 ++ public/index.php | 13 ++- src/Database/DatabaseVersionException.php | 29 ++++++ .../Migration/Migration.php | 2 +- .../Migration/sqlite/V1_user.php | 4 +- .../Migration/sqlite/V2_session.php | 4 +- .../Migration/sqlite/V3_shares.php | 4 +- src/Database/MigrationMismatchException.php | 21 +++++ src/Database/SQLiteDatabase.php | 32 +++++++ src/Database/SQLiteMigrator.php | 93 +++++++++++++++++++ src/Manager/DependencyManager.php | 22 +++-- src/Repository/DBVersionRepository.php | 33 +++++++ src/Repository/SQLiteDBVersionRepository.php | 39 ++++++++ src/Repository/SQLiteDatabase.php | 91 ------------------ 18 files changed, 362 insertions(+), 106 deletions(-) create mode 100755 bin/migrate create mode 100755 docker-entrypoint.sh create mode 100644 src/Database/DatabaseVersionException.php rename src/{Repository => Database}/Migration/Migration.php (94%) rename src/{Repository => Database}/Migration/sqlite/V1_user.php (80%) rename src/{Repository => Database}/Migration/sqlite/V2_session.php (88%) rename src/{Repository => Database}/Migration/sqlite/V3_shares.php (88%) create mode 100644 src/Database/MigrationMismatchException.php create mode 100644 src/Database/SQLiteDatabase.php create mode 100644 src/Database/SQLiteMigrator.php create mode 100644 src/Repository/DBVersionRepository.php create mode 100644 src/Repository/SQLiteDBVersionRepository.php delete mode 100644 src/Repository/SQLiteDatabase.php 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/composer.json b/composer.json index d415605..d052d32 100644 --- a/composer.json +++ b/composer.json @@ -15,8 +15,9 @@ "phpstan/phpstan": "^2.2" }, "scripts": { - "lint": "find src public -name '*.php' -exec php -l {} \\;", + "lint": "find src public bin -type f \\( -name '*.php' -o -path '*/bin/*' \\) -exec php -l {} \\;", "analyse": "vendor/bin/phpstan analyse --no-progress", + "migrate": "php bin/migrate", "ci": [ "@lint", "@analyse" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..153ba25 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -eu + +# Migrates the SQLite schema exactly once at container startup +php /var/www/html/bin/migrate + +# Run Apache Webserver +exec apache2-foreground "$@" diff --git a/public/index.php b/public/index.php index 80ab23c..379da39 100644 --- a/public/index.php +++ b/public/index.php @@ -4,9 +4,20 @@ use KittyShare\Controller\{AdminController, LoginController, LogoutController, SetupController, ShareController}; use KittyShare\Manager\{DependencyManager, ConfigManager}; +use KittyShare\Database\DatabaseVersionException; use KittyShare\Http\{Router, Method}; -$dependencies = DependencyManager::get(); +try { + $dependencies = DependencyManager::get(); +} catch (DatabaseVersionException $e) { + http_response_code(500); + + header('Content-Type: text/plain; charset=utf-8'); + + echo $e->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..e403890 --- /dev/null +++ b/src/Database/SQLiteMigrator.php @@ -0,0 +1,93 @@ + 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 []; + } + + sort($files); + + 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(); - } - } -} From 550ff28cd9f5bb1e3440c4be18cc6bf88f0d3fc7 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Wed, 23 Sep 2026 23:03:20 +0200 Subject: [PATCH 2/7] Add CI check for migrations As migrations can theoretically be incorrect, the CI should be able to see that they are not valid. As such a new script is added that checks the correctness of these migrations. --- bin/validate-migrations | 86 +++++++++++++++++++++++++++++++++++++++++ composer.json | 4 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100755 bin/validate-migrations 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 + Date: Thu, 24 Sep 2026 21:48:34 +0200 Subject: [PATCH 3/7] Fix: Inverted validity check It seems like there was a small oversight and the validity check was inverted. This meant that every database version that is NOT the current, was accepted - which is not desirable. --- src/Repository/SQLiteDBVersionRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Repository/SQLiteDBVersionRepository.php b/src/Repository/SQLiteDBVersionRepository.php index 0bf872a..94cba34 100644 --- a/src/Repository/SQLiteDBVersionRepository.php +++ b/src/Repository/SQLiteDBVersionRepository.php @@ -32,7 +32,7 @@ public function ensureValid(string $path): void { $current = $this->currentVersion(); - if ($this->isValid()) { + if (!$this->isValid()) { throw new DatabaseVersionException($current, self::EXPECTED_VERSION, $path); } } From c5b48d1ccbc6cec0dbab05fe4e99758aac3e4045 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Thu, 24 Sep 2026 21:56:42 +0200 Subject: [PATCH 4/7] Fix: Incorrect sorting of migration files Previously the sorting was based on lexicographic sort. However this led to the problem that when there are migration files like: V9.php and V_10.php it would sort them in an incorrect order: V10.php and V9.php This would lead to an incorrect order of applications of the migrations and an invalid database. As such the `natsort` function is now used. --- src/Database/SQLiteMigrator.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Database/SQLiteMigrator.php b/src/Database/SQLiteMigrator.php index e403890..c1d8abc 100644 --- a/src/Database/SQLiteMigrator.php +++ b/src/Database/SQLiteMigrator.php @@ -9,6 +9,7 @@ use function count; use function assert; +use function natsort; /** * Applies pending SQLite schema migrations. @@ -81,7 +82,9 @@ private static function migrationFiles(string $migrationsDir): array return []; } - sort($files); + // 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"] + natsort($files); return $files; } From 5205e9551de0aa80ac685905ff0e63f05aa1b37e Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Thu, 24 Sep 2026 22:04:55 +0200 Subject: [PATCH 5/7] Fix: Incorrect error message There seems to be an incorrect error message when migrating that references an outdated class. --- src/Database/MigrationMismatchException.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/MigrationMismatchException.php b/src/Database/MigrationMismatchException.php index 1c2db7f..dbce4fb 100644 --- a/src/Database/MigrationMismatchException.php +++ b/src/Database/MigrationMismatchException.php @@ -15,7 +15,7 @@ public function __construct( ) { parent::__construct( "Migration file version V{$fileVersion} exceeds expected schema version {$expectedVersion}. " . - 'Bump DBVersionRepository::EXPECTED_VERSION in the same commit as the new migration file.', + 'Bump the EXPECTED_VERSION in the same commit as the new migration file.', ); } } From a3bb40b90f79228bb447f21d26232c67f8e1d7b5 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Thu, 24 Sep 2026 22:24:23 +0200 Subject: [PATCH 6/7] fix: Sort with keys that are in sequential order It seems like (It didn't occur to me from the documentation) that natsort does not change the actual order of the keys and as such does not create a list. As such PHPStan (correctly) didn't like the natsort. --- src/Database/SQLiteMigrator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/SQLiteMigrator.php b/src/Database/SQLiteMigrator.php index c1d8abc..74e69eb 100644 --- a/src/Database/SQLiteMigrator.php +++ b/src/Database/SQLiteMigrator.php @@ -9,7 +9,7 @@ use function count; use function assert; -use function natsort; +use function sort; /** * Applies pending SQLite schema migrations. @@ -84,7 +84,7 @@ private static function migrationFiles(string $migrationsDir): array // 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"] - natsort($files); + sort($files, SORT_NATURAL); return $files; } From eae3e6003b9a0b9d71a2217a10531394a1e57481 Mon Sep 17 00:00:00 2001 From: QuickWrite Date: Thu, 24 Sep 2026 22:26:28 +0200 Subject: [PATCH 7/7] Refactor: Make constant actually a const It seems like the MIGRATION_NAMESPACE was only static and not a constant. This is completely valid; However this does not mean that it is semantically correct. --- src/Database/SQLiteMigrator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/SQLiteMigrator.php b/src/Database/SQLiteMigrator.php index 74e69eb..d89f272 100644 --- a/src/Database/SQLiteMigrator.php +++ b/src/Database/SQLiteMigrator.php @@ -16,7 +16,7 @@ */ final class SQLiteMigrator { - private static string $migration_namespace = 'KittyShare\\Database\\Migration\\sqlite'; + private const MIGRATION_NAMESPACE = 'KittyShare\\Database\\Migration\\sqlite'; /** * Applies all pending migrations and returns the versions that were applied. @@ -48,7 +48,7 @@ public static function migrate(PDO $pdo): array } require_once $file; - $className = self::$migration_namespace . "\\MigrationV{$fileVersion}"; + $className = self::MIGRATION_NAMESPACE . "\\MigrationV{$fileVersion}"; if (!is_subclass_of($className, Migration::class)) { continue;