Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions bin/migrate
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env php
<?php

/**
* Migrates the SQLite database schema to the latest version.
*
* Usage:
* php bin/migrate (uses KITTYSHARE_DATABASE_PATH or the default)
* composer migrate
*
* Exit codes: 0 when the database is up to date (or was migrated
* successfully), 1 on failure.
*/

require_once __DIR__ . '/../vendor/autoload.php';

use KittyShare\Manager\ConfigManager;
use KittyShare\Database\SQLiteDatabase;
use KittyShare\Database\SQLiteMigrator;
use KittyShare\Repository\SQLiteDBVersionRepository;

if (php_sapi_name() !== 'cli') {
fwrite(STDERR, "The script can only be executed from the command line.");

exit(1);
}

$databasePath = ConfigManager::get()->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);
86 changes: 86 additions & 0 deletions bin/validate-migrations
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env php
<?php

/**
* Validates that the SQLite migration files are in sync with the expected schema version.
*
* Checks:
* - every file in src/Database/Migration/sqlite matches V{version}_{name}.php
* - no duplicate versions, no gaps (versions must be 1..max contiguous)
* - highest migration version equals SQLiteDBVersionRepository::EXPECTED_VERSION
*
* Exit codes: 0 when in sync, 1 on mismatch.
*/

require_once __DIR__ . '/../vendor/autoload.php';

use KittyShare\Repository\SQLiteDBVersionRepository;

if (php_sapi_name() !== 'cli') {
fwrite(STDERR, "The script can only be executed from the command line.");

exit(1);
}

$migrationsDir = __DIR__ . '/../src/Database/Migration/sqlite';

$files = glob("{$migrationsDir}/V*_*.php");

if ($files === false) {
fwrite(STDERR, "Could not read migration directory: {$migrationsDir}\n");

exit(1);
}

$versions = [];

$versionError = false;

foreach ($files as $file) {
if (preg_match('/\/V(\d+)_.*\.php$/', $file, $m) !== 1) {
fwrite(STDERR, "Invalid migration filename (expected V{version}_{name}.php): {$file}\n");

$versionError = true;
}

$versions[] = (int) $m[1];
}

if ($versionError) {
exit(1);
}

sort($versions);

$unique = array_values(array_unique($versions));

if ($unique !== $versions) {
fwrite(STDERR, "Duplicate migration versions detected.\n");

exit(1);
}

$max = $versions === [] ? 0 : $versions[count($versions) - 1];
$expectedRange = $max === 0 ? [] : range(1, $max);

if ($versions !== $expectedRange) {
fwrite(STDERR, 'Migration versions must be contiguous starting at V1 without gaps. Found: V' . implode(', V', $versions) . "\n");

exit(1);
}

$expected = SQLiteDBVersionRepository::EXPECTED_VERSION;

if ($max !== $expected) {
fwrite(
STDERR,
"Migration version mismatch: highest migration file is V{$max} but SQLiteDBVersionRepository::EXPECTED_VERSION is {$expected}. " .
"Bump EXPECTED_VERSION in the same commit as the new migration file.\n"
);

exit(1);
}

echo "Migrations in sync (highest version V{$max} matches EXPECTED_VERSION {$expected}).\n";

exit(0);
7 changes: 5 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@
"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",
"check:migrations": "php bin/validate-migrations",
"ci": [
"@lint",
"@analyse"
"@analyse",
"@check:migrations"
],
"test": "echo 'No test suite yet.' && exit 1",
"start": "php -S localhost:8000 -t public"
Expand Down
9 changes: 9 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
13 changes: 12 additions & 1 deletion public/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
29 changes: 29 additions & 0 deletions src/Database/DatabaseVersionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace KittyShare\Database;

use RuntimeException;

/**
* Thrown when the version of the database is different than what the application expected.
*/
final class DatabaseVersionException extends RuntimeException
{
public function __construct(
public readonly int $currentVersion,
public readonly int $expectedVersion,
public readonly string $databasePath,
) {
if ($currentVersion < $expectedVersion) {
parent::__construct(
"Database schema version {$currentVersion} is older than expected version {$expectedVersion}. " .
'Run the migration first: php bin/migrate (or composer migrate).',
);
} else {
parent::__construct(
"Database schema version {$currentVersion} is somehow younger than the expected version {$expectedVersion}.\n" .
'Please open an issue at https://github.com/QuickWrite/KittyShare/issues with this exception and your settings.'
);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php

namespace KittyShare\Repository\Migration;
namespace KittyShare\Database\Migration;

use PDO;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php

namespace KittyShare\Repository\Migration\sqlite;
namespace KittyShare\Database\Migration\sqlite;

use KittyShare\Repository\Migration\Migration;
use KittyShare\Database\Migration\Migration;
use PDO;
use Override;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php

namespace KittyShare\Repository\Migration\sqlite;
namespace KittyShare\Database\Migration\sqlite;

use KittyShare\Repository\Migration\Migration;
use KittyShare\Database\Migration\Migration;
use PDO;
use Override;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php

namespace KittyShare\Repository\Migration\sqlite;
namespace KittyShare\Database\Migration\sqlite;

use KittyShare\Repository\Migration\Migration;
use KittyShare\Database\Migration\Migration;
use PDO;
use Override;

Expand Down
21 changes: 21 additions & 0 deletions src/Database/MigrationMismatchException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace KittyShare\Database;

use RuntimeException;

/**
* Thrown when a migration file is newer than the expected schema version.
*/
final class MigrationMismatchException extends RuntimeException
{
public function __construct(
public readonly int $fileVersion,
public readonly int $expectedVersion,
) {
parent::__construct(
"Migration file version V{$fileVersion} exceeds expected schema version {$expectedVersion}. " .
'Bump the EXPECTED_VERSION in the same commit as the new migration file.',
);
}
}
32 changes: 32 additions & 0 deletions src/Database/SQLiteDatabase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace KittyShare\Database;

use PDO;

/**
* Opens the connection to the given SQLite database
*/
final class SQLiteDatabase
{
private function __construct()
{
}

/**
* Opens a connection with general settings
*
* @param string $path Path to the SQLite database file.
*/
public static function connect(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');

return $instance;
}
}
Loading
Loading