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 @@ +