Produktiv- und Testtabellen per Prefix trennen
This commit is contained in:
+3
-1
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/bootstrap.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/prefixed-pdo.php';
|
||||||
|
|
||||||
function app_db_pdo(): PDO
|
function app_db_pdo(): PDO
|
||||||
{
|
{
|
||||||
@@ -23,11 +24,12 @@ function app_db_pdo(): PDO
|
|||||||
}
|
}
|
||||||
|
|
||||||
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $database);
|
||||||
$pdo = new PDO($dsn, $user, $password ?? '', [
|
$pdo = new AppPrefixedPDO($dsn, $user, $password ?? '', [
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
PDO::ATTR_EMULATE_PREPARES => true,
|
PDO::ATTR_EMULATE_PREPARES => true,
|
||||||
]);
|
]);
|
||||||
|
$pdo->setTablePrefix(app_db_table_prefix());
|
||||||
|
|
||||||
// DB-Session auf denselben Offset wie die (in bootstrap.php gepinnte)
|
// DB-Session auf denselben Offset wie die (in bootstrap.php gepinnte)
|
||||||
// PHP-Zeitzone setzen, damit MySQL NOW() und PHP-Zeitstempel
|
// PHP-Zeitzone setzen, damit MySQL NOW() und PHP-Zeitstempel
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vollständiger Katalog aller Tabellen, die zur Kaffeeliste gehören. Die
|
||||||
|
* zentrale SQL-Umschreibung beschränkt sich bewusst auf diese feste Liste;
|
||||||
|
* ein vom Request gelieferter Bezeichner kann dadurch nie in SQL gelangen.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function app_database_table_names(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'schema_migrations',
|
||||||
|
'CoffeeSurveyVotedEmails',
|
||||||
|
'CoffeeSurveyResponses',
|
||||||
|
'dev_baseline_metadata',
|
||||||
|
'kl_Kaffeeverbrauch',
|
||||||
|
'kl_Einzahlungen',
|
||||||
|
'kl_Mitarbeiter',
|
||||||
|
'kl_hinweise',
|
||||||
|
'kl_config',
|
||||||
|
'payment_import_batches',
|
||||||
|
'payment_import_rows',
|
||||||
|
'stripe_webhook_events',
|
||||||
|
'rate_limit_attempts',
|
||||||
|
'tenant_memberships',
|
||||||
|
'legal_acceptances',
|
||||||
|
'outbound_emails',
|
||||||
|
'user_auth_tokens',
|
||||||
|
'tenant_settings',
|
||||||
|
'platform_admins',
|
||||||
|
'tenant_features',
|
||||||
|
'paypal_payments',
|
||||||
|
'ledger_entries',
|
||||||
|
'legal_requests',
|
||||||
|
'tenant_domains',
|
||||||
|
'tenant_billing',
|
||||||
|
'faq_entries',
|
||||||
|
'participants',
|
||||||
|
'audit_log',
|
||||||
|
'notices',
|
||||||
|
'tenants',
|
||||||
|
'users',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_validate_db_table_prefix(string $prefix): string
|
||||||
|
{
|
||||||
|
$prefix = trim($prefix);
|
||||||
|
if ($prefix === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (strlen($prefix) > 16 || preg_match('/^[a-z][a-z0-9_]*_$/', $prefix) !== 1) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'DB_TABLE_PREFIX muss leer sein oder aus höchstens 16 Kleinbuchstaben, Ziffern und Unterstrichen bestehen und mit Unterstrich enden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_db_table_prefix(): string
|
||||||
|
{
|
||||||
|
$prefix = app_validate_db_table_prefix((string)app_env('DB_TABLE_PREFIX', ''));
|
||||||
|
if ($prefix === '' && !app_is_dev()) {
|
||||||
|
throw new RuntimeException('DB_TABLE_PREFIX darf außerhalb der Entwicklungsumgebung nicht leer sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_db_table_name(string $baseName, ?string $prefix = null): string
|
||||||
|
{
|
||||||
|
if (!in_array($baseName, app_database_table_names(), true)) {
|
||||||
|
throw new InvalidArgumentException('Unbekannte Anwendungstabelle: ' . $baseName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app_validate_db_table_prefix($prefix ?? app_db_table_prefix()) . $baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt ausschließlich bekannte Tabellen- und FK-Namen um. Das gilt auch
|
||||||
|
* innerhalb der dynamischen DDL-Strings in den Migrationen 0003/0008 und für
|
||||||
|
* deren INFORMATION_SCHEMA-Vergleiche.
|
||||||
|
*/
|
||||||
|
function app_prefix_database_sql(string $sql, ?string $prefix = null): string
|
||||||
|
{
|
||||||
|
$prefix = app_validate_db_table_prefix($prefix ?? app_db_table_prefix());
|
||||||
|
if ($prefix === '') {
|
||||||
|
return $sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
static $tablePattern = null;
|
||||||
|
if ($tablePattern === null) {
|
||||||
|
$tables = app_database_table_names();
|
||||||
|
usort($tables, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));
|
||||||
|
$tablePattern = '/(?<![A-Za-z0-9_])('
|
||||||
|
. implode('|', array_map(static fn(string $table): string => preg_quote($table, '/'), $tables))
|
||||||
|
. ')(?![A-Za-z0-9_])/i';
|
||||||
|
}
|
||||||
|
$sql = preg_replace_callback(
|
||||||
|
$tablePattern,
|
||||||
|
static fn(array $match): string => $prefix . $match[0],
|
||||||
|
$sql
|
||||||
|
) ?? $sql;
|
||||||
|
|
||||||
|
// MySQL verlangt innerhalb einer Datenbank eindeutige Namen für
|
||||||
|
// Fremdschlüssel-Constraints. Die Migrationen verwenden durchgängig
|
||||||
|
// sprechende fk_*-Namen; sie brauchen daher ebenfalls den Umgebungs-Prefix.
|
||||||
|
$sql = preg_replace_callback(
|
||||||
|
'/(?<![A-Za-z0-9_])(fk_[A-Za-z0-9_]+)(?![A-Za-z0-9_])/i',
|
||||||
|
static fn(array $match): string => $prefix . $match[1],
|
||||||
|
$sql
|
||||||
|
) ?? $sql;
|
||||||
|
|
||||||
|
return $sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppPrefixedPDO extends PDO
|
||||||
|
{
|
||||||
|
private string $tablePrefix = '';
|
||||||
|
|
||||||
|
public function setTablePrefix(string $prefix): void
|
||||||
|
{
|
||||||
|
$this->tablePrefix = app_validate_db_table_prefix($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tablePrefix(): string
|
||||||
|
{
|
||||||
|
return $this->tablePrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||||
|
{
|
||||||
|
return parent::prepare(app_prefix_database_sql($query, $this->tablePrefix), $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
|
||||||
|
{
|
||||||
|
$query = app_prefix_database_sql($query, $this->tablePrefix);
|
||||||
|
if ($fetchMode === null) {
|
||||||
|
return parent::query($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::query($query, $fetchMode, ...$fetchModeArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exec(string $statement): int|false
|
||||||
|
{
|
||||||
|
return parent::exec(app_prefix_database_sql($statement, $this->tablePrefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,11 @@ Wichtige Punkte:
|
|||||||
|
|
||||||
- `--single-transaction` sorgt für einen konsistenten Snapshot ohne
|
- `--single-transaction` sorgt für einen konsistenten Snapshot ohne
|
||||||
Tabellen zu sperren (InnoDB, wie hier durchgängig verwendet).
|
Tabellen zu sperren (InnoDB, wie hier durchgängig verwendet).
|
||||||
|
- Da `test_` und `prod_` in derselben physischen Datenbank liegen, enthält
|
||||||
|
der vollständige Dump beide logisch getrennten Schemas. Beim Restore darf
|
||||||
|
nicht nur ein einzelner Prefix zurückgespielt werden, ohne Abhängigkeiten
|
||||||
|
und den jeweiligen Stand von `test_schema_migrations` beziehungsweise
|
||||||
|
`prod_schema_migrations` zu prüfen.
|
||||||
- Der Backup-Ordner muss **außerhalb** des über HTTP erreichbaren Webroots
|
- Der Backup-Ordner muss **außerhalb** des über HTTP erreichbaren Webroots
|
||||||
liegen, sonst wären Dumps öffentlich abrufbar.
|
liegen, sonst wären Dumps öffentlich abrufbar.
|
||||||
- Zusätzlich zu lokalen Backups auf dem Webspace mindestens eine Kopie an
|
- Zusätzlich zu lokalen Backups auf dem Webspace mindestens eine Kopie an
|
||||||
@@ -55,7 +60,9 @@ gunzip -c kaffeeliste_20260715_030000.sql.gz | mysql -h "$DB_HOST" -u "$DB_USER"
|
|||||||
|
|
||||||
Danach `php scripts/migrate.php` laufen lassen, falls das Backup älter als
|
Danach `php scripts/migrate.php` laufen lassen, falls das Backup älter als
|
||||||
die zuletzt eingespielten Migrationen ist (das Skript ist idempotent und
|
die zuletzt eingespielten Migrationen ist (das Skript ist idempotent und
|
||||||
wendet nur fehlende Migrationen an).
|
wendet nur fehlende Migrationen im konfigurierten `DB_TABLE_PREFIX` an).
|
||||||
|
Den Lauf daher mit der Konfiguration jeder wiederhergestellten Umgebung
|
||||||
|
separat ausführen.
|
||||||
|
|
||||||
**Restore-Test:** Mindestens einmal pro Quartal einen echten Restore gegen
|
**Restore-Test:** Mindestens einmal pro Quartal einen echten Restore gegen
|
||||||
eine separate Test-Datenbank durchführen und die App dagegen starten
|
eine separate Test-Datenbank durchführen und die App dagegen starten
|
||||||
|
|||||||
+41
-14
@@ -9,14 +9,22 @@ Dieses Dokument beschreibt, wie die Anwendung auf den Netcup-Webspace
|
|||||||
|
|
||||||
## Umgebungen
|
## Umgebungen
|
||||||
|
|
||||||
| Umgebung | Host | Webroot | Zweck |
|
| Umgebung | Host | Webroot | Tabellen-Prefix | Zweck |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | Vollständiger Durchlauf vor jedem Produktiv-Deploy |
|
| Staging | `testumgebung.kaffeeliste.de` | `/testumgebung.kaffeeliste.de/httpdocs/` | `test_` | Vollständiger Durchlauf vor jedem Produktiv-Deploy |
|
||||||
| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | Echtbetrieb |
|
| Produktion | `app.kaffeeliste.de` (App) + `kaffeeliste.de` (Marketing) | noch einzurichten | `prod_` | Echtbetrieb |
|
||||||
|
|
||||||
Beide Umgebungen brauchen **eigene Datenbanken** und **eigene**
|
Beide Umgebungen verwenden dieselbe physische Datenbank, aber zwingend
|
||||||
`env.local.php`. Die Staging-Umgebung läuft mit Stripe-Testkeys, die
|
unterschiedliche Tabellen-Prefixe und jeweils eine eigene `env.local.php`.
|
||||||
Produktion mit Live-Keys.
|
Damit sind Daten, Migrationstabellen und Fremdschlüssel von Staging und
|
||||||
|
Produktion logisch getrennt. Die Staging-Umgebung läuft mit Stripe-Testkeys,
|
||||||
|
die Produktion mit Live-Keys.
|
||||||
|
|
||||||
|
Der Prefix schützt vor versehentlichem Zugriff der einen Umgebung auf die
|
||||||
|
Tabellen der anderen, ersetzt aber keine getrennte Datenbank als harte
|
||||||
|
Sicherheitsgrenze: Ein kompromittierter gemeinsamer Datenbankzugang kann
|
||||||
|
weiterhin alle Tabellen sehen. Deshalb Zugangsdaten besonders restriktiv
|
||||||
|
behandeln und immer die gesamte Datenbank sichern.
|
||||||
|
|
||||||
### Host-Split beachten
|
### Host-Split beachten
|
||||||
|
|
||||||
@@ -80,12 +88,13 @@ Die temporäre `phpinfo()`-Datei danach wieder löschen.
|
|||||||
|
|
||||||
### 2. Datenbank anlegen
|
### 2. Datenbank anlegen
|
||||||
|
|
||||||
In Plesk eine leere MySQL-Datenbank samt eigenem Benutzer anlegen. Die
|
In Plesk eine MySQL-Datenbank samt Benutzer anlegen. Beide Umgebungen dürfen
|
||||||
Zugangsdaten werden gleich in `env.local.php` eingetragen.
|
dieselben Zugangsdaten verwenden; die Trennung erfolgt über
|
||||||
|
`DB_TABLE_PREFIX=test_` beziehungsweise `DB_TABLE_PREFIX=prod_`.
|
||||||
|
|
||||||
Die vorhandene Entwicklungsdatenbank **nicht** übernehmen: sie enthält
|
Unpräfixierte vorhandene Testdaten dürfen ausschließlich mit dem dafür
|
||||||
ausschließlich Testdaten inklusive des migrierten Default-Mandanten aus
|
vorgesehenen Übernahmeskript nach `test_` kopiert werden. Sie dürfen niemals
|
||||||
der Legacy-App.
|
nach `prod_` übernommen werden.
|
||||||
|
|
||||||
### 3. Dateien hochladen
|
### 3. Dateien hochladen
|
||||||
|
|
||||||
@@ -121,12 +130,16 @@ putenv('APP_TIMEZONE=Europe/Berlin');
|
|||||||
putenv('APP_DB_DRIVER=mysql');
|
putenv('APP_DB_DRIVER=mysql');
|
||||||
putenv('DB_HOST=localhost');
|
putenv('DB_HOST=localhost');
|
||||||
putenv('DB_NAME=…'); putenv('DB_USER=…'); putenv('DB_PASS=…');
|
putenv('DB_NAME=…'); putenv('DB_USER=…'); putenv('DB_PASS=…');
|
||||||
|
putenv('DB_TABLE_PREFIX=test_');
|
||||||
putenv('APP_SESSION_PATH=' . __DIR__ . '/var/sessions');
|
putenv('APP_SESSION_PATH=' . __DIR__ . '/var/sessions');
|
||||||
putenv('APP_MAIL_TRANSPORT=mail');
|
putenv('APP_MAIL_TRANSPORT=mail');
|
||||||
putenv('APP_MAIL_FROM=noreply@kaffeeliste.de');
|
putenv('APP_MAIL_FROM=noreply@kaffeeliste.de');
|
||||||
```
|
```
|
||||||
|
|
||||||
Stripe-, Dolibarr- und IMAP-Werte je Umgebung ergänzen (siehe unten).
|
Stripe-, Dolibarr- und IMAP-Werte je Umgebung ergänzen (siehe unten).
|
||||||
|
In der produktiven `env.local.php` muss stattdessen
|
||||||
|
`DB_TABLE_PREFIX=prod_` stehen. Ein leerer Prefix ist im laufenden Betrieb
|
||||||
|
nicht zulässig.
|
||||||
|
|
||||||
### 5. Schreibrechte für `var/`
|
### 5. Schreibrechte für `var/`
|
||||||
|
|
||||||
@@ -139,7 +152,21 @@ den CSV-Import. Beide sind per `.htaccess` von außen gesperrt.
|
|||||||
|
|
||||||
Es gibt 30 versionierte Migrationen in `database/migrations/`, angewendet
|
Es gibt 30 versionierte Migrationen in `database/migrations/`, angewendet
|
||||||
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
|
über `scripts/migrate.php`. Das Skript ist idempotent und wendet nur
|
||||||
fehlende Migrationen an.
|
fehlende Migrationen an. Es arbeitet ausschließlich im Prefix aus der
|
||||||
|
jeweiligen `env.local.php`; daher müssen Migrationen einmal in Staging und
|
||||||
|
einmal in Produktion ausgeführt werden.
|
||||||
|
|
||||||
|
Für die einmalige Übernahme vorhandener, bisher unpräfixierter Testdaten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php scripts/migrate-table-prefix.php --target-prefix=test_ --replace
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript migriert zuerst das `test_`-Schema, ersetzt dessen Inhalt
|
||||||
|
transaktional durch eine exakte Kopie der unpräfixierten Daten und vergleicht
|
||||||
|
pro Tabelle die Zeilenzahl. Die unpräfixierten Quelltabellen bleiben als
|
||||||
|
Rückfallmöglichkeit unverändert. Vor dem Lauf trotzdem ein vollständiges
|
||||||
|
Datenbank-Backup erstellen.
|
||||||
|
|
||||||
Ohne SSH-Zugang läuft das über **Plesk → Geplante Aufgaben (Scheduled
|
Ohne SSH-Zugang läuft das über **Plesk → Geplante Aufgaben (Scheduled
|
||||||
Tasks)** als einmalig ausgeführter Task vom Typ *PHP-Skript ausführen*:
|
Tasks)** als einmalig ausgeführter Task vom Typ *PHP-Skript ausführen*:
|
||||||
@@ -348,7 +375,7 @@ Rechten der Secret-Dateien oder einer nicht angewendeten Legal-Migration.
|
|||||||
ein Vorlauf mit `--dry-run` wurde kontrolliert
|
ein Vorlauf mit `--dry-run` wurde kontrolliert
|
||||||
- [ ] Stündlicher Task `php scripts/purge-expired-operational-data.php` ist
|
- [ ] Stündlicher Task `php scripts/purge-expired-operational-data.php` ist
|
||||||
eingerichtet; ein Vorlauf mit `--dry-run` wurde kontrolliert
|
eingerichtet; ein Vorlauf mit `--dry-run` wurde kontrolliert
|
||||||
- [ ] Frische Produktivdatenbank ohne Testdaten
|
- [ ] Frisches `prod_`-Schema ohne Testdaten; Staging verwendet ausschließlich `test_`
|
||||||
|
|
||||||
## Bewusst offen
|
## Bewusst offen
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -44,6 +44,7 @@ export DB_PORT="3306"
|
|||||||
export DB_NAME="example_database"
|
export DB_NAME="example_database"
|
||||||
export DB_USER="example_user"
|
export DB_USER="example_user"
|
||||||
export DB_PASS="example_password"
|
export DB_PASS="example_password"
|
||||||
|
export DB_TABLE_PREFIX="test_"
|
||||||
export DEV_AUTH_EMAIL="admin@test.local"
|
export DEV_AUTH_EMAIL="admin@test.local"
|
||||||
export DEV_AUTH_NAME="Test Admin"
|
export DEV_AUTH_NAME="Test Admin"
|
||||||
# Optional: Standard ist var/sessions im Repository.
|
# Optional: Standard ist var/sessions im Repository.
|
||||||
@@ -95,7 +96,9 @@ der Port über die Ports-Ansicht weitergeleitet werden.
|
|||||||
Nachrichten unter `var/mail` ab. Für Produktion kann zunächst
|
Nachrichten unter `var/mail` ab. Für Produktion kann zunächst
|
||||||
`APP_MAIL_TRANSPORT=mail` mit passendem `APP_BASE_URL` und Absender gesetzt
|
`APP_MAIL_TRANSPORT=mail` mit passendem `APP_BASE_URL` und Absender gesetzt
|
||||||
werden; eine SMTP-/Provider-Anbindung bleibt ein späterer Betriebsausbau.
|
werden; eine SMTP-/Provider-Anbindung bleibt ein späterer Betriebsausbau.
|
||||||
- Die Tabellen werden über `database/migrations/` angelegt.
|
- Die Tabellen werden über `database/migrations/` mit dem Prefix aus
|
||||||
|
`DB_TABLE_PREFIX` angelegt. Lokal und auf Staging ist das `test_`, in
|
||||||
|
Produktion ausschließlich `prod_`.
|
||||||
- `database/mysql-dev-schema.sql` bleibt als historische Dev-Schema-Baseline
|
- `database/mysql-dev-schema.sql` bleibt als historische Dev-Schema-Baseline
|
||||||
erhalten; der aktive Weg ist `scripts/migrate.php`.
|
erhalten; der aktive Weg ist `scripts/migrate.php`.
|
||||||
- Session-Dateien liegen standardmäßig unter `var/sessions`; `var/` wird von
|
- Session-Dateien liegen standardmäßig unter `var/sessions`; `var/` wird von
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ LD_LIBRARY_PATH="$PWD/.local/php/usr/lib/x86_64-linux-gnu:$PWD/.local/php/usr/li
|
|||||||
```
|
```
|
||||||
|
|
||||||
Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
|
Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
|
||||||
`DB_USER` und `DB_PASS`. `scripts/init-mysql-dev.php` braucht zusätzlich
|
`DB_USER`, `DB_PASS` und `DB_TABLE_PREFIX`. `scripts/init-mysql-dev.php`
|
||||||
`DEV_AUTH_EMAIL`.
|
braucht zusätzlich `DEV_AUTH_EMAIL`.
|
||||||
|
|
||||||
## Aktueller Prüfstatus
|
## Aktueller Prüfstatus
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ putenv('DB_PORT=3306');
|
|||||||
putenv('DB_NAME=');
|
putenv('DB_NAME=');
|
||||||
putenv('DB_USER=');
|
putenv('DB_USER=');
|
||||||
putenv('DB_PASS=');
|
putenv('DB_PASS=');
|
||||||
|
// Bei gemeinsamer physischer Datenbank zwingend pro Umgebung verschieden:
|
||||||
|
// Produktion `prod_`, Test/Staging `test_`. Der Prefix endet immer auf `_`.
|
||||||
|
putenv('DB_TABLE_PREFIX=prod_');
|
||||||
|
|
||||||
// Muss ausserhalb des oeffentlichen Webroots oder zumindest per .htaccess
|
// Muss ausserhalb des oeffentlichen Webroots oder zumindest per .htaccess
|
||||||
// gesperrt sein - siehe Deployment-Hinweise.
|
// gesperrt sein - siehe Deployment-Hinweise.
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../app/prefixed-pdo.php';
|
||||||
|
|
||||||
|
$assertSame = static function (string $expected, string $actual, string $message): void {
|
||||||
|
if ($expected !== $actual) {
|
||||||
|
fwrite(STDERR, "FEHLER: {$message}\nErwartet: {$expected}\nTatsächlich: {$actual}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$assertSame('', app_validate_db_table_prefix(''), 'Ein leerer Prefix muss für die einmalige Datenübernahme erlaubt sein.');
|
||||||
|
$assertSame('test_', app_validate_db_table_prefix('test_'), 'Der Test-Prefix muss gültig sein.');
|
||||||
|
$assertSame('prod_', app_validate_db_table_prefix('prod_'), 'Der Produktions-Prefix muss gültig sein.');
|
||||||
|
|
||||||
|
foreach (['Test_', '1test_', 'test', 'test-'] as $invalidPrefix) {
|
||||||
|
try {
|
||||||
|
app_validate_db_table_prefix($invalidPrefix);
|
||||||
|
fwrite(STDERR, "FEHLER: Ungültiger Prefix {$invalidPrefix} wurde akzeptiert.\n");
|
||||||
|
exit(1);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
// Erwartet.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$previousAppEnv = getenv('APP_ENV');
|
||||||
|
$previousDbPrefix = getenv('DB_TABLE_PREFIX');
|
||||||
|
putenv('APP_ENV=prod');
|
||||||
|
putenv('DB_TABLE_PREFIX');
|
||||||
|
try {
|
||||||
|
app_db_table_prefix();
|
||||||
|
fwrite(STDERR, "FEHLER: Produktion wurde ohne DB_TABLE_PREFIX akzeptiert.\n");
|
||||||
|
exit(1);
|
||||||
|
} catch (RuntimeException) {
|
||||||
|
// Erwartet: Außerhalb von Dev gilt fail-closed.
|
||||||
|
} finally {
|
||||||
|
$previousAppEnv === false ? putenv('APP_ENV') : putenv('APP_ENV=' . $previousAppEnv);
|
||||||
|
$previousDbPrefix === false ? putenv('DB_TABLE_PREFIX') : putenv('DB_TABLE_PREFIX=' . $previousDbPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assertSame(
|
||||||
|
'SELECT * FROM test_users JOIN test_tenants ON test_tenants.id = test_users.tenant_id',
|
||||||
|
app_prefix_database_sql('SELECT * FROM users JOIN tenants ON tenants.id = users.tenant_id', 'test_'),
|
||||||
|
'Normale Laufzeit-Abfragen müssen vollständig umgeschrieben werden.'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
'SELECT * FROM test_users',
|
||||||
|
app_prefix_database_sql('SELECT * FROM test_users', 'test_'),
|
||||||
|
'Bereits umgeschriebene Tabellennamen dürfen keinen doppelten Prefix erhalten.'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
"ALTER TABLE test_users ADD CONSTRAINT test_fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES test_tenants(id)",
|
||||||
|
app_prefix_database_sql(
|
||||||
|
'ALTER TABLE users ADD CONSTRAINT fk_users_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)',
|
||||||
|
'test_'
|
||||||
|
),
|
||||||
|
'Tabellen und Fremdschlüssel müssen gemeinsam umgeschrieben werden.'
|
||||||
|
);
|
||||||
|
$assertSame(
|
||||||
|
"WHERE TABLE_NAME = 'test_users' AND CONSTRAINT_NAME = 'test_fk_users_tenant'",
|
||||||
|
app_prefix_database_sql("WHERE TABLE_NAME = 'users' AND CONSTRAINT_NAME = 'fk_users_tenant'", 'test_'),
|
||||||
|
'Dynamische Migrationen müssen auch INFORMATION_SCHEMA-Vergleiche umschreiben.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql') ?: [];
|
||||||
|
if (count($migrationFiles) !== 30) {
|
||||||
|
fwrite(STDERR, 'FEHLER: Erwartet wurden 30 Migrationen, gefunden wurden ' . count($migrationFiles) . ".\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allMigrationSql = '';
|
||||||
|
foreach ($migrationFiles as $migrationFile) {
|
||||||
|
$contents = file_get_contents($migrationFile);
|
||||||
|
if ($contents === false) {
|
||||||
|
fwrite(STDERR, "FEHLER: Migration {$migrationFile} konnte nicht gelesen werden.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$allMigrationSql .= "\n" . $contents;
|
||||||
|
}
|
||||||
|
$prefixedMigrationSql = app_prefix_database_sql($allMigrationSql, 'test_');
|
||||||
|
|
||||||
|
foreach (app_database_table_names() as $table) {
|
||||||
|
$rawPattern = '/(?<![A-Za-z0-9_])' . preg_quote($table, '/') . '(?![A-Za-z0-9_])/i';
|
||||||
|
if (preg_match($rawPattern, $allMigrationSql) === 1 && preg_match($rawPattern, $prefixedMigrationSql) === 1) {
|
||||||
|
fwrite(STDERR, "FEHLER: Tabelle {$table} blieb in den umgeschriebenen Migrationen ohne Prefix.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preg_match('/(?<![A-Za-z0-9_])fk_[A-Za-z0-9_]+(?![A-Za-z0-9_])/i', $prefixedMigrationSql) === 1) {
|
||||||
|
fwrite(STDERR, "FEHLER: Mindestens ein Fremdschlüssel blieb in den Migrationen ohne Prefix.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "DB-Prefix-Prüfung bestanden: Produktion ist fail-closed; Laufzeit-SQL und alle 30 Migrationen werden getrennt umgeschrieben.\n";
|
||||||
@@ -25,6 +25,16 @@ if ($baseUrl !== '' && (!str_starts_with($baseUrl, 'https://') || parse_url($bas
|
|||||||
$errors[] = 'APP_BASE_URL muss eine HTTPS-URL auf APP_HOST sein.';
|
$errors[] = 'APP_BASE_URL muss eine HTTPS-URL auf APP_HOST sein.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dbTablePrefix = app_db_table_prefix();
|
||||||
|
if ($dbTablePrefix === '') {
|
||||||
|
$errors[] = 'DB_TABLE_PREFIX ist leer; Produktion und Test wären in derselben Datenbank nicht sicher getrennt.';
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$dbTablePrefix = '';
|
||||||
|
$errors[] = 'DB_TABLE_PREFIX ist ungültig: ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
$phone = app_legal_phone();
|
$phone = app_legal_phone();
|
||||||
if (strlen(preg_replace('/\D/', '', $phone) ?? '') < 7) {
|
if (strlen(preg_replace('/\D/', '', $phone) ?? '') < 7) {
|
||||||
$errors[] = 'LEGAL_PHONE fehlt oder ist unplausibel; B2C darf so nicht freigeschaltet werden.';
|
$errors[] = 'LEGAL_PHONE fehlt oder ist unplausibel; B2C darf so nicht freigeschaltet werden.';
|
||||||
@@ -66,10 +76,13 @@ if (preg_match('~@import\s+url\(["\']?https?://|url\(["\']?https?://~i', $mainCs
|
|||||||
try {
|
try {
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) {
|
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) {
|
||||||
$stmt = $pdo->prepare('SHOW TABLES LIKE ?');
|
$stmt = $pdo->prepare(
|
||||||
$stmt->execute([$table]);
|
'SELECT 1 FROM INFORMATION_SCHEMA.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([app_db_table_name($table)]);
|
||||||
if ($stmt->fetchColumn() === false) {
|
if ($stmt->fetchColumn() === false) {
|
||||||
$errors[] = "Datenbanktabelle {$table} fehlt; Migrationen ausführen.";
|
$errors[] = "Datenbanktabelle " . app_db_table_name($table) . ' fehlt; Migrationen ausführen.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
|
||||||
|
|||||||
+11
-2
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../app/bootstrap.php';
|
require_once __DIR__ . '/../app/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../app/prefixed-pdo.php';
|
||||||
|
|
||||||
function dev_required_env(string $name): string
|
function dev_required_env(string $name): string
|
||||||
{
|
{
|
||||||
@@ -15,7 +16,7 @@ function dev_required_env(string $name): string
|
|||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dev_pdo(): PDO
|
function dev_pdo_for_prefix(string $prefix): AppPrefixedPDO
|
||||||
{
|
{
|
||||||
$dsn = sprintf(
|
$dsn = sprintf(
|
||||||
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
||||||
@@ -24,11 +25,19 @@ function dev_pdo(): PDO
|
|||||||
dev_required_env('DB_NAME')
|
dev_required_env('DB_NAME')
|
||||||
);
|
);
|
||||||
|
|
||||||
return new PDO($dsn, dev_required_env('DB_USER'), dev_required_env('DB_PASS'), [
|
$pdo = new AppPrefixedPDO($dsn, dev_required_env('DB_USER'), dev_required_env('DB_PASS'), [
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
PDO::ATTR_EMULATE_PREPARES => true,
|
PDO::ATTR_EMULATE_PREPARES => true,
|
||||||
]);
|
]);
|
||||||
|
$pdo->setTablePrefix($prefix);
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dev_pdo(): AppPrefixedPDO
|
||||||
|
{
|
||||||
|
return dev_pdo_for_prefix(app_db_table_prefix());
|
||||||
}
|
}
|
||||||
|
|
||||||
function dev_apply_schema(PDO $pdo): void
|
function dev_apply_schema(PDO $pdo): void
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/dev-db.php';
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = getopt('', ['source-prefix:', 'target-prefix:', 'replace']);
|
||||||
|
$sourcePrefix = app_validate_db_table_prefix((string)($options['source-prefix'] ?? ''));
|
||||||
|
$targetOption = $options['target-prefix'] ?? null;
|
||||||
|
if (!is_string($targetOption) || trim($targetOption) === '') {
|
||||||
|
fwrite(STDERR, "Aufruf: php scripts/migrate-table-prefix.php --target-prefix=test_ --replace [--source-prefix=alt_]\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$targetPrefix = app_validate_db_table_prefix($targetOption);
|
||||||
|
|
||||||
|
if ($sourcePrefix === $targetPrefix) {
|
||||||
|
fwrite(STDERR, "Quell- und Ziel-Prefix müssen verschieden sein.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (!array_key_exists('replace', $options)) {
|
||||||
|
fwrite(STDERR, "Abbruch: --replace ist erforderlich, weil vorhandene Ziel-Testdaten ersetzt werden.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string */
|
||||||
|
$quoteIdentifier = static function (string $name): string {
|
||||||
|
return '`' . str_replace('`', '``', $name) . '`';
|
||||||
|
};
|
||||||
|
|
||||||
|
$rawPdo = dev_pdo_for_prefix('');
|
||||||
|
$targetPdo = dev_pdo_for_prefix($targetPrefix);
|
||||||
|
|
||||||
|
echo "Zielschema {$targetPrefix} wird auf den aktuellen Migrationsstand gebracht.\n";
|
||||||
|
$applied = dev_apply_migrations($targetPdo);
|
||||||
|
echo count($applied) . " neue Migration(en) für {$targetPrefix} angewendet.\n";
|
||||||
|
|
||||||
|
$tableExists = static function (PDO $pdo, string $table): bool {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT 1 FROM INFORMATION_SCHEMA.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$table]);
|
||||||
|
|
||||||
|
return $stmt->fetchColumn() !== false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
$tableColumns = static function (PDO $pdo, string $table): array {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||||
|
ORDER BY ORDINAL_POSITION'
|
||||||
|
);
|
||||||
|
$stmt->execute([$table]);
|
||||||
|
|
||||||
|
return array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
};
|
||||||
|
|
||||||
|
$targetsToClear = [];
|
||||||
|
$copies = [];
|
||||||
|
$sourceTableCount = 0;
|
||||||
|
|
||||||
|
foreach (app_database_table_names() as $baseName) {
|
||||||
|
if ($baseName === 'schema_migrations') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceTable = app_db_table_name($baseName, $sourcePrefix);
|
||||||
|
$targetTable = app_db_table_name($baseName, $targetPrefix);
|
||||||
|
$sourceExists = $tableExists($rawPdo, $sourceTable);
|
||||||
|
$targetExists = $tableExists($rawPdo, $targetTable);
|
||||||
|
|
||||||
|
if ($sourceExists) {
|
||||||
|
$sourceTableCount++;
|
||||||
|
}
|
||||||
|
if ($sourceExists && !$targetExists) {
|
||||||
|
throw new RuntimeException("Zieltabelle {$targetTable} fehlt nach den Migrationen.");
|
||||||
|
}
|
||||||
|
if (!$targetExists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetsToClear[] = $targetTable;
|
||||||
|
if (!$sourceExists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceColumns = $tableColumns($rawPdo, $sourceTable);
|
||||||
|
$targetColumns = $tableColumns($rawPdo, $targetTable);
|
||||||
|
if ($sourceColumns !== $targetColumns) {
|
||||||
|
throw new RuntimeException("Spalten von {$sourceTable} und {$targetTable} stimmen nicht überein.");
|
||||||
|
}
|
||||||
|
$copies[] = [$sourceTable, $targetTable, $sourceColumns];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceTableCount === 0) {
|
||||||
|
throw new RuntimeException('Im Quellschema wurden keine Anwendungstabellen gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$copiedRows = 0;
|
||||||
|
$rawPdo->exec('SET FOREIGN_KEY_CHECKS = 0');
|
||||||
|
try {
|
||||||
|
$rawPdo->beginTransaction();
|
||||||
|
|
||||||
|
foreach ($targetsToClear as $targetTable) {
|
||||||
|
$rawPdo->exec('DELETE FROM ' . $quoteIdentifier($targetTable));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($copies as [$sourceTable, $targetTable, $columns]) {
|
||||||
|
$quotedColumns = implode(', ', array_map($quoteIdentifier, $columns));
|
||||||
|
$sourceRows = (int)$rawPdo
|
||||||
|
->query('SELECT COUNT(*) FROM ' . $quoteIdentifier($sourceTable))
|
||||||
|
->fetchColumn();
|
||||||
|
|
||||||
|
if ($sourceRows > 0) {
|
||||||
|
$rawPdo->exec(
|
||||||
|
'INSERT INTO ' . $quoteIdentifier($targetTable)
|
||||||
|
. ' (' . $quotedColumns . ') SELECT ' . $quotedColumns
|
||||||
|
. ' FROM ' . $quoteIdentifier($sourceTable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetRows = (int)$rawPdo
|
||||||
|
->query('SELECT COUNT(*) FROM ' . $quoteIdentifier($targetTable))
|
||||||
|
->fetchColumn();
|
||||||
|
if ($sourceRows !== $targetRows) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Zeilenzahl stimmt für {$sourceTable} -> {$targetTable} nicht: {$sourceRows} != {$targetRows}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$copiedRows += $targetRows;
|
||||||
|
echo "{$sourceTable} -> {$targetTable}: {$targetRows} Zeile(n)\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawPdo->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if ($rawPdo->inTransaction()) {
|
||||||
|
$rawPdo->rollBack();
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
} finally {
|
||||||
|
$rawPdo->exec('SET FOREIGN_KEY_CHECKS = 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Datenübernahme abgeschlossen: {$copiedRows} Zeile(n). Das Quellschema blieb unverändert.\n";
|
||||||
Reference in New Issue
Block a user