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
+3
View File
@@ -29,6 +29,9 @@ Archivbestand erhalten.
Setup, Hosting und Betriebsablauf.
3. Arbeite anschliessend in [saas-app/README.md](saas-app/README.md) weiter.
Auf Webspace ohne Shell ist der bevorzugte Einstieg die gefuehrte Installation
unter `saas-app/public/install/`.
## Hilfsskripte
- `scripts/check-prerequisites.php` prueft lokale Voraussetzungen.
+24 -6
View File
@@ -4,6 +4,23 @@ Diese Anleitung beschreibt den vorgesehenen Weg fuer die SaaS-Version in
`saas-app/`. Sie ist auf Webspace-Betrieb ohne Docker und ohne dauerhafte
Worker ausgelegt.
## Empfohlener Weg Auf Webspace
Wenn auf dem Hosting keine Shell- oder CLI-Ausfuehrung moeglich ist, nutze den
gefuehrten Installer im Public-Pfad:
- `https://deine-domain.tld/install/`
Der Installer kann:
- die `.env` speichern
- das SQL-Bundle erzeugen
- Migrationen direkt per PHP ausfuehren, wenn `pdo_sqlsrv` verfuegbar ist
- sich nach erfolgreicher Einrichtung sperren
Die CLI-Skripte unter `scripts/*.php` bleiben als Alternative fuer lokale
Vorbereitung oder Server mit Shell-Zugriff erhalten.
## Voraussetzungen
- PHP 8.2 oder neuer
@@ -14,7 +31,7 @@ Worker ausgelegt.
- optional: SMTP-Zugang fuer Mails
- optional: OIDC-Provider fuer SSO
## Installation lokal
## Installation Lokal
1. Repository klonen.
2. Mit `php scripts/check-prerequisites.php` die Voraussetzungen pruefen.
@@ -26,7 +43,7 @@ Worker ausgelegt.
8. Einen ersten Benutzer und eine Mitgliedszuordnung anlegen.
9. Spaeter Composer und das eigentliche Laravel-Bootstrap nachziehen.
## Migrationen ausfuehren
## Migrationen Ausfuehren
Die aktuellen Dateien unter `saas-app/database/migrations/` sind SQL-Skizzen in
PHP-Dateien, keine Laravel-Migrationsklassen. Deshalb gibt es aktuell bewusst
@@ -70,9 +87,10 @@ eigene Umgebung angepasst werden:
1. Den Inhalt von `saas-app/` auf den Zielserver hochladen.
2. Das Document-Root auf `public/` zeigen lassen.
3. Schreibrechte fuer spaetere Storage-, Cache- und Queue-Bereiche sicherstellen.
4. `.env` serverseitig hinterlegen.
5. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen.
3. Schreibrechte fuer `saas-app/`, `.env`, `.installer.lock` und `database/migrations/generated/` sicherstellen.
4. Den Installer unter `/install/` aufrufen und die Einrichtung durchfuehren.
5. Nach erfolgreicher Einrichtung den Installer sperren.
6. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen.
## Cron- und Batch-Betrieb
@@ -89,7 +107,7 @@ Typische Aufgaben:
Empfehlung: pro Aufgabe einen klar benannten Cron-Eintrag anlegen und die
Ausgabe in Logdateien schreiben.
## Migration aus dem Legacy-System
## Migration Aus Dem Legacy-System
Wenn Daten aus der alten Root-Anwendung uebernommen werden sollen, folgt die
Reihenfolge:
+11 -6
View File
@@ -19,14 +19,19 @@ Die komplette Installationsanleitung steht im Repo unter
Kurzfassung:
1. Webserver auf `public/` ausrichten.
2. Im Browser `https://deine-domain.tld/install/` aufrufen.
3. `.env`, DB-Zugang und Tenancy-Werte ueber den Installer speichern.
4. SQL-Bundle erzeugen und wenn moeglich Migrationen direkt ausfuehren.
5. Installer sperren.
6. Einen ersten Mandanten und erste Benutzer anlegen.
7. Cron-Jobs fuer Queue, Import, Export und Benachrichtigungen einrichten.
Alternative mit PHP-CLI:
1. `php ../scripts/check-prerequisites.php`
2. `php ../scripts/install-saas.php`
3. `.env` aus `.env.example` ableiten und anpassen.
4. Datenbank und Tenancy-Werte konfigurieren.
5. SQL-Migrationen ueber das erzeugte Bundle ausfuehren.
6. Einen ersten Mandanten und erste Benutzer anlegen.
7. Den Webserver auf `public/` ausrichten.
8. Cron-Jobs fuer Queue, Import, Export und Benachrichtigungen einrichten.
3. `php ../scripts/run-sql-migrations.php --server=... --database=...`
## Migrationen
@@ -1,5 +1,5 @@
-- Generated migration bundle for Kaffeeliste SaaS
-- Generated at 2026-03-21T18:18:09+00:00
-- Generated at 2026-03-21T19:14:14+00:00
SET XACT_ABORT ON;
BEGIN TRANSACTION;
+1
View File
@@ -3,6 +3,7 @@ Dieses Verzeichnis ist fuer den Webserver-Document-Root vorgesehen.
Aktuell enthaelt es bereits:
- `index.php` als Preview-Einstieg fuer die neue SaaS-Struktur
- `install/index.php` als gefuehrten Einmal-Installer fuer Webspace
- `.htaccess` fuer einen einfachen Front-Controller-Pfad auf Apache
Sobald die Zielanwendung als vollwertiges Laravel-Projekt gebootstrapped ist,
+17 -14
View File
@@ -38,11 +38,11 @@ $modules = [
];
$installSteps = [
'1. `php scripts/check-prerequisites.php` ausfuehren.',
'2. `php scripts/prepare-saas-env.php` fuer die lokale `.env` verwenden.',
'3. `saas-app\.env` mit echten DB-, Mail-, Tenancy- und OIDC-Werten fuellen.',
'4. `php scripts/install-saas.php` fuer die SQL-Bundle-Erzeugung verwenden.',
'5. Optional `php scripts/run-sql-migrations.php --server=<server> --database=<db> --username=<user> --password=<pass>` fuer die direkte SQL-Ausfuehrung nutzen.',
'1. Den Webserver auf `saas-app/public/` zeigen lassen.',
'2. Den Installer unter `/install/` oeffnen.',
'3. `.env`, DB-, Mail-, Tenancy- und OIDC-Werte im Formular speichern.',
'4. SQL-Bundle erzeugen und optional Migrationen direkt im Installer ausfuehren.',
'5. Den Installer danach sperren.',
'6. Ersten Tenant, ersten Benutzer und erste Member-Zuordnung anlegen.',
];
@@ -289,6 +289,7 @@ function renderList(array $items): void
<a href="?page=home">Start</a>
<a href="?page=install">Installation</a>
<a href="?page=migrations">Migrationen</a>
<a href="install/">Installer</a>
</div>
</nav>
@@ -297,13 +298,13 @@ function renderList(array $items): void
<div class="eyebrow">Installation</div>
<h1>Die Installationsschritte laufen jetzt ueber interne Preview-Seiten.</h1>
<p>
Der Link zeigt nicht mehr auf <code>docs/installationshandbuch.md</code>,
sondern auf eine im Public-Bereich erreichbare Anleitung. Das ist noetig,
weil Dateien ausserhalb von <code>saas-app/public/</code> im Browser nicht
direkt verlinkt werden sollten.
Der bevorzugte Webspace-Weg laeuft jetzt ueber den gefuehrten Installer
unter <code>/install/</code>. Das ist noetig, weil Dateien ausserhalb von
<code>saas-app/public/</code> im Browser nicht direkt verlinkt werden
sollten und Shell-Zugriff auf vielen Hostings fehlt.
</p>
<div class="actions">
<a class="button" href="?page=migrations">Migrationen ansehen</a>
<a class="button" href="install/">Installer starten</a>
<a class="button secondary" href="?page=home">Zur Startseite</a>
</div>
<?php renderList($installSteps); ?>
@@ -314,11 +315,11 @@ function renderList(array $items): void
<h1>Die Migrationen werden ueber ein SQL-Bundle erzeugt und ausgefuehrt.</h1>
<p>
Aktuell gibt es hier keine lauffaehige Laravel-<code>artisan migrate</code>-Strecke.
Die Migrationsdateien liefern SQL und werden deshalb ueber Skripte zu
einer ausfuehrbaren Datei zusammengefuehrt.
Die Migrationsdateien liefern SQL und werden deshalb im Installer oder
ueber PHP-Skripte zu einer ausfuehrbaren Datei zusammengefuehrt.
</p>
<div class="actions">
<a class="button" href="?page=install">Installationsschritte</a>
<a class="button" href="install/">Installer starten</a>
<a class="button secondary" href="?page=home">Zur Startseite</a>
</div>
<?php renderList($migrationSteps); ?>
@@ -337,7 +338,7 @@ function renderList(array $items): void
gestalteten Produktseiten liegen unter <code>saas-app/</code>.
</p>
<div class="actions">
<a class="button" href="?page=install">Installation ansehen</a>
<a class="button" href="install/">Installer starten</a>
<a class="button secondary" href="?page=migrations">Migrationen ansehen</a>
</div>
<div class="meta">
@@ -371,6 +372,7 @@ function renderList(array $items): void
<li><code>scripts/install-saas.php</code></li>
<li><code>scripts/build-migration-bundle.php</code></li>
<li><code>scripts/run-sql-migrations.php</code></li>
<li><code>public/install/index.php</code></li>
</ul>
</article>
<article class="card">
@@ -379,6 +381,7 @@ function renderList(array $items): void
<li>Composer ist fuer den finalen Bootstrap weiterhin erforderlich.</li>
<li>Die Migrationen sind aktuell SQL-basiert, nicht <code>artisan</code>-basiert.</li>
<li>Fuer automatische Ausfuehrung wird die PHP-Erweiterung <code>pdo_sqlsrv</code> benoetigt.</li>
<li>Der bevorzugte Hosting-Pfad ist jetzt <code>/install/</code> als gefuehrter Einmal-Installer.</li>
</ul>
</article>
</section>
+453
View File
@@ -0,0 +1,453 @@
<?php
declare(strict_types=1);
session_start();
require_once dirname(__DIR__, 2) . '/scripts/support.php';
$requestMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (isset($_GET['download']) && $_GET['download'] === 'bundle' && is_file(scripts_bundle_output_path())) {
header('Content-Type: application/sql; charset=utf-8');
header('Content-Disposition: attachment; filename="all-migrations.sql"');
readfile(scripts_bundle_output_path());
exit;
}
if (!isset($_SESSION['installer_csrf'])) {
$_SESSION['installer_csrf'] = bin2hex(random_bytes(24));
}
$defaults = scripts_read_env_file(scripts_env_example_path());
$currentEnv = scripts_read_env_file(scripts_env_path());
$values = [
'APP_URL' => $currentEnv['APP_URL'] ?? ($defaults['APP_URL'] ?? ''),
'DB_HOST' => $currentEnv['DB_HOST'] ?? ($defaults['DB_HOST'] ?? '127.0.0.1'),
'DB_PORT' => $currentEnv['DB_PORT'] ?? ($defaults['DB_PORT'] ?? '1433'),
'DB_DATABASE' => $currentEnv['DB_DATABASE'] ?? ($defaults['DB_DATABASE'] ?? ''),
'DB_USERNAME' => $currentEnv['DB_USERNAME'] ?? ($defaults['DB_USERNAME'] ?? ''),
'DB_PASSWORD' => '',
'TENANCY_MODE' => $currentEnv['TENANCY_MODE'] ?? ($defaults['TENANCY_MODE'] ?? 'subdomain'),
'TENANCY_CENTRAL_DOMAINS' => $currentEnv['TENANCY_CENTRAL_DOMAINS'] ?? ($defaults['TENANCY_CENTRAL_DOMAINS'] ?? 'localhost'),
'TENANCY_FALLBACK_TENANT' => $currentEnv['TENANCY_FALLBACK_TENANT'] ?? ($defaults['TENANCY_FALLBACK_TENANT'] ?? ''),
'MAIL_HOST' => $currentEnv['MAIL_HOST'] ?? ($defaults['MAIL_HOST'] ?? '127.0.0.1'),
'MAIL_PORT' => $currentEnv['MAIL_PORT'] ?? ($defaults['MAIL_PORT'] ?? '1025'),
'MAIL_USERNAME' => $currentEnv['MAIL_USERNAME'] ?? ($defaults['MAIL_USERNAME'] ?? ''),
'MAIL_PASSWORD' => '',
'MAIL_FROM_ADDRESS' => $currentEnv['MAIL_FROM_ADDRESS'] ?? ($defaults['MAIL_FROM_ADDRESS'] ?? 'noreply@example.test'),
'MAIL_FROM_NAME' => $currentEnv['MAIL_FROM_NAME'] ?? ($defaults['MAIL_FROM_NAME'] ?? 'KaffeelisteSaaS'),
'OIDC_ENABLED' => $currentEnv['OIDC_ENABLED'] ?? ($defaults['OIDC_ENABLED'] ?? 'false'),
'OIDC_DEFAULT_PROVIDER' => $currentEnv['OIDC_DEFAULT_PROVIDER'] ?? ($defaults['OIDC_DEFAULT_PROVIDER'] ?? ''),
];
$messages = [];
$errors = [];
$executedMigrations = [];
$bundlePath = is_file(scripts_bundle_output_path()) ? scripts_bundle_output_path() : null;
$locked = scripts_installer_is_locked();
if ($requestMethod === 'POST' && !$locked) {
$csrf = (string) ($_POST['csrf'] ?? '');
if (!hash_equals($_SESSION['installer_csrf'], $csrf)) {
$errors[] = 'Ungueltiger CSRF-Status. Seite neu laden.';
} else {
foreach (array_keys($values) as $key) {
if (array_key_exists($key, $_POST)) {
$values[$key] = trim((string) $_POST[$key]);
}
}
$values['OIDC_ENABLED'] = isset($_POST['OIDC_ENABLED']) ? 'true' : 'false';
if ($values['DB_PASSWORD'] === '' && isset($currentEnv['DB_PASSWORD'])) {
$values['DB_PASSWORD'] = $currentEnv['DB_PASSWORD'];
}
if ($values['MAIL_PASSWORD'] === '' && isset($currentEnv['MAIL_PASSWORD'])) {
$values['MAIL_PASSWORD'] = $currentEnv['MAIL_PASSWORD'];
}
foreach (['APP_URL', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME'] as $required) {
if ($values[$required] === '') {
$errors[] = $required . ' darf nicht leer sein.';
}
}
if ($errors === []) {
try {
scripts_write_env_file($values);
$messages[] = '.env wurde geschrieben.';
$bundlePath = scripts_build_migration_bundle();
$messages[] = 'SQL-Bundle wurde erzeugt.';
if (isset($_POST['run_migrations'])) {
$executedMigrations = scripts_run_sql_migrations([
'server' => $values['DB_HOST'],
'database' => $values['DB_DATABASE'],
'port' => $values['DB_PORT'],
'username' => $values['DB_USERNAME'],
'password' => $values['DB_PASSWORD'],
]);
$messages[] = 'Migrationen wurden direkt ueber PHP ausgefuehrt.';
}
if (isset($_POST['lock_installer'])) {
scripts_installer_lock([
'app_url' => $values['APP_URL'],
'db_database' => $values['DB_DATABASE'],
]);
$messages[] = 'Installer wurde gesperrt. Fuer eine erneute Ausfuehrung muss `saas-app/.installer.lock` entfernt werden.';
$locked = true;
}
} catch (Throwable $exception) {
$message = $exception->getMessage();
if (str_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')) {
$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.';
}
}
}
}
}
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES);
}
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kaffeeliste SaaS Installer</title>
<style>
:root {
--bg: #f7f1e8;
--ink: #24170f;
--muted: #6a5649;
--brand: #0f766e;
--accent: #b45309;
--danger: #b42318;
--ok: #027a48;
--card: rgba(255, 252, 247, 0.92);
--line: rgba(36, 23, 15, 0.12);
--shadow: 0 24px 60px rgba(61, 38, 24, 0.12);
--radius: 24px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Aptos", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(180, 83, 9, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 22%),
linear-gradient(180deg, #fbf7f0 0%, var(--bg) 100%);
}
.shell {
width: min(1100px, calc(100vw - 32px));
margin: 24px auto 40px;
}
.hero, .panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero {
padding: 28px;
margin-bottom: 18px;
}
.grid {
display: grid;
gap: 18px;
grid-template-columns: 1.35fr 0.85fr;
}
.panel {
padding: 24px;
}
h1, h2 {
margin: 0 0 12px;
font-family: Georgia, serif;
line-height: 1.05;
}
h1 { font-size: clamp(2rem, 4vw, 3.4rem); }
h2 { font-size: 1.35rem; }
p, li {
color: var(--muted);
line-height: 1.65;
}
.eyebrow {
display: inline-block;
margin-bottom: 12px;
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.message-list, .status-list {
margin: 0;
padding-left: 18px;
}
.message-list li + li,
.status-list li + li {
margin-top: 8px;
}
.ok { color: var(--ok); }
.error { color: var(--danger); }
form {
display: grid;
gap: 18px;
}
.field-grid {
display: grid;
gap: 14px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field {
display: grid;
gap: 8px;
}
.field.full {
grid-column: 1 / -1;
}
label {
font-size: 0.92rem;
font-weight: 700;
}
input, select {
width: 100%;
border: 1px solid rgba(36, 23, 15, 0.16);
border-radius: 14px;
padding: 12px 14px;
font: inherit;
background: rgba(255, 255, 255, 0.96);
}
.checkbox {
display: flex;
gap: 10px;
align-items: flex-start;
}
.checkbox input {
width: auto;
margin-top: 4px;
}
.stack {
display: grid;
gap: 10px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
button, .link-button {
border: 0;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}
button {
background: linear-gradient(135deg, var(--brand), #115e59);
color: #fff;
}
.link-button {
color: var(--brand);
background: rgba(15, 118, 110, 0.08);
}
code {
padding: 2px 6px;
border-radius: 8px;
background: rgba(15, 118, 110, 0.08);
color: var(--brand);
}
@media (max-width: 900px) {
.grid,
.field-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<div class="eyebrow">One-time Installer</div>
<h1>Kaffeeliste SaaS im Browser installieren.</h1>
<p>
Diese Seite fuehrt die Erstinstallation ueber den Webspace: `.env` schreiben,
SQL-Bundle erzeugen und optional die Migrationen direkt ueber PHP ausfuehren.
Nach erfolgreicher Einrichtung sollte der Installer gesperrt bleiben.
</p>
<div class="actions" style="margin-top: 18px;">
<a class="link-button" href="/index.php">Zur Preview</a>
</div>
</section>
<section class="grid">
<section class="panel">
<h2>Installation</h2>
<?php if ($messages !== []): ?>
<ul class="message-list">
<?php foreach ($messages as $message): ?>
<li class="ok"><?= h($message) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($errors !== []): ?>
<ul class="message-list">
<?php foreach ($errors as $error): ?>
<li class="error"><?= h($error) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($locked): ?>
<ul class="status-list">
<li>Der Installer ist gesperrt.</li>
<li>Fuer eine erneute Ausfuehrung muss <code>saas-app/.installer.lock</code> entfernt werden.</li>
</ul>
<?php else: ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= h($_SESSION['installer_csrf']) ?>">
<div class="field-grid">
<div class="field full">
<label for="APP_URL">APP_URL</label>
<input id="APP_URL" name="APP_URL" value="<?= h($values['APP_URL']) ?>" required>
</div>
<div class="field">
<label for="DB_HOST">DB_HOST</label>
<input id="DB_HOST" name="DB_HOST" value="<?= h($values['DB_HOST']) ?>" required>
</div>
<div class="field">
<label for="DB_PORT">DB_PORT</label>
<input id="DB_PORT" name="DB_PORT" value="<?= h($values['DB_PORT']) ?>" required>
</div>
<div class="field">
<label for="DB_DATABASE">DB_DATABASE</label>
<input id="DB_DATABASE" name="DB_DATABASE" value="<?= h($values['DB_DATABASE']) ?>" required>
</div>
<div class="field">
<label for="DB_USERNAME">DB_USERNAME</label>
<input id="DB_USERNAME" name="DB_USERNAME" value="<?= h($values['DB_USERNAME']) ?>" required>
</div>
<div class="field full">
<label for="DB_PASSWORD">DB_PASSWORD</label>
<input id="DB_PASSWORD" name="DB_PASSWORD" type="password" value="<?= h($values['DB_PASSWORD']) ?>">
</div>
<div class="field">
<label for="TENANCY_MODE">TENANCY_MODE</label>
<select id="TENANCY_MODE" name="TENANCY_MODE">
<option value="subdomain"<?= $values['TENANCY_MODE'] === 'subdomain' ? ' selected' : '' ?>>subdomain</option>
<option value="path"<?= $values['TENANCY_MODE'] === 'path' ? ' selected' : '' ?>>path</option>
</select>
</div>
<div class="field">
<label for="TENANCY_CENTRAL_DOMAINS">TENANCY_CENTRAL_DOMAINS</label>
<input id="TENANCY_CENTRAL_DOMAINS" name="TENANCY_CENTRAL_DOMAINS" value="<?= h($values['TENANCY_CENTRAL_DOMAINS']) ?>">
</div>
<div class="field full">
<label for="TENANCY_FALLBACK_TENANT">TENANCY_FALLBACK_TENANT</label>
<input id="TENANCY_FALLBACK_TENANT" name="TENANCY_FALLBACK_TENANT" value="<?= h($values['TENANCY_FALLBACK_TENANT']) ?>">
</div>
<div class="field">
<label for="MAIL_HOST">MAIL_HOST</label>
<input id="MAIL_HOST" name="MAIL_HOST" value="<?= h($values['MAIL_HOST']) ?>">
</div>
<div class="field">
<label for="MAIL_PORT">MAIL_PORT</label>
<input id="MAIL_PORT" name="MAIL_PORT" value="<?= h($values['MAIL_PORT']) ?>">
</div>
<div class="field">
<label for="MAIL_USERNAME">MAIL_USERNAME</label>
<input id="MAIL_USERNAME" name="MAIL_USERNAME" value="<?= h($values['MAIL_USERNAME']) ?>">
</div>
<div class="field">
<label for="MAIL_PASSWORD">MAIL_PASSWORD</label>
<input id="MAIL_PASSWORD" name="MAIL_PASSWORD" type="password" value="<?= h($values['MAIL_PASSWORD']) ?>">
</div>
<div class="field">
<label for="MAIL_FROM_ADDRESS">MAIL_FROM_ADDRESS</label>
<input id="MAIL_FROM_ADDRESS" name="MAIL_FROM_ADDRESS" value="<?= h($values['MAIL_FROM_ADDRESS']) ?>">
</div>
<div class="field">
<label for="MAIL_FROM_NAME">MAIL_FROM_NAME</label>
<input id="MAIL_FROM_NAME" name="MAIL_FROM_NAME" value="<?= h($values['MAIL_FROM_NAME']) ?>">
</div>
<div class="field">
<label for="OIDC_DEFAULT_PROVIDER">OIDC_DEFAULT_PROVIDER</label>
<input id="OIDC_DEFAULT_PROVIDER" name="OIDC_DEFAULT_PROVIDER" value="<?= h($values['OIDC_DEFAULT_PROVIDER']) ?>">
</div>
<div class="field">
<label>OIDC_ENABLED</label>
<label class="checkbox">
<input name="OIDC_ENABLED" type="checkbox"<?= $values['OIDC_ENABLED'] === 'true' ? ' checked' : '' ?>>
<span>OIDC aktivieren</span>
</label>
</div>
</div>
<div class="stack">
<label class="checkbox">
<input name="run_migrations" type="checkbox"<?= extension_loaded('pdo_sqlsrv') ? '' : ' disabled' ?>>
<span>Migrationen direkt ueber PHP ausfuehren<?= extension_loaded('pdo_sqlsrv') ? '' : ' (pdo_sqlsrv fehlt aktuell)' ?></span>
</label>
<label class="checkbox">
<input name="lock_installer" type="checkbox" checked>
<span>Installer nach erfolgreicher Einrichtung sperren</span>
</label>
</div>
<div class="actions">
<button type="submit">Installation starten</button>
<?php if ($bundlePath !== null): ?>
<a class="link-button" href="?download=bundle">SQL-Bundle herunterladen</a>
<?php endif; ?>
</div>
</form>
<?php endif; ?>
</section>
<aside class="panel">
<h2>Status</h2>
<ul class="status-list">
<li>PHP Version: <code><?= h(PHP_VERSION) ?></code></li>
<li>.env.example: <code><?= is_file(scripts_env_example_path()) ? 'vorhanden' : 'fehlt' ?></code></li>
<li>.env: <code><?= is_file(scripts_env_path()) ? 'vorhanden' : 'fehlt' ?></code></li>
<li>pdo_sqlsrv: <code><?= extension_loaded('pdo_sqlsrv') ? 'aktiv' : 'nicht geladen' ?></code></li>
<li>Installer-Lock: <code><?= $locked ? 'aktiv' : 'offen' ?></code></li>
<li>Bundle-Pfad: <code><?= h(scripts_bundle_output_path()) ?></code></li>
</ul>
<?php if ($bundlePath !== null): ?>
<p style="margin-top: 18px;">Erzeugtes Bundle: <code><?= h($bundlePath) ?></code></p>
<?php endif; ?>
<?php if ($executedMigrations !== []): ?>
<p style="margin-top: 18px;">Ausgefuehrte Migrationen:</p>
<ul class="status-list">
<?php foreach ($executedMigrations as $file): ?>
<li><code><?= h($file) ?></code></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</aside>
</section>
</main>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/install.php';
+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);