diff --git a/.gitignore b/.gitignore
index 52fd351..813e987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
.env.local
.local/
uploads/
+var/
diff --git a/app/bootstrap.php b/app/bootstrap.php
new file mode 100644
index 0000000..1de3a6b
--- /dev/null
+++ b/app/bootstrap.php
@@ -0,0 +1,104 @@
+ 0,
+ 'path' => '/',
+ 'domain' => '',
+ 'secure' => app_is_https(),
+ 'httponly' => true,
+ 'samesite' => 'Lax',
+ ]);
+
+ session_name(app_env('APP_SESSION_NAME', 'kaffeeliste_session') ?? 'kaffeeliste_session');
+ session_start();
+}
+
+function app_csrf_token(): string
+{
+ app_start_session();
+
+ if (!isset($_SESSION) || !is_array($_SESSION)) {
+ $_SESSION = [];
+ }
+
+ if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+
+ return $_SESSION['csrf_token'];
+}
+
+function app_csrf_field(): string
+{
+ return '';
+}
+
+function app_verify_csrf(?string $token): bool
+{
+ app_start_session();
+
+ if (!isset($_SESSION) || !is_array($_SESSION)) {
+ $_SESSION = [];
+ }
+
+ return is_string($token)
+ && isset($_SESSION['csrf_token'])
+ && is_string($_SESSION['csrf_token'])
+ && hash_equals($_SESSION['csrf_token'], $token);
+}
+
+function app_require_csrf(): void
+{
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ return;
+ }
+
+ if (!app_verify_csrf($_POST['csrf_token'] ?? null)) {
+ http_response_code(419);
+ die('CSRF validation failed.');
+ }
+}
diff --git a/config.php b/config.php
index 8fa1324..11b0e15 100644
--- a/config.php
+++ b/config.php
@@ -1,5 +1,9 @@
exec(
+ 'CREATE TABLE IF NOT EXISTS schema_migrations (
+ version VARCHAR(255) PRIMARY KEY,
+ applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
+ );
+}
+
+/**
+ * @return array
+ */
+function dev_applied_migrations(PDO $pdo): array
+{
+ dev_ensure_migration_table($pdo);
+
+ $applied = [];
+ $stmt = $pdo->query('SELECT version FROM schema_migrations ORDER BY version');
+ foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $version) {
+ $applied[(string)$version] = true;
+ }
+
+ return $applied;
+}
+
+/**
+ * @return list Applied migration filenames.
+ */
+function dev_apply_migrations(PDO $pdo): array
+{
+ $dir = dev_migrations_dir();
+ $files = glob($dir . '/*.sql');
+ if ($files === false || $files === []) {
+ fwrite(STDERR, "No migration files found in {$dir}.\n");
exit(1);
}
- $pdo->exec($schema);
-}
+ sort($files, SORT_STRING);
+ $applied = dev_applied_migrations($pdo);
+ $newlyApplied = [];
+ $insert = $pdo->prepare('INSERT INTO schema_migrations (version) VALUES (?)');
+ foreach ($files as $file) {
+ $version = basename($file);
+ if (isset($applied[$version])) {
+ continue;
+ }
+
+ $sql = file_get_contents($file);
+ if ($sql === false) {
+ fwrite(STDERR, "Could not read migration file {$version}.\n");
+ exit(1);
+ }
+
+ try {
+ $pdo->exec($sql);
+ $insert->execute([$version]);
+ $newlyApplied[] = $version;
+ } catch (Throwable $e) {
+ fwrite(STDERR, "Migration {$version} failed: {$e->getMessage()}\n");
+ exit(1);
+ }
+ }
+
+ return $newlyApplied;
+}
diff --git a/scripts/init-mysql-dev.php b/scripts/init-mysql-dev.php
index 8aea62a..dacf57f 100644
--- a/scripts/init-mysql-dev.php
+++ b/scripts/init-mysql-dev.php
@@ -2,35 +2,18 @@
declare(strict_types=1);
-$required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS', 'DEV_AUTH_EMAIL'];
-foreach ($required as $name) {
- if ((getenv($name) ?: '') === '') {
- fwrite(STDERR, "Missing environment variable: {$name}\n");
- exit(1);
- }
-}
+require __DIR__ . '/dev-db.php';
-$dsn = sprintf(
- 'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
- getenv('DB_HOST'),
- getenv('DB_PORT') ?: '3306',
- getenv('DB_NAME')
-);
-
-$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
-]);
-
-$schema = file_get_contents(__DIR__ . '/../database/mysql-dev-schema.sql');
-if ($schema === false) {
- fwrite(STDERR, "Could not read schema file.\n");
+$email = getenv('DEV_AUTH_EMAIL');
+if ($email === false || trim($email) === '') {
+ fwrite(STDERR, "Missing environment variable: DEV_AUTH_EMAIL\n");
exit(1);
}
-$pdo->exec($schema);
+$pdo = dev_pdo();
+dev_apply_schema($pdo);
-$email = strtolower(trim((string)getenv('DEV_AUTH_EMAIL')));
+$email = strtolower(trim((string)$email));
$name = trim((string)(getenv('DEV_AUTH_NAME') ?: 'Test Admin'));
$stmt = $pdo->prepare(
@@ -64,4 +47,3 @@ if ($noticeCount === 0) {
}
echo "MySQL dev schema is ready for {$email}.\n";
-
diff --git a/scripts/migrate.php b/scripts/migrate.php
new file mode 100644
index 0000000..da448d4
--- /dev/null
+++ b/scripts/migrate.php
@@ -0,0 +1,20 @@
+