Add guided installer for SaaS application setup

- Updated README.md to include instructions for guided installation on webspace without shell access.
- Enhanced installationshandbuch.md with detailed steps for using the guided installer.
- Modified saas-app/README.md to summarize the installation process using the new installer.
- Generated SQL migration bundle now includes timestamp for better tracking.
- Introduced public/install/index.php as the entry point for the guided installer.
- Updated public/index.php to reflect changes in installation steps and added links to the installer.
- Refactored migration bundle generation and SQL execution scripts for improved error handling and modularity.
- Implemented session-based CSRF protection in the installer.
- Added form handling for environment variable configuration and migration execution in the installer.
- Created a user-friendly HTML interface for the installer with status messages and error handling.
This commit is contained in:
2026-03-21 20:18:41 +01:00
parent 70e6d59c63
commit cb6e4e7dcb
13 changed files with 741 additions and 136 deletions
+7 -49
View File
@@ -2,54 +2,12 @@
declare(strict_types=1);
$projectRoot = dirname(__DIR__);
$migrationDir = $projectRoot . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations';
$outputPath = $argv[1] ?? (
$projectRoot . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR . 'generated' . DIRECTORY_SEPARATOR . 'all-migrations.sql'
);
require_once __DIR__ . '/support.php';
if (!is_dir($migrationDir)) {
fwrite(STDERR, "Migrationsverzeichnis nicht gefunden: {$migrationDir}" . PHP_EOL);
exit(1);
$outputPath = $argv[1] ?? scripts_bundle_output_path();
try {
scripts_stdout(scripts_build_migration_bundle($outputPath));
} catch (Throwable $exception) {
scripts_stderr($exception->getMessage());
}
$files = glob($migrationDir . DIRECTORY_SEPARATOR . '*.php');
sort($files, SORT_STRING);
if ($files === []) {
fwrite(STDERR, "Keine Migrationsdateien gefunden." . PHP_EOL);
exit(1);
}
$outputDir = dirname($outputPath);
if (!is_dir($outputDir) && !mkdir($outputDir, 0777, true) && !is_dir($outputDir)) {
fwrite(STDERR, "Ausgabeverzeichnis konnte nicht erstellt werden: {$outputDir}" . PHP_EOL);
exit(1);
}
$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) === '') {
fwrite(STDERR, "Ungueltige Migration: {$file}" . PHP_EOL);
exit(1);
}
$bundle[] = '';
$bundle[] = '-- Migration: ' . basename($file);
$bundle[] = trim($sql);
}
$bundle[] = '';
$bundle[] = 'COMMIT TRANSACTION;';
$bundle[] = '';
file_put_contents($outputPath, implode(PHP_EOL, $bundle));
fwrite(STDOUT, $outputPath . PHP_EOL);
+2
View File
@@ -7,6 +7,7 @@ require_once __DIR__ . '/support.php';
$phpVersion = PHP_VERSION;
$composerExists = scripts_check_command('composer');
$gitExists = scripts_check_command('git');
$pdoSqlsrvLoaded = extension_loaded('pdo_sqlsrv');
scripts_stdout('Pruefe lokale Voraussetzungen fuer Kaffeeliste SaaS...');
scripts_stdout('Projektwurzel: ' . scripts_project_root());
@@ -16,6 +17,7 @@ $checks = [
['PHP', true, $phpVersion],
['Composer', $composerExists, $composerExists ? 'verfuegbar' : 'nicht gefunden'],
['Git', $gitExists, $gitExists ? 'verfuegbar' : 'nicht gefunden'],
['pdo_sqlsrv', $pdoSqlsrvLoaded, $pdoSqlsrvLoaded ? 'geladen' : 'nicht geladen'],
['SaaS-App', is_dir(scripts_saas_app_path()), scripts_saas_app_path()],
['.env.example', is_file(scripts_env_example_path()), scripts_env_example_path()],
['.env', is_file(scripts_env_path()), is_file(scripts_env_path()) ? 'vorhanden' : 'noch nicht angelegt'],
+14 -15
View File
@@ -6,7 +6,7 @@ require_once __DIR__ . '/support.php';
$options = scripts_parse_options($argv);
$forceEnv = isset($options['force-env']);
$prepareEnv = isset($options['prepare-env']) || !is_file(scripts_env_path());
$prepareEnv = $forceEnv || isset($options['prepare-env']) || !is_file(scripts_env_path());
scripts_stdout('Starte SaaS-Installationsvorbereitung...');
scripts_stdout('');
@@ -14,28 +14,27 @@ scripts_stdout('');
require __DIR__ . '/check-prerequisites.php';
if ($prepareEnv) {
$command = 'php ' . escapeshellarg(__DIR__ . '/prepare-saas-env.php');
if ($forceEnv) {
$command .= ' --force';
$envValues = scripts_read_env_file(scripts_env_example_path());
try {
scripts_write_env_file($envValues, scripts_env_path());
scripts_stdout('Lokale .env wurde angelegt: ' . scripts_env_path());
} catch (Throwable $exception) {
scripts_stderr($exception->getMessage());
}
passthru($command, $exitCode);
if ($exitCode !== 0) {
exit($exitCode);
if ($forceEnv) {
scripts_stdout('Hinweis: vorhandene .env wurde bewusst ueberschrieben.');
}
}
$bundlePath = scripts_project_root() . DIRECTORY_SEPARATOR . 'saas-app' . DIRECTORY_SEPARATOR . 'database' . DIRECTORY_SEPARATOR . 'migrations' . DIRECTORY_SEPARATOR . 'generated' . DIRECTORY_SEPARATOR . 'all-migrations.sql';
$command = 'php ' . escapeshellarg(__DIR__ . '/build-migration-bundle.php') . ' ' . escapeshellarg($bundlePath);
passthru($command, $exitCode);
if ($exitCode !== 0) {
exit($exitCode);
try {
$bundlePath = scripts_build_migration_bundle();
} catch (Throwable $exception) {
scripts_stderr($exception->getMessage());
}
scripts_stdout('');
scripts_stdout('SQL-Bundle erzeugt: ' . $bundlePath);
scripts_stdout('Naechste Schritte:');
scripts_stdout('1. saas-app/.env mit echten DB-, Mail- und Tenancy-Werten fuellen.');
scripts_stdout('2. Das SQL-Bundle manuell importieren oder `php scripts/run-sql-migrations.php --server=... --database=...` verwenden.');
+8 -45
View File
@@ -7,54 +7,17 @@ require_once __DIR__ . '/support.php';
$options = scripts_parse_options($argv);
$env = scripts_read_env_file(scripts_env_path());
$server = $options['server'] ?? $env['DB_HOST'] ?? null;
$database = $options['database'] ?? $env['DB_DATABASE'] ?? null;
$port = $options['port'] ?? $env['DB_PORT'] ?? '1433';
$username = $options['username'] ?? $env['DB_USERNAME'] ?? null;
$password = $options['password'] ?? $env['DB_PASSWORD'] ?? null;
if ($server === null || $database === null) {
scripts_stderr('Bitte --server und --database angeben oder eine passende saas-app/.env pflegen.');
}
if (!extension_loaded('pdo_sqlsrv')) {
scripts_stderr('Die PHP-Erweiterung pdo_sqlsrv ist nicht geladen. Fuehre das Bundle manuell aus oder aktiviere den Treiber.');
}
$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 === []) {
scripts_stderr('Keine Migrationsdateien gefunden.');
}
$dsn = sprintf('sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1', $server, $port, $database);
$config = [
'server' => $options['server'] ?? $env['DB_HOST'] ?? null,
'database' => $options['database'] ?? $env['DB_DATABASE'] ?? null,
'port' => $options['port'] ?? $env['DB_PORT'] ?? '1433',
'username' => $options['username'] ?? $env['DB_USERNAME'] ?? null,
'password' => $options['password'] ?? $env['DB_PASSWORD'] ?? null,
];
try {
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->beginTransaction();
foreach ($files as $file) {
$sql = require $file;
if (!is_string($sql) || trim($sql) === '') {
throw new RuntimeException('Ungueltige Migration: ' . basename($file));
}
scripts_stdout('Fuehre aus: ' . basename($file));
$pdo->exec($sql);
}
$pdo->commit();
scripts_run_sql_migrations($config);
scripts_stdout('Migrationen wurden erfolgreich ausgefuehrt.');
} catch (Throwable $exception) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
$pdo->rollBack();
}
scripts_stderr('Migration fehlgeschlagen: ' . $exception->getMessage());
}
+195
View File
@@ -22,6 +22,20 @@ 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';
@@ -78,6 +92,187 @@ function scripts_read_env_file(string $path): array
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);