diff --git a/saas-app/database/migrations/generated/all-migrations.sql b/saas-app/database/migrations/generated/all-migrations.sql index 0212ece..d7e6140 100644 --- a/saas-app/database/migrations/generated/all-migrations.sql +++ b/saas-app/database/migrations/generated/all-migrations.sql @@ -1,5 +1,5 @@ -- Generated migration bundle for Kaffeeliste SaaS --- Generated at 2026-03-21T19:14:14+00:00 +-- Generated at 2026-03-21T19:24:13+00:00 SET XACT_ABORT ON; BEGIN TRANSACTION; diff --git a/saas-app/install-support.php b/saas-app/install-support.php new file mode 100644 index 0000000..d3f6e88 --- /dev/null +++ b/saas-app/install-support.php @@ -0,0 +1,317 @@ +NUL' : ' 2>/dev/null'; + @exec($where . ' ' . escapeshellarg($command) . $redirect, $output, $exitCode); + + return $exitCode === 0; +} + +function scripts_parse_options(array $argv): array +{ + $options = []; + + foreach (array_slice($argv, 1) as $arg) { + if (!scripts_string_starts_with($arg, '--')) { + continue; + } + + $arg = substr($arg, 2); + + if (scripts_string_contains($arg, '=')) { + list($key, $value) = explode('=', $arg, 2); + $options[$key] = $value; + continue; + } + + $options[$arg] = true; + } + + return $options; +} + +function scripts_read_env_file(string $path): array +{ + if (!is_file($path)) { + return []; + } + + $values = []; + + foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $trimmed = trim($line); + + if ($trimmed === '' || scripts_string_starts_with($trimmed, '#') || !scripts_string_contains($trimmed, '=')) { + continue; + } + + list($key, $value) = explode('=', $trimmed, 2); + $values[trim($key)] = trim($value, " \t\n\r\0\x0B\""); + } + + return $values; +} + +function scripts_write_env_file(array $values, ?string $targetPath = null): string +{ + if ($targetPath === null) { + $targetPath = scripts_env_path(); + } + + $templateLines = file(scripts_env_example_path(), FILE_IGNORE_NEW_LINES); + + if ($templateLines === false) { + throw new RuntimeException('Die .env.example konnte nicht gelesen werden.'); + } + + $output = []; + + foreach ($templateLines as $line) { + if (!scripts_string_contains($line, '=')) { + $output[] = $line; + continue; + } + + list($key) = explode('=', $line, 2); + $trimmedKey = trim($key); + + if (!array_key_exists($trimmedKey, $values)) { + $output[] = $line; + continue; + } + + $output[] = $trimmedKey . '=' . scripts_format_env_value((string) $values[$trimmedKey]); + unset($values[$trimmedKey]); + } + + foreach ($values as $key => $value) { + $output[] = $key . '=' . scripts_format_env_value((string) $value); + } + + if (file_put_contents($targetPath, implode(PHP_EOL, $output) . PHP_EOL) === false) { + throw new RuntimeException('Die .env konnte nicht geschrieben werden: ' . $targetPath); + } + + return $targetPath; +} + +function scripts_format_env_value(string $value): string +{ + if ($value === '') { + return ''; + } + + if (preg_match('/\s|#|"|=/', $value) === 1) { + return '"' . addcslashes($value, "\\\"") . '"'; + } + + return $value; +} + +function scripts_build_migration_bundle(?string $outputPath = null): string +{ + $migrationDir = scripts_saas_app_path() + . DIRECTORY_SEPARATOR . 'database' + . DIRECTORY_SEPARATOR . 'migrations'; + + if ($outputPath === null) { + $outputPath = scripts_bundle_output_path(); + } + + if (!is_dir($migrationDir)) { + throw new RuntimeException('Migrationsverzeichnis nicht gefunden: ' . $migrationDir); + } + + $files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); + sort($files, SORT_STRING); + + if ($files === []) { + throw new RuntimeException('Keine Migrationsdateien gefunden.'); + } + + $outputDir = dirname($outputPath); + if (!is_dir($outputDir) && !mkdir($outputDir, 0777, true) && !is_dir($outputDir)) { + throw new RuntimeException('Ausgabeverzeichnis konnte nicht erstellt werden: ' . $outputDir); + } + + $bundle = []; + $bundle[] = '-- Generated migration bundle for Kaffeeliste SaaS'; + $bundle[] = '-- Generated at ' . date('c'); + $bundle[] = 'SET XACT_ABORT ON;'; + $bundle[] = 'BEGIN TRANSACTION;'; + + foreach ($files as $file) { + $sql = require $file; + + if (!is_string($sql) || trim($sql) === '') { + throw new RuntimeException('Ungueltige Migration: ' . $file); + } + + $bundle[] = ''; + $bundle[] = '-- Migration: ' . basename($file); + $bundle[] = trim($sql); + } + + $bundle[] = ''; + $bundle[] = 'COMMIT TRANSACTION;'; + $bundle[] = ''; + + if (file_put_contents($outputPath, implode(PHP_EOL, $bundle)) === false) { + throw new RuntimeException('Das SQL-Bundle konnte nicht geschrieben werden: ' . $outputPath); + } + + return $outputPath; +} + +function scripts_run_sql_migrations(array $config): array +{ + $server = isset($config['server']) ? $config['server'] : null; + $database = isset($config['database']) ? $config['database'] : null; + $port = isset($config['port']) ? (string) $config['port'] : '1433'; + $username = isset($config['username']) ? $config['username'] : null; + $password = isset($config['password']) ? $config['password'] : null; + + if ($server === null || $database === null) { + throw new RuntimeException('Bitte Server und Datenbank angeben.'); + } + + if (!extension_loaded('pdo_sqlsrv')) { + throw new RuntimeException('Die PHP-Erweiterung pdo_sqlsrv ist nicht geladen.'); + } + + $migrationDir = scripts_saas_app_path() + . DIRECTORY_SEPARATOR . 'database' + . DIRECTORY_SEPARATOR . 'migrations'; + + $files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); + sort($files, SORT_STRING); + + if ($files === []) { + throw new RuntimeException('Keine Migrationsdateien gefunden.'); + } + + $dsn = sprintf('sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1', $server, $port, $database); + $pdo = new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + + $pdo->beginTransaction(); + + $executed = []; + + try { + foreach ($files as $file) { + $sql = require $file; + + if (!is_string($sql) || trim($sql) === '') { + throw new RuntimeException('Ungueltige Migration: ' . basename($file)); + } + + $pdo->exec($sql); + $executed[] = basename($file); + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } + + return $executed; +} + +function scripts_installer_is_locked(): bool +{ + return is_file(scripts_installer_lock_path()); +} + +function scripts_installer_lock(array $meta = []): string +{ + $payload = json_encode([ + 'locked_at' => date('c'), + 'meta' => $meta, + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $path = scripts_installer_lock_path(); + if (file_put_contents($path, $payload . PHP_EOL) === false) { + throw new RuntimeException('Die Installer-Sperrdatei konnte nicht geschrieben werden.'); + } + + return $path; +} + +function scripts_stdout(string $message): void +{ + fwrite(STDOUT, $message . PHP_EOL); +} + +function scripts_stderr(string $message, int $exitCode = 1): void +{ + fwrite(STDERR, $message . PHP_EOL); + exit($exitCode); +} diff --git a/saas-app/public/install.php b/saas-app/public/install.php index 341e55e..852ac91 100644 --- a/saas-app/public/install.php +++ b/saas-app/public/install.php @@ -4,7 +4,16 @@ declare(strict_types=1); session_start(); -require_once dirname(__DIR__, 2) . '/scripts/support.php'; +$bootstrapPath = dirname(__DIR__) . '/install-support.php'; + +if (!is_file($bootstrapPath)) { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + echo "Installer-Basis nicht gefunden: " . $bootstrapPath; + exit; +} + +require_once $bootstrapPath; $requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET'; @@ -105,9 +114,9 @@ if ($requestMethod === 'POST' && !$locked) { } catch (Throwable $exception) { $message = $exception->getMessage(); - if (str_contains($message, 'pdo_sqlsrv')) { + if (scripts_string_contains($message, 'pdo_sqlsrv')) { $errors[] = 'Migrationen konnten nicht direkt ueber PHP ausgefuehrt werden. Bitte `pdo_sqlsrv` pruefen oder das SQL-Bundle manuell importieren.'; - } elseif (str_contains($message, '.env')) { + } elseif (scripts_string_contains($message, '.env')) { $errors[] = 'Die Konfiguration konnte nicht gespeichert werden. Bitte Schreibrechte pruefen.'; } else { $errors[] = 'Die Installation konnte nicht abgeschlossen werden. Bitte Eingaben, DB-Zugang und Dateirechte pruefen.'; @@ -292,7 +301,7 @@ function h(string $value): string Nach erfolgreicher Einrichtung sollte der Installer gesperrt bleiben.

- Zur Preview + Zur Preview
diff --git a/scripts/support.php b/scripts/support.php index ffecd24..c254d22 100644 --- a/scripts/support.php +++ b/scripts/support.php @@ -2,284 +2,4 @@ declare(strict_types=1); -function scripts_project_root(): string -{ - return dirname(__DIR__); -} - -function scripts_saas_app_path(): string -{ - return scripts_project_root() . DIRECTORY_SEPARATOR . 'saas-app'; -} - -function scripts_env_example_path(): string -{ - return scripts_saas_app_path() . DIRECTORY_SEPARATOR . '.env.example'; -} - -function scripts_env_path(): string -{ - return scripts_saas_app_path() . DIRECTORY_SEPARATOR . '.env'; -} - -function scripts_bundle_output_path(): string -{ - return scripts_saas_app_path() - . DIRECTORY_SEPARATOR . 'database' - . DIRECTORY_SEPARATOR . 'migrations' - . DIRECTORY_SEPARATOR . 'generated' - . DIRECTORY_SEPARATOR . 'all-migrations.sql'; -} - -function scripts_installer_lock_path(): string -{ - return scripts_saas_app_path() . DIRECTORY_SEPARATOR . '.installer.lock'; -} - -function scripts_check_command(string $command): bool -{ - $where = stripos(PHP_OS_FAMILY, 'Windows') === 0 ? 'where' : 'command -v'; - $output = []; - $exitCode = 0; - $redirect = stripos(PHP_OS_FAMILY, 'Windows') === 0 ? ' 2>NUL' : ' 2>/dev/null'; - @exec($where . ' ' . escapeshellarg($command) . $redirect, $output, $exitCode); - - return $exitCode === 0; -} - -function scripts_parse_options(array $argv): array -{ - $options = []; - - foreach (array_slice($argv, 1) as $arg) { - if (!str_starts_with($arg, '--')) { - continue; - } - - $arg = substr($arg, 2); - - if (str_contains($arg, '=')) { - [$key, $value] = explode('=', $arg, 2); - $options[$key] = $value; - continue; - } - - $options[$arg] = true; - } - - return $options; -} - -function scripts_read_env_file(string $path): array -{ - if (!is_file($path)) { - return []; - } - - $values = []; - - foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { - $trimmed = trim($line); - - if ($trimmed === '' || str_starts_with($trimmed, '#') || !str_contains($trimmed, '=')) { - continue; - } - - [$key, $value] = explode('=', $trimmed, 2); - $values[trim($key)] = trim($value, " \t\n\r\0\x0B\""); - } - - return $values; -} - -function scripts_write_env_file(array $values, ?string $targetPath = null): string -{ - $targetPath ??= scripts_env_path(); - $templateLines = file(scripts_env_example_path(), FILE_IGNORE_NEW_LINES); - - if ($templateLines === false) { - throw new RuntimeException('Die .env.example konnte nicht gelesen werden.'); - } - - $output = []; - - foreach ($templateLines as $line) { - if (!str_contains($line, '=')) { - $output[] = $line; - continue; - } - - [$key] = explode('=', $line, 2); - $trimmedKey = trim($key); - - if (!array_key_exists($trimmedKey, $values)) { - $output[] = $line; - continue; - } - - $output[] = $trimmedKey . '=' . scripts_format_env_value((string) $values[$trimmedKey]); - unset($values[$trimmedKey]); - } - - foreach ($values as $key => $value) { - $output[] = $key . '=' . scripts_format_env_value((string) $value); - } - - if (file_put_contents($targetPath, implode(PHP_EOL, $output) . PHP_EOL) === false) { - throw new RuntimeException('Die .env konnte nicht geschrieben werden: ' . $targetPath); - } - - return $targetPath; -} - -function scripts_format_env_value(string $value): string -{ - if ($value === '') { - return ''; - } - - if (preg_match('/\s|#|"|=/', $value) === 1) { - return '"' . addcslashes($value, "\\\"") . '"'; - } - - return $value; -} - -function scripts_build_migration_bundle(?string $outputPath = null): string -{ - $projectRoot = scripts_project_root(); - $migrationDir = $projectRoot . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'; - $outputPath ??= scripts_bundle_output_path(); - - if (!is_dir($migrationDir)) { - throw new RuntimeException("Migrationsverzeichnis nicht gefunden: {$migrationDir}"); - } - - $files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); - sort($files, SORT_STRING); - - if ($files === []) { - throw new RuntimeException('Keine Migrationsdateien gefunden.'); - } - - $outputDir = dirname($outputPath); - if (!is_dir($outputDir) && !mkdir($outputDir, 0777, true) && !is_dir($outputDir)) { - throw new RuntimeException("Ausgabeverzeichnis konnte nicht erstellt werden: {$outputDir}"); - } - - $bundle = []; - $bundle[] = '-- Generated migration bundle for Kaffeeliste SaaS'; - $bundle[] = '-- Generated at ' . date('c'); - $bundle[] = 'SET XACT_ABORT ON;'; - $bundle[] = 'BEGIN TRANSACTION;'; - - foreach ($files as $file) { - $sql = require $file; - - if (!is_string($sql) || trim($sql) === '') { - throw new RuntimeException("Ungueltige Migration: {$file}"); - } - - $bundle[] = ''; - $bundle[] = '-- Migration: ' . basename($file); - $bundle[] = trim($sql); - } - - $bundle[] = ''; - $bundle[] = 'COMMIT TRANSACTION;'; - $bundle[] = ''; - - if (file_put_contents($outputPath, implode(PHP_EOL, $bundle)) === false) { - throw new RuntimeException("Das SQL-Bundle konnte nicht geschrieben werden: {$outputPath}"); - } - - return $outputPath; -} - -function scripts_run_sql_migrations(array $config): array -{ - $server = $config['server'] ?? null; - $database = $config['database'] ?? null; - $port = (string) ($config['port'] ?? '1433'); - $username = $config['username'] ?? null; - $password = $config['password'] ?? null; - - if ($server === null || $database === null) { - throw new RuntimeException('Bitte Server und Datenbank angeben.'); - } - - if (!extension_loaded('pdo_sqlsrv')) { - throw new RuntimeException('Die PHP-Erweiterung pdo_sqlsrv ist nicht geladen.'); - } - - $migrationDir = scripts_project_root() . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations'; - $files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php'); - sort($files, SORT_STRING); - - if ($files === []) { - throw new RuntimeException('Keine Migrationsdateien gefunden.'); - } - - $dsn = sprintf('sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1', $server, $port, $database); - $pdo = new PDO($dsn, $username, $password, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - ]); - - $pdo->beginTransaction(); - - $executed = []; - - try { - foreach ($files as $file) { - $sql = require $file; - - if (!is_string($sql) || trim($sql) === '') { - throw new RuntimeException('Ungueltige Migration: ' . basename($file)); - } - - $pdo->exec($sql); - $executed[] = basename($file); - } - - $pdo->commit(); - } catch (Throwable $exception) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - - throw $exception; - } - - return $executed; -} - -function scripts_installer_is_locked(): bool -{ - return is_file(scripts_installer_lock_path()); -} - -function scripts_installer_lock(array $meta = []): string -{ - $payload = json_encode([ - 'locked_at' => date('c'), - 'meta' => $meta, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - - $path = scripts_installer_lock_path(); - if (file_put_contents($path, $payload . PHP_EOL) === false) { - throw new RuntimeException('Die Installer-Sperrdatei konnte nicht geschrieben werden.'); - } - - return $path; -} - -function scripts_stdout(string $message): void -{ - fwrite(STDOUT, $message . PHP_EOL); -} - -function scripts_stderr(string $message, int $exitCode = 1): never -{ - fwrite(STDERR, $message . PHP_EOL); - exit($exitCode); -} +require_once dirname(__DIR__) . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'install-support.php';