M2 Umsetzung

This commit is contained in:
2026-07-12 01:07:35 +02:00
parent 66bdd38d54
commit 174fff6f31
10 changed files with 430 additions and 35 deletions
+1
View File
@@ -1,3 +1,4 @@
.env.local
.local/
uploads/
var/
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
if (!defined('APP_ROOT')) {
define('APP_ROOT', dirname(__DIR__));
}
function app_env(string $name, ?string $default = null): ?string
{
$value = getenv($name);
if ($value === false || $value === '') {
return $default;
}
return $value;
}
function app_is_dev(): bool
{
return app_env('APP_ENV', 'prod') === 'dev';
}
function app_is_https(): bool
{
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['SERVER_PORT'] ?? null) === '443');
}
function app_start_session(): void
{
if (PHP_SAPI === 'cli' || session_status() === PHP_SESSION_ACTIVE) {
return;
}
$sessionPath = app_env('APP_SESSION_PATH', APP_ROOT . '/var/sessions');
if ($sessionPath !== null && !is_dir($sessionPath)) {
@mkdir($sessionPath, 0700, true);
}
if ($sessionPath !== null && is_dir($sessionPath) && is_writable($sessionPath)) {
session_save_path($sessionPath);
}
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => app_is_https(),
'httponly' => true,
'samesite' => 'Lax',
]);
session_name(app_env('APP_SESSION_NAME', 'kaffeeliste_session') ?? 'kaffeeliste_session');
session_start();
}
function app_csrf_token(): string
{
app_start_session();
if (!isset($_SESSION) || !is_array($_SESSION)) {
$_SESSION = [];
}
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function app_csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="'
. htmlspecialchars(app_csrf_token(), ENT_QUOTES, 'UTF-8')
. '">';
}
function app_verify_csrf(?string $token): bool
{
app_start_session();
if (!isset($_SESSION) || !is_array($_SESSION)) {
$_SESSION = [];
}
return is_string($token)
&& isset($_SESSION['csrf_token'])
&& is_string($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
function app_require_csrf(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return;
}
if (!app_verify_csrf($_POST['csrf_token'] ?? null)) {
http_response_code(419);
die('CSRF validation failed.');
}
}
+4
View File
@@ -1,5 +1,9 @@
<?php
require_once __DIR__ . '/app/bootstrap.php';
app_start_session();
if ((getenv('APP_DB_DRIVER') ?: '') === 'mysql' && !function_exists('sqlsrv_connect')) {
require_once __DIR__ . '/lib/sqlsrv_mysql_compat.php';
}
@@ -0,0 +1,95 @@
CREATE TABLE IF NOT EXISTS kl_Mitarbeiter (
MitarbeiterID INT AUTO_INCREMENT PRIMARY KEY,
Name VARCHAR(255) NOT NULL,
Email VARCHAR(255) NOT NULL,
paypalname VARCHAR(255) NULL,
aktiv TINYINT(1) NOT NULL DEFAULT 1,
admin TINYINT(1) NOT NULL DEFAULT 0,
UNIQUE KEY uq_kl_mitarbeiter_email (Email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS kl_config (
id INT AUTO_INCREMENT PRIMARY KEY,
KostenproStrich DECIMAL(10,2) NOT NULL DEFAULT 0.20,
paypaluse TINYINT(1) NOT NULL DEFAULT 0,
paypallink VARCHAR(1000) NOT NULL DEFAULT '',
strichperweb TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS kl_Einzahlungen (
EinzahlungsID INT AUTO_INCREMENT PRIMARY KEY,
MitarbeiterID INT NOT NULL,
Betrag DECIMAL(10,2) NOT NULL,
Datum DATETIME NOT NULL,
KEY idx_kl_einzahlungen_mitarbeiter_datum (MitarbeiterID, Datum),
CONSTRAINT fk_kl_einzahlungen_mitarbeiter
FOREIGN KEY (MitarbeiterID) REFERENCES kl_Mitarbeiter(MitarbeiterID)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS kl_Kaffeeverbrauch (
VerbrauchID INT AUTO_INCREMENT PRIMARY KEY,
MitarbeiterID INT NOT NULL,
AnzahlStriche INT NOT NULL,
Kosten DECIMAL(10,2) NOT NULL,
KostenproStrich DECIMAL(10,2) NOT NULL,
Datum DATETIME NOT NULL,
Eintragsart INT NULL,
KEY idx_kl_verbrauch_mitarbeiter_datum (MitarbeiterID, Datum),
CONSTRAINT fk_kl_verbrauch_mitarbeiter
FOREIGN KEY (MitarbeiterID) REFERENCES kl_Mitarbeiter(MitarbeiterID)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS kl_hinweise (
id INT AUTO_INCREMENT PRIMARY KEY,
nachricht TEXT NOT NULL,
gueltig_bis DATETIME NOT NULL,
KEY idx_kl_hinweise_gueltig_bis (gueltig_bis)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS CoffeeSurveyVotedEmails (
EmailNorm VARCHAR(255) PRIMARY KEY,
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS CoffeeSurveyResponses (
id INT AUTO_INCREMENT PRIMARY KEY,
Q1_ListEase INT NOT NULL,
Q2_ListSatisfaction INT NOT NULL,
Q3_CoffeeQuality INT NOT NULL,
Q4_WebsiteSatisfaction INT NOT NULL,
Q5_Drinks TEXT NOT NULL,
Q5_DrinksOther TEXT NULL,
Q6_NewVarieties TEXT NULL,
Q7_ListProblem VARCHAR(100) NOT NULL,
Q7_ListProblemOther TEXT NULL,
Q8_Improvements TEXT NOT NULL,
Q8_ImprovementsOther TEXT NULL,
Q9_BetterIdeas TEXT NULL,
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS dev_baseline_metadata (
meta_key VARCHAR(100) PRIMARY KEY,
meta_value TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO kl_config (id, KostenproStrich, paypaluse, paypallink, strichperweb)
VALUES (1, 0.20, 0, '', 1)
ON DUPLICATE KEY UPDATE
KostenproStrich = VALUES(KostenproStrich),
paypaluse = VALUES(paypaluse),
strichperweb = VALUES(strichperweb);
INSERT INTO dev_baseline_metadata (meta_key, meta_value)
VALUES
('baseline_kind', 'reconstructed_mysql_dev'),
('legacy_database_status', 'no_ms_sql_legacy_snapshot_available'),
('legacy_behavior_source', 'code_and_screenshots'),
('visual_reference_path', 'docs/m0/screenshots'),
('schema_source', 'database/migrations/0001_legacy_mysql_baseline.sql')
ON DUPLICATE KEY UPDATE
meta_value = VALUES(meta_value);
+15 -2
View File
@@ -24,10 +24,20 @@ export DB_USER="example_user"
export DB_PASS="example_password"
export DEV_AUTH_EMAIL="admin@test.local"
export DEV_AUTH_NAME="Test Admin"
# Optional: Standard ist var/sessions im Repository.
export APP_SESSION_PATH="/path/to/writable/sessions"
```
## Testdatenbank initialisieren
Migrationen anwenden:
```bash
php scripts/migrate.php
```
Dev-Admin und Basistestdaten anlegen:
```bash
php scripts/init-mysql-dev.php
```
@@ -53,6 +63,9 @@ der Port ueber die Ports-Ansicht weitergeleitet werden.
## Hinweise
- Der Dev-Login umgeht AD/LDAP und nutzt `DEV_AUTH_EMAIL`.
- Die Tabellen werden aus `database/mysql-dev-schema.sql` angelegt.
- Die Tabellen werden ueber `database/migrations/` angelegt.
- `database/mysql-dev-schema.sql` bleibt als historische Dev-Schema-Baseline
erhalten; der aktive Weg ist `scripts/migrate.php`.
- Session-Dateien liegen standardmaessig unter `var/sessions`; `var/` wird von
Git ignoriert.
- Der Dev-Modus ist nur fuer lokale Tests gedacht, nicht fuer Produktion.
+102
View File
@@ -0,0 +1,102 @@
# M2 Technisches Fundament
Stand: 2026-07-12
M2 fuehrt die technischen Grundbausteine fuer den SaaS-Umbau ein, ohne das
Legacy-Verhalten oder das bestehende Design zu veraendern.
## Ziel
- Versionierte Datenbankmigrationen statt loser Schema-Ausfuehrung.
- Zentrales Bootstrap fuer Umgebung, Session und CSRF-Helper.
- Bestehende Legacy-Seiten bleiben kompatibel.
- Layout-Trennung wird vorbereitet, aber noch nicht in bestehende Seiten
hineingezogen.
## Umgesetzte Bausteine
### Bootstrap
- `app/bootstrap.php` definiert zentrale Helfer:
- `app_env()`
- `app_is_dev()`
- `app_start_session()`
- `app_csrf_token()`
- `app_csrf_field()`
- `app_verify_csrf()`
- `app_require_csrf()`
- `config.php` laedt den Bootstrap und startet die Session mit sicheren
Cookie-Optionen.
- Sessions werden standardmaessig unter `var/sessions` abgelegt, weil die
lokale PHP-Umgebung keinen beschreibbaren Systempfad garantiert. `var/` ist
von Git ignoriert. Der Pfad kann ueber `APP_SESSION_PATH` ueberschrieben
werden.
- CSRF ist bewusst noch nicht global erzwungen. Die vorhandenen POST-Seiten
werden spaeter einzeln umgestellt, damit keine Formulare oder Spezialflows
brechen.
### Migrationen
- `database/migrations/0001_legacy_mysql_baseline.sql` bildet die bisherige
MySQL-Dev-Baseline als erste versionierte Migration ab.
- `scripts/dev-db.php` verwaltet `schema_migrations` und fuehrt neue
Migrationen idempotent aus.
- `scripts/migrate.php` ist der direkte Runner fuer Migrationen.
- `scripts/init-mysql-dev.php` nutzt ab jetzt ebenfalls die Migrationslogik.
### Layout
- `header.php` und `footer.php` bleiben vorerst Legacy-Wrapper.
- Die spaetere Trennung in Public-Layout und App-Layout wird erst umgesetzt,
wenn die neue Route-/View-Struktur steht.
- Die bestehende HTML5-UP-Struktur, Sidebar und Assets bleiben unveraendert.
## Ausfuehrung
Mit normaler PHP-CLI:
```bash
php scripts/migrate.php
php scripts/init-mysql-dev.php
php scripts/check-golden-master.php
php scripts/http-smoke.php
```
In der aktuellen lokalen Umgebung:
```bash
LD_LIBRARY_PATH="$PWD/.local/php/usr/lib/x86_64-linux-gnu:$PWD/.local/php/usr/lib/x86_64-linux-gnu/sasl2" \
"$PWD/.local/php/usr/bin/php8.3" \
-c "$PWD/.local/php-dev.ini" \
scripts/migrate.php
```
Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
`DB_USER` und `DB_PASS`. `scripts/init-mysql-dev.php` braucht zusaetzlich
`DEV_AUTH_EMAIL`.
## Aktueller Pruefstatus
- Migration `0001_legacy_mysql_baseline.sql` erfolgreich angewendet.
- Zweiter Migrationslauf meldet: Datenbank ist aktuell.
- PHP-Syntax fuer Bootstrap, Migrationen, Init-Skript und Config ist sauber.
- Session-Start laeuft in der lokalen Dev-Umgebung ohne PHP-Warnings ueber
`var/sessions`.
- Golden Master weiterhin gruen mit 104 Assertions.
- HTTP-Smoke weiterhin gruen mit 14 sicheren Seiten.
## Bewusste Grenzen
- Keine Tenant-/User-/Rollen-Tabellen in M2. Diese gehoeren zu M3.
- Keine globale CSRF-Erzwingung in M2. Die Absicherung der POST-Seiten erfolgt
schrittweise.
- Kein Layout-Umbau in M2. Die visuelle Struktur bleibt stabil.
- PDF-/Mail-/Jahresprozesse bleiben als M6-Themen offen.
## Naechste Schritte
1. POST-Seiten einzeln mit `app_csrf_field()` und `app_require_csrf()`
absichern.
2. Eine duenne View-/Layout-Struktur vorbereiten, ohne Header/Footer-Markup
sofort zu verschieben.
3. Danach M3 starten: Tenants, User, Registrierung, Login und Rollen.
+11 -3
View File
@@ -282,7 +282,7 @@ Uebersicht:
| --- | --- | --- |
| M0 | Baseline | Bestand, Sicherheit und Designreferenz sind dokumentiert |
| M1 | Golden Master | Legacy-Ergebnisse sind als Vergleichsbasis eingefroren; HTTP-Smoke prueft sichere Seiten |
| M2 | Technisches Fundament | Public-/App-Struktur, Bootstrap, Session und CSRF stehen |
| M2 | Technisches Fundament | Migrationen, Bootstrap, Session und CSRF-Helper stehen |
| M3 | SaaS-Basis | Tenants, User, Registrierung, Login und Rollen funktionieren |
| M4 | Datenmigration | Legacy-Daten sind tenant-sicher im Zielmodell abgebildet |
| M5 | App-Kern | Dashboard, Striche, Einzahlungen, Mitglieder und Liste laufen |
@@ -373,19 +373,27 @@ Schritte:
- Projektstruktur festlegen: Public-Routes, App-Routes, Views/Templates,
Services/Repositories.
- Zentrales Bootstrap fuer Config, DB-Verbindung, Session und Fehlerbehandlung.
- Layouts trennen: Public-Layout und App-Layout.
Begonnen mit `app/bootstrap.php`.
- Versionierte Migrationen einfuehren. Begonnen mit
`database/migrations/0001_legacy_mysql_baseline.sql` und
`scripts/migrate.php`.
- Layouts trennen: Public-Layout und App-Layout. Noch offen; Header/Footer
bleiben vorerst Legacy-Wrapper.
- Bestehende Assets weiterverwenden.
- CSRF- und Session-Basis einziehen.
- CSRF- und Session-Basis einziehen. Session und CSRF-Helper sind vorhanden;
globale Erzwingung erfolgt schrittweise pro POST-Seite.
- Konfigurationswerte aus Code in Umgebung oder Settings verschieben.
Ergebnis:
- Grundgeruest fuer neue SaaS-App.
- Kein fachlicher Rewrite, aber klare Struktur fuer die Migration.
- Dokumentation: `docs/m2-technical-foundation.md`.
Abhaengigkeiten:
- Entscheidung, ob PHP nativ weitergefuehrt oder ein Framework genutzt wird.
- M2 bleibt bewusst ohne Tenant-/User-/Rollen-Tabellen; diese starten in M3.
### M3: Mandanten, Registrierung und Login
+71 -5
View File
@@ -31,12 +31,78 @@ function dev_pdo(): PDO
function dev_apply_schema(PDO $pdo): void
{
$schema = file_get_contents(__DIR__ . '/../database/mysql-dev-schema.sql');
if ($schema === false) {
fwrite(STDERR, "Could not read schema file.\n");
dev_apply_migrations($pdo);
}
function dev_migrations_dir(): string
{
return __DIR__ . '/../database/migrations';
}
function dev_ensure_migration_table(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
/**
* @return array<string, true>
*/
function dev_applied_migrations(PDO $pdo): array
{
dev_ensure_migration_table($pdo);
$applied = [];
$stmt = $pdo->query('SELECT version FROM schema_migrations ORDER BY version');
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $version) {
$applied[(string)$version] = true;
}
return $applied;
}
/**
* @return list<string> Applied migration filenames.
*/
function dev_apply_migrations(PDO $pdo): array
{
$dir = dev_migrations_dir();
$files = glob($dir . '/*.sql');
if ($files === false || $files === []) {
fwrite(STDERR, "No migration files found in {$dir}.\n");
exit(1);
}
$pdo->exec($schema);
}
sort($files, SORT_STRING);
$applied = dev_applied_migrations($pdo);
$newlyApplied = [];
$insert = $pdo->prepare('INSERT INTO schema_migrations (version) VALUES (?)');
foreach ($files as $file) {
$version = basename($file);
if (isset($applied[$version])) {
continue;
}
$sql = file_get_contents($file);
if ($sql === false) {
fwrite(STDERR, "Could not read migration file {$version}.\n");
exit(1);
}
try {
$pdo->exec($sql);
$insert->execute([$version]);
$newlyApplied[] = $version;
} catch (Throwable $e) {
fwrite(STDERR, "Migration {$version} failed: {$e->getMessage()}\n");
exit(1);
}
}
return $newlyApplied;
}
+7 -25
View File
@@ -2,35 +2,18 @@
declare(strict_types=1);
$required = ['DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS', 'DEV_AUTH_EMAIL'];
foreach ($required as $name) {
if ((getenv($name) ?: '') === '') {
fwrite(STDERR, "Missing environment variable: {$name}\n");
exit(1);
}
}
require __DIR__ . '/dev-db.php';
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
getenv('DB_HOST'),
getenv('DB_PORT') ?: '3306',
getenv('DB_NAME')
);
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$schema = file_get_contents(__DIR__ . '/../database/mysql-dev-schema.sql');
if ($schema === false) {
fwrite(STDERR, "Could not read schema file.\n");
$email = getenv('DEV_AUTH_EMAIL');
if ($email === false || trim($email) === '') {
fwrite(STDERR, "Missing environment variable: DEV_AUTH_EMAIL\n");
exit(1);
}
$pdo->exec($schema);
$pdo = dev_pdo();
dev_apply_schema($pdo);
$email = strtolower(trim((string)getenv('DEV_AUTH_EMAIL')));
$email = strtolower(trim((string)$email));
$name = trim((string)(getenv('DEV_AUTH_NAME') ?: 'Test Admin'));
$stmt = $pdo->prepare(
@@ -64,4 +47,3 @@ if ($noticeCount === 0) {
}
echo "MySQL dev schema is ready for {$email}.\n";
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
$pdo = dev_pdo();
$applied = dev_apply_migrations($pdo);
if ($applied === []) {
echo "No new migrations. Database is up to date.\n";
exit(0);
}
foreach ($applied as $version) {
echo "Applied migration: {$version}\n";
}
echo "Applied " . count($applied) . " migration(s).\n";