diff --git a/README.md b/README.md index fa2c2a2..1255026 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ Die App ist so aufgebaut, dass UI und Datenzugriff getrennt sind. Je nach Reimpo - Einstiegspunkt (z. B. `public/index.php`): Lädt eine Seite/Ansicht, die das Layout und die Matrix enthält. - Matrix-Ansicht (z. B. `partials/landing/main/material-matrix.php`): Enthält das Markup + JS, das die Daten lädt und die Tabelle rendert. -- API-Endpoints (z. B. `public/api/*`): Stellen JSON bereit für +- API-Endpoints (z. B. `/api/*` über das Routing): Stellen JSON bereit für - Drucker-Liste - Material-Liste - Drucker-spezifische Material-Kompatibilität -- DB-Zugriff (z. B. `tools/db.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt. +- DB-Zugriff (z. B. `src/App/Database.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt. Aktuelle DB-Struktur (Schema-Orientierung) Das Schema besteht aus drei Kern-Tabellen, die für die Materialmatrix benötigt werden: @@ -47,6 +47,7 @@ Hinweise für Reimport/Neuaufbau - `printer`: Drucker-Datensatz - `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos. - Die Datenbank kann migriert werden, solange Material-, Drucker- und Zuordnungsdaten semantisch erhalten bleiben. +- Für DB-Zugriff muss `APP_DB_ENABLED` aktiviert sein; die Zugangsdaten sollten per ENV-Variablen kommen. Sicherheit - Zugangsdaten sollten nicht im Repo liegen. Nutze ENV-Variablen oder separate Configs pro Environment. diff --git a/api/.gitkeep b/api/.gitkeep index 8b13789..e69de29 100644 --- a/api/.gitkeep +++ b/api/.gitkeep @@ -1 +0,0 @@ - diff --git a/config/.gitkeep b/config/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/config/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/config/community.php b/config/community.php deleted file mode 100644 index e5553d8..0000000 --- a/config/community.php +++ /dev/null @@ -1 +0,0 @@ -session()->start(); +$clientId = $app->session()->ensureClientId(); + +// Optionally expose a single global for templates if desired +$GLOBALS['client_id'] = $clientId; diff --git a/config/prod/.gitkeep b/config/prod/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/config/prod/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/config/prod/db.php b/config/prod/db.php index e5553d8..ff6afa6 100644 --- a/config/prod/db.php +++ b/config/prod/db.php @@ -1 +1,94 @@ - 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'mydb', + + // optional: schema/search_path (commonly "public") + 'schema' => 'public', + + 'user' => 'myuser', + 'password' => 'secret', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +// ---- MySQL / MariaDB (PDO driver: mysql) ------------------- +$mysql = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'dbname' => 'd0453540', + 'charset' => 'utf8mb4', + + // Alternative to host/port: + // 'unix_socket' => '/var/run/mysqld/mysqld.sock', + + 'user' => 'd0453540', + 'password' => 'P6jGRrSaX8QSiBMEJBL7', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; + +// ---- SQLite (PDO driver: sqlite) --------------------------- +$sqlite = [ + 'driver' => 'sqlite', + + // Use an absolute path in production, e.g. /var/app/data/app.sqlite + // For demo/dev you can use a relative path. + 'path' => __DIR__ . '/../var/app.sqlite', + + // SQLite ignores host/port/user/pass + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +// ------------------------------------------------------------ +// 3) Select and return config +// ------------------------------------------------------------ +switch ($driver) { + case 'pgsql': + return $pgsql; + + case 'mysql': + return $mysql; + + case 'sqlite': + return $sqlite; + + default: + throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver); +} + diff --git a/config/prod/domaindata.php b/config/prod/domaindata.php index e5553d8..9230205 100644 --- a/config/prod/domaindata.php +++ b/config/prod/domaindata.php @@ -1 +1,12 @@ - 'localhost', - 'db_name' => 'd0453540', - 'db_user' => 'd0453540', - 'db_pass' => 'P6jGRrSaX8QSiBMEJBL7', - 'db_charset' => 'utf8mb4', +declare(strict_types=1); + +/** + * config/db.php + * + * - Choose ONE driver below (others stay commented). + * - Each driver has its own config section. + * - The file returns ONE normalized array used by Database::createPdo(). + */ + +// ------------------------------------------------------------ +// 1) Driver selection (choose one) +// ------------------------------------------------------------ +//$driver = 'pqsql'; +$driver = 'mysql'; +// $driver = 'sqlite'; + +// ------------------------------------------------------------ +// 2) Driver-specific configuration sections +// ------------------------------------------------------------ + +// ---- PostgreSQL (PDO driver: pgsql) ------------------------- +$pgsql = [ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'mydb', + + // optional: schema/search_path (commonly "public") + 'schema' => 'public', + + 'user' => 'myuser', + 'password' => 'secret', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], ]; + +// ---- MySQL / MariaDB (PDO driver: mysql) ------------------- +$mysql = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'dbname' => 'd0453540', + 'charset' => 'utf8mb4', + + // Alternative to host/port: + // 'unix_socket' => '/var/run/mysqld/mysqld.sock', + + 'user' => 'd0453540', + 'password' => 'P6jGRrSaX8QSiBMEJBL7', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; + +// ---- SQLite (PDO driver: sqlite) --------------------------- +$sqlite = [ + 'driver' => 'sqlite', + + // Use an absolute path in production, e.g. /var/app/data/app.sqlite + // For demo/dev you can use a relative path. + 'path' => __DIR__ . '/../var/app.sqlite', + + // SQLite ignores host/port/user/pass + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +// ------------------------------------------------------------ +// 3) Select and return config +// ------------------------------------------------------------ +switch ($driver) { + case 'pgsql': + return $pgsql; + + case 'mysql': + return $mysql; + + case 'sqlite': + return $sqlite; + + default: + throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver); +} diff --git a/config/staging/domaindata.php b/config/staging/domaindata.php index e5553d8..0c0eadd 100644 --- a/config/staging/domaindata.php +++ b/config/staging/domaindata.php @@ -1 +1,12 @@ - -
+assets()->addStyle('/assets/app.css', 'early'); +$app->assets()->addScript('/assets/app.js', 'footer', true); +?> + +
+
-

3D-Druck Materialmatrix

-

Schnell prüfen, welche Filamente auf welchen Druckern laufen.

+

Materialmatrix

+

+

Schnell prüfen, welche Filamente auf welchen Druckern laufen.

- +
Bereit
-
- - - -
-
- - +
+
+
+ - - - - - - - - - + + + + + + + + +
MaterialEigenschaftenTg °CDüsePlatteZusatzAnwendungKinderEmissionMaterialEigenschaftenTg °CDüsePlatteZusatzAnwendungKinderEmission
- + - -
-

- Hinweis: Dieses Projekt wird privat betrieben und befindet sich im Aufbau. +

+

Hinweis: Dieses Projekt wird privat betrieben und befindet sich im Aufbau. Es sind noch nicht alle Drucker und Materialien eingetragen. Alle Angaben erfolgen nach bestem Wissen, jedoch ohne Gewähr auf Vollständigkeit oder Richtigkeit.

- - - +
diff --git a/partials/landing/search/.gitkeep b/partials/landing/search/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/partials/structure/layout_end.php b/partials/structure/layout_end.php index 01e20ed..1871bd7 100644 --- a/partials/structure/layout_end.php +++ b/partials/structure/layout_end.php @@ -1,9 +1,3 @@ - - - - + + diff --git a/partials/structure/layout_start.php b/partials/structure/layout_start.php index 8cf2491..77c6cba 100644 --- a/partials/structure/layout_start.php +++ b/partials/structure/layout_start.php @@ -1,29 +1,14 @@ +/** @var \App\App $app */ +$app = app(); +?> + - - - 3D-Druck Materialmatrix - - - - - - + + + + <?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?> + + + + diff --git a/partials/structure/matomo.php b/partials/structure/matomo.php deleted file mode 100644 index cfbf8ea..0000000 --- a/partials/structure/matomo.php +++ /dev/null @@ -1,2 +0,0 @@ - -
-
-
3D-Druck Materialmatrix
- -
-
diff --git a/partials/landing/account/.gitkeep b/public/.gitkeep similarity index 100% rename from partials/landing/account/.gitkeep rename to public/.gitkeep diff --git a/public/.htaccess b/public/.htaccess index 4640904..8ca4b75 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1 +1,32 @@ -# TODO +# ------------------------------------------------- +# Apache Front Controller Setup (public/.htaccess) +# ------------------------------------------------- + +RewriteEngine On + +# Sicherheit: keine Directory Listings +Options -Indexes + +# ------------------------------------------------- +# 1) Assets DIREKT ausliefern +# ------------------------------------------------- +RewriteRule ^assets/ - [L] + +# ------------------------------------------------- +# 2) page/ von außen sperren (nur intern per require nutzbar) +# ------------------------------------------------- +RewriteRule ^page/ - [F,L] + +# ------------------------------------------------- +# 3) Alles andere an den Front Controller +# ------------------------------------------------- +RewriteRule ^ index.php [L] + +# ------------------------------------------------- +# 4) (Optional) Zusätzliche Sicherheits-Header +# ------------------------------------------------- + + Header set X-Frame-Options "SAMEORIGIN" + Header set X-Content-Type-Options "nosniff" + Header set Referrer-Policy "strict-origin-when-cross-origin" + diff --git a/public/api/_db.php b/public/api/_db.php deleted file mode 100755 index 8848260..0000000 --- a/public/api/_db.php +++ /dev/null @@ -1,14 +0,0 @@ - 'DB connection failed']; - if (getenv('APP_DEBUG') === '1') { - $payload['detail'] = $e->getMessage(); - } - echo json_encode($payload, JSON_UNESCAPED_UNICODE); - exit; -} diff --git a/public/api/materials.php b/public/api/materials.php deleted file mode 100755 index 3b743ed..0000000 --- a/public/api/materials.php +++ /dev/null @@ -1,6 +0,0 @@ -query("SELECT * FROM materials WHERE is_active = 1 ORDER BY code"); -echo json_encode($stmt->fetchAll(), JSON_UNESCAPED_UNICODE); diff --git a/public/api/printer-materials.php b/public/api/printer-materials.php deleted file mode 100755 index 29ccf7f..0000000 --- a/public/api/printer-materials.php +++ /dev/null @@ -1,35 +0,0 @@ - 'printer id missing']); - exit; -} - -$printerStmt = $pdo->prepare("SELECT * FROM printers WHERE id = ?"); -$printerStmt->execute([$printer_id]); -$printer = $printerStmt->fetch(); -if (!$printer) { - http_response_code(404); - echo json_encode(['error' => 'printer not found']); - exit; -} - -$sql = "SELECT m.*, pms.support_level, pms.partial_reason, pms.extra_info - FROM materials m - LEFT JOIN printer_material_support pms - ON pms.material_id = m.id AND pms.printer_id = :pid - WHERE m.is_active = 1 - ORDER BY m.code"; -$stmt = $pdo->prepare($sql); -$stmt->execute([':pid' => $printer_id]); -$materials = $stmt->fetchAll(); - -echo json_encode([ - 'printer' => $printer, - 'materials' => $materials -], JSON_UNESCAPED_UNICODE); diff --git a/public/api/printers.php b/public/api/printers.php deleted file mode 100755 index 1adb40e..0000000 --- a/public/api/printers.php +++ /dev/null @@ -1,7 +0,0 @@ -/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + async function fetchJSON(path) { + const res = await fetch(API_BASE + path, { headers: { 'Accept': 'application/json' } }); + if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + path); + return await res.json(); + } + + async function loadPrinters() { + try { + setStatus('Lade Drucker ...'); + const data = await fetchJSON('/printers'); + printerSelect.innerHTML = ''; + printerCompare.innerHTML = ''; + + if (!Array.isArray(data) || data.length === 0) { + printerSelect.innerHTML = ''; + setStatus('Keine Drucker gefunden'); + return; + } + + data.forEach(p => { + printerSelect.appendChild(new Option(p.name, p.id)); + printerCompare.appendChild(new Option(p.name, p.id)); + }); + + loadSinglePrinter(data[0].id); + setStatus('Drucker geladen'); + } catch (err) { + console.error(err); + showError('Konnte Drucker nicht laden.'); + setStatus('Fehler'); + } + } + + async function loadSinglePrinter(id) { + if (!id) return; + clearError(); + setStatus('Lade Materialien ...'); + try { + const data = await fetchJSON('/printer-materials?id=' + encodeURIComponent(id)); + renderTable([data]); + setStatus('Fertig'); + } catch (err) { + console.error(err); + showError('Konnte Materialien für den Drucker nicht laden.'); + setStatus('Fehler'); + } + } + + async function loadMultiplePrinters(ids) { + if (!ids.length) return; + clearError(); + setStatus('Lade Vergleich ...'); + try { + const datasets = await Promise.all( + ids.map(id => fetchJSON('/printer-materials?id=' + encodeURIComponent(id))) + ); + renderTable(datasets); + setStatus('Vergleich geladen'); + } catch (err) { + console.error(err); + showError('Konnte einen der gewählten Drucker nicht laden.'); + setStatus('Fehler'); + } + } + + function renderTable(datasets) { + const baseHead = [ + 'Material', + 'Eigenschaften', + 'Tg °C', + 'Düse', + 'Platte', + 'Zusatz', + 'Anwendung', + 'Kinder', + 'Emission' + ].map(label => `${label}`).join(''); + + let printerCols = ''; + datasets.forEach(ds => { + if (ds && ds.printer) { + printerCols += `${escapeHtml(ds.printer.name)}`; + } + }); + + tableHead.innerHTML = baseHead + printerCols; + + const materials = (datasets[0] && datasets[0].materials) ? datasets[0].materials : []; + matBody.innerHTML = ''; + + if (!materials.length) { + const empty = document.createElement('tr'); + empty.innerHTML = 'Keine Materialien gefunden.'; + matBody.appendChild(empty); + return; + } + + materials.forEach((m, idx) => { + const tr = document.createElement('tr'); + tr.className = idx % 2 === 0 ? '' : 'is-alt'; + + const kid = m.kid_safety === 'safe' ? 'grün' : (m.kid_safety === 'limited' ? 'gelb' : 'rot'); + const em = m.emission === 'low' ? 'niedrig' : (m.emission === 'medium' ? 'mittel' : 'hoch'); + + let html = ''; + html += `${escapeHtml(m.code)}
${escapeHtml(m.short_desc || '')}
`; + html += `${escapeHtml(m.properties || '')}`; + html += `${escapeHtml(m.tg_celsius || '–')}`; + html += `${escapeHtml(m.nozzle_req || '')}`; + html += `${escapeHtml(m.plate_req || '')}`; + html += `${escapeHtml(m.extra_req || '')}`; + html += `${escapeHtml(m.application || '')}`; + html += `${escapeHtml(kid)}`; + html += `${escapeHtml(em)}`; + + datasets.forEach(ds => { + const printerId = ds && ds.printer ? ds.printer.id : ''; + const match = ds.materials.find(x => x.id === m.id || x.code === m.code); + if (!match || !match.support_level) { + html += `unbekannt`; + } else { + let badge = ''; + if (match.support_level === 'full') { + badge = 'voll'; + } else if (match.support_level === 'partial') { + badge = 'teilw.'; + } else if (match.support_level === 'with_addon') { + badge = 'Zusatz'; + } else { + badge = 'nein'; + } + const note = match.partial_reason + ? `
${escapeHtml(match.partial_reason)}
` + : (match.extra_info ? `
${escapeHtml(match.extra_info)}
` : ''); + html += `${badge}${note}`; + } + }); + + tr.innerHTML = html; + matBody.appendChild(tr); + }); + } + + printerSelect.addEventListener('change', e => { + const id = e.target.value; + if (id) { + loadSinglePrinter(id); + printerCompare.selectedIndex = -1; + } + }); + + printerCompare.addEventListener('change', e => { + const ids = Array.from(e.target.selectedOptions).map(o => o.value); + if (ids.length) { + loadMultiplePrinters(ids); + } + }); + + loadPrinters(); +})(); diff --git a/public/assets/bilder/.gitkeep b/public/assets/bilder/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/public/assets/bilder/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/assets/css/.gitkeep b/public/assets/css/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/public/assets/css/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/assets/fonts/.gitkeep b/public/assets/fonts/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/public/assets/fonts/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/assets/js/.gitkeep b/public/assets/js/.gitkeep deleted file mode 100644 index 8b13789..0000000 --- a/public/assets/js/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/index.php b/public/index.php index 1a7ad0c..04c65ea 100644 --- a/public/index.php +++ b/public/index.php @@ -1,2 +1,88 @@ +
+

Seite nicht gefunden

+

Die angeforderte Seite existiert nicht.

+
diff --git a/public/page/api/materials.php b/public/page/api/materials.php new file mode 100644 index 0000000..3928030 --- /dev/null +++ b/public/page/api/materials.php @@ -0,0 +1,19 @@ +pdo(); + +if (!$pdo) { + http_response_code(500); + echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE); + exit; +} + +$repo = new \App\Repository\MaterialMatrixRepository($pdo); +$materials = $repo->listActiveMaterials(); + +echo json_encode($materials, JSON_UNESCAPED_UNICODE); +exit; diff --git a/public/page/api/printer-materials.php b/public/page/api/printer-materials.php new file mode 100644 index 0000000..4155ad6 --- /dev/null +++ b/public/page/api/printer-materials.php @@ -0,0 +1,37 @@ + 'printer id missing'], JSON_UNESCAPED_UNICODE); + exit; +} + +$app = app(); +$pdo = $app->pdo(); + +if (!$pdo) { + http_response_code(500); + echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE); + exit; +} + +$repo = new \App\Repository\MaterialMatrixRepository($pdo); +$printer = $repo->getPrinterById($printerId); + +if (!$printer) { + http_response_code(404); + echo json_encode(['error' => 'printer not found'], JSON_UNESCAPED_UNICODE); + exit; +} + +$materials = $repo->listMaterialsForPrinter($printerId); + +echo json_encode([ + 'printer' => $printer, + 'materials' => $materials, +], JSON_UNESCAPED_UNICODE); +exit; diff --git a/public/page/api/printers.php b/public/page/api/printers.php new file mode 100644 index 0000000..38fd4be --- /dev/null +++ b/public/page/api/printers.php @@ -0,0 +1,19 @@ +pdo(); + +if (!$pdo) { + http_response_code(500); + echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE); + exit; +} + +$repo = new \App\Repository\MaterialMatrixRepository($pdo); +$printers = $repo->listActivePrinters(); + +echo json_encode($printers, JSON_UNESCAPED_UNICODE); +exit; diff --git a/public/page/dashboard.php b/public/page/dashboard.php deleted file mode 100644 index 47dcce4..0000000 --- a/public/page/dashboard.php +++ /dev/null @@ -1,5 +0,0 @@ -flash()->get(); + $isLoggedIn = isset($_SESSION['user_id']); + $error = ''; + $displayName = ''; + $email = ''; + + if ($isLoggedIn) { + redirect('/dashboard'); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $displayName = trim((string)($_POST['display_name'] ?? '')); + $email = trim((string)($_POST['email'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + $password2 = (string)($_POST['password_confirm'] ?? ''); + + if ($password !== $password2) { + $error = 'Passwörter stimmen nicht überein.'; + } elseif (strlen($password) < 8) { + $error = 'Passwort muss mindestens 8 Zeichen haben.'; + } else { + try { + $auth = new Auth($app); + $userId = $auth->register($displayName, $email, $password); + $code = $auth->createVerifyCode($userId, $email); + $mailer = new Mailer($app); + $mailer->sendTemplate('registration_confirm', $email, [ + 'code' => $code, + 'display_name' => $displayName, + ]); + $_SESSION['verify_email'] = $email; + $app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.'); + redirect('/verify'); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + } + + return compact('flash', 'error', 'displayName', 'email'); + } + + public static function login(App $app): array + { + $flash = $app->flash()->get(); + $isLoggedIn = isset($_SESSION['user_id']); + $error = ''; + $emailPrefill = ''; + + if ($isLoggedIn) { + redirect('/dashboard'); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $email = trim((string)($_POST['email'] ?? '')); + $emailPrefill = $email; + $password = (string)($_POST['password'] ?? ''); + try { + $auth = new Auth($app); + $res = $auth->login($email, $password); + if ($res['status'] === 'pending') { + $code = $auth->createVerifyCode($res['id'], $email); + $mailer = new Mailer($app); + $mailer->sendTemplate('registration_confirm', $email, [ + 'code' => $code, + 'display_name' => $email, + ]); + $_SESSION['verify_email'] = $email; + $app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.'); + redirect('/verify'); + } + $_SESSION['user_id'] = $res['id']; + $app->flash()->set('success', 'Erfolgreich angemeldet.'); + redirect('/dashboard'); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + return compact('flash', 'error', 'emailPrefill', 'isLoggedIn'); + } + + public static function verify(App $app): array + { + $pdo = $app->pdo(); + $flash = $app->flash()->get(); + $error = ''; + $info = ''; + $email = $_SESSION['verify_email'] ?? ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? 'verify'; + $email = trim((string)($_POST['email'] ?? '')); + $code = strtoupper(trim((string)($_POST['code'] ?? ''))); + $auth = new Auth($app); + $mailer = new Mailer($app); + + if ($action === 'resend') { + try { + $stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1'); + $stmt?->execute(['email' => $email]); + $row = $stmt?->fetch(\PDO::FETCH_ASSOC); + if (!$row) { + throw new \RuntimeException('E-Mail nicht gefunden.'); + } + $userId = (int)$row['id']; + $codeNew = $auth->createVerifyCode($userId, $email); + $mailer->sendTemplate('registration_resend_code', $email, [ + 'code' => $codeNew, + 'display_name' => $row['display_name'] ?? '', + ]); + $info = 'Neuer Code wurde versendet.'; + $_SESSION['verify_email'] = $email; + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } else { + try { + $userId = $auth->verifyCode($email, $code); + $_SESSION['user_id'] = $userId; + unset($_SESSION['verify_email']); + $mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]); + $app->flash()->set('success', 'Registrierung bestätigt. Willkommen!'); + redirect('/dashboard'); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + } + + return compact('flash', 'error', 'info', 'email'); + } + + public static function dashboard(App $app): array + { + if (!isset($_SESSION['user_id'])) { + redirect('/login'); + } + + $pdo = $app->pdo(); + $flash = $app->flash()->get(); + $userId = (int)$_SESSION['user_id']; + $error = ''; + $info = ''; + $crypto = null; + try { $crypto = new Crypto($app->config()); } catch (\Throwable) {} + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + try { + if ($action === 'profile') { + $languages = $_POST['languages'] ?? ''; + if (is_array($languages)) { + $languages = implode(', ', array_map('trim', $languages)); + } + $phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']); + $stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id'); + $stmt?->execute([ + 'name' => trim((string)$_POST['display_name']), + 'fname' => trim((string)$_POST['first_name']), + 'lname' => trim((string)$_POST['last_name']), + 'zip' => trim((string)$_POST['zip']), + 'city' => trim((string)$_POST['city']), + 'prof' => trim((string)$_POST['profession']), + 'langs' => trim((string)$languages), + 'about' => trim((string)$_POST['about']), + 'phone' => $phoneEnc, + 'id' => $userId, + ]); + $info = 'Profil gespeichert.'; + } elseif ($action === 'child_add') { + $firstNameEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['first_name'])) : trim((string)$_POST['first_name']); + $noteEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['note'])) : trim((string)$_POST['note']); + $stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())'); + $stmt?->execute([ + 'uid' => $userId, + 'gender' => $_POST['gender'] ?? 'unknown', + 'birthdate' => $_POST['birthdate'] ?: null, + 'age' => $_POST['age_years'] ?: null, + 'name' => $firstNameEnc, + 'note' => $noteEnc, + ]); + $info = 'Kind hinzugefügt.'; + } elseif ($action === 'event_add' || $action === 'event_update') { + $street = trim((string)($_POST['street'] ?? '')); + $zip = trim((string)($_POST['zip'] ?? '')); + $city = trim((string)($_POST['city'] ?? '')); + $region = trim((string)($_POST['region'] ?? '')); + $lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null; + $lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null; + $needsGeocode = ($lat === null || $lng === null || $region === ''); + if ($needsGeocode) { + [$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region); + if ($lat === null) { $lat = $geoLat; } + if ($lng === null) { $lng = $geoLng; } + if ($region === '' && $geoRegion) { $region = $geoRegion; } + } + + if ($action === 'event_add') { + $stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())'); + $stmt?->execute([ + 'uid' => $userId, + 'title' => trim((string)$_POST['title']), + 'teaser' => trim((string)$_POST['teaser']), + 'descr' => trim((string)$_POST['description']), + 'loc' => trim((string)$_POST['location_label']), + 'street' => $street ?: null, + 'zip' => $zip, + 'city' => $city, + 'region' => $region, + 'lat' => $lat, + 'lng' => $lng, + 'start' => $_POST['starts_at'] ?? null, + 'allow' => isset($_POST['allow_kids']) ? 0 : 1, + 'vis' => $_POST['visibility'] ?? 'public', + 'status' => 'published', + ]); + $info = 'Event gespeichert.'; + // Punkte für Event-Erstellung vergeben + try { + $cfgPath = dirname(__DIR__, 2) . '/config/community.php'; + $communityCfg = file_exists($cfgPath) ? require $cfgPath : []; + $community = new Community($pdo, $communityCfg); + $community->addPoints($userId, 'event', 'create', ['event_id' => $pdo?->lastInsertId()]); + } catch (\Throwable) { + // still continue, points optional + } + } else { + $eventId = (int)($_POST['event_id'] ?? 0); + $stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid'); + $stmt?->execute([ + 'id' => $eventId, + 'uid' => $userId, + 'title' => trim((string)$_POST['title']), + 'teaser' => trim((string)$_POST['teaser']), + 'descr' => trim((string)$_POST['description']), + 'loc' => trim((string)$_POST['location_label']), + 'street' => $street ?: null, + 'zip' => $zip, + 'city' => $city, + 'region' => $region, + 'lat' => $lat, + 'lng' => $lng, + 'start' => $_POST['starts_at'] ?? null, + 'allow' => isset($_POST['allow_kids']) ? 0 : 1, + 'vis' => $_POST['visibility'] ?? 'public', + ]); + $info = 'Event aktualisiert.'; + } + } elseif ($action === 'event_delete') { + $eventId = (int)($_POST['event_id'] ?? 0); + $stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1'); + $stmt?->execute(['id' => $eventId]); + $ev = $stmt?->fetch(\PDO::FETCH_ASSOC); + if (!$ev || (int)$ev['created_by'] !== $userId) { + throw new \RuntimeException('Event nicht gefunden.'); + } + if ((int)$ev['participant_count'] > 0) { + throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.'); + } + $pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]); + $info = 'Event gelöscht.'; + } elseif ($action === 'event_cancel') { + $eventId = (int)($_POST['event_id'] ?? 0); + $stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1'); + $stmt?->execute(['id' => $eventId]); + $ev = $stmt?->fetch(\PDO::FETCH_ASSOC); + if (!$ev || (int)$ev['created_by'] !== $userId) { + throw new \RuntimeException('Event nicht gefunden.'); + } + $pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([ + 'st' => 'cancelled', + 'id' => $eventId, + ]); + $info = 'Event wurde abgesagt.'; + } + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + // Daten laden + $profile = [ + 'display_name' => '', + 'first_name' => '', + 'last_name' => '', + 'zip' => '', + 'city' => '', + 'profession' => '', + 'languages' => '', + 'about' => '', + 'email' => '', + 'contact_phone' => '', + ]; + $stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1'); + $stmt?->execute(['id' => $userId]); + $row = $stmt?->fetch(\PDO::FETCH_ASSOC); + if ($row) { + $profile = array_merge($profile, array_filter($row, fn($v) => $v !== null)); + if ($crypto && !empty($profile['contact_phone'])) { + $profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: ''; + } + } + + $children = []; + $stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC'); + $stmt?->execute(['id' => $userId]); + $childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; + foreach ($childrenRaw as $c) { + if ($crypto) { + $c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: ''; + $c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: ''; + } + $children[] = $c; + } + + $eventsUpcoming = []; + $eventsPast = []; + $editEvent = null; + $stmt = $pdo?->prepare( + 'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng, + (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count + FROM events e + WHERE e.created_by = :id AND e.starts_at >= NOW() + ORDER BY e.starts_at ASC' + ); + $stmt?->execute(['id' => $userId]); + $eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; + + $stmt = $pdo?->prepare( + 'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status, + (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count + FROM events e + WHERE e.created_by = :id AND e.starts_at < NOW() + ORDER BY e.starts_at DESC' + ); + $stmt?->execute(['id' => $userId]); + $eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; + + if (isset($_GET['edit_event'])) { + $editId = (int)$_GET['edit_event']; + $stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1'); + $stmt?->execute(['id' => $editId, 'uid' => $userId]); + $editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent'); + } + + private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array + { + $parts = array_filter([ + $street ?: null, + $zip ?: null, + $city ?: null, + $region ?: null, + ]); + if (!$parts) { + return [null, null, null]; + } + + $query = implode(', ', $parts); + $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ + 'format' => 'jsonv2', + 'limit' => 1, + 'q' => $query, + ]); + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n", + 'timeout' => 6, + ], + ]); + + $resp = @file_get_contents($url, false, $ctx); + if ($resp === false) { + return [null, null, null]; + } + $json = json_decode($resp, true); + if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) { + return [null, null, null]; + } + $addr = $json[0]['address'] ?? []; + $regionGuess = $addr['city_district'] ?? $addr['suburb'] ?? $addr['state'] ?? $addr['county'] ?? $addr['region'] ?? $addr['state_district'] ?? null; + return [round((float)$json[0]['lat'], 7), round((float)$json[0]['lon'], 7), $regionGuess]; + } +} diff --git a/src/App/App.php b/src/App/App.php index e5553d8..2679583 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -1 +1,50 @@ -request = new Request(); + $this->session = new SessionManager($config); + $this->assets = new Assets($config); + $this->i18n = new I18n($config, 'de'); + $this->flash = new Flash($this->session); + $this->pdo = Database::createPdo($config); + } + + public static function init(Config $config): self + { + if (self::$instance === null) { + self::$instance = new self($config); + } + return self::$instance; + } + + public static function get(): self + { + if (self::$instance === null) { + throw new \RuntimeException('App not initialized. Call App::init() in bootstrap.'); + } + return self::$instance; + } + + public function config(): Config { return $this->config; } + public function request(): Request { return $this->request; } + public function session(): SessionManager { return $this->session; } + public function assets(): Assets { return $this->assets; } + public function i18n(): I18n { return $this->i18n; } + public function flash(): Flash { return $this->flash; } + public function pdo(): ?\PDO { return $this->pdo; } +} diff --git a/src/App/Assets.php b/src/App/Assets.php index e5553d8..6bd655b 100644 --- a/src/App/Assets.php +++ b/src/App/Assets.php @@ -1 +1,44 @@ -config->assetVersion; + $this->styles[] = [ + 'href' => $href, + 'priority' => $priority, + 'version' => $version, + ]; + } + + public function addScript(string $src, string $pos = 'footer', bool $defer = true, bool $async = false, ?string $version = null): void + { + $version ??= $this->config->assetVersion; + $row = [ + 'src' => $src, + 'defer' => $defer, + 'async' => $async, + 'version' => $version, + ]; + + if ($pos === 'header') { + $this->scriptsHeader[] = $row; + } else { + $this->scriptsFooter[] = $row; + } + } + + public function styles(): array { return $this->styles; } + public function headerScripts(): array { return $this->scriptsHeader; } + public function footerScripts(): array { return $this->scriptsFooter; } +} diff --git a/src/App/Auth.php b/src/App/Auth.php index e5553d8..5af7d63 100644 --- a/src/App/Auth.php +++ b/src/App/Auth.php @@ -1 +1,217 @@ -app->pdo(); + if (!$pdo) { + throw new \RuntimeException('Database connection not available.'); + } + return $pdo; + } + + public function register(string $displayName, string $email, string $password): int + { + $pdo = $this->pdo(); + $email = strtolower(trim($email)); + $displayName = trim($displayName); + + if ($displayName === '' || $email === '' || $password === '') { + throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.'); + } + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1'); + $stmt->execute(['email' => $email]); + if ($stmt->fetchColumn()) { + throw new \RuntimeException('E-Mail ist bereits registriert.'); + } + + $hash = password_hash($password, PASSWORD_ARGON2ID); + $stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())'); + $stmt->execute([ + 'email' => $email, + 'pw' => $hash, + 'status' => 'pending', + ]); + $userId = (int)$pdo->lastInsertId(); + + $stmt = $pdo->prepare('INSERT INTO user_profiles (user_id, display_name, share_level, children_visibility, created_at, updated_at) VALUES (:uid, :name, :share, :childvis, NOW(), NOW())'); + $stmt->execute([ + 'uid' => $userId, + 'name' => $displayName, + 'share' => 'basic', + 'childvis' => 'hidden', + ]); + + $pdo->commit(); + return $userId; + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + } + + public function createVerifyCode(int $userId, string $email): string + { + $pdo = $this->pdo(); + $code = $this->generateCode(6); + $hash = hash('sha256', $code); + + $pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'verify']); + $stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 48 HOUR), NOW())'); + $stmt->execute([ + 'uid' => $userId, + 'type' => 'verify', + 'code' => $code, + 'hash' => $hash, + ]); + + return $code; + } + + public function verifyCode(string $email, string $code): int + { + $pdo = $this->pdo(); + $email = strtolower(trim($email)); + $hash = hash('sha256', $code); + + $stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1'); + $stmt->execute(['type' => 'verify', 'email' => $email]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + if (!$row || !hash_equals((string)$row['token_hash'], $hash)) { + throw new \RuntimeException('Code ist ungültig oder abgelaufen.'); + } + + $userId = (int)$row['id']; + $tid = (int)$row['tid']; + + $pdo->beginTransaction(); + try { + $pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]); + $pdo->prepare('UPDATE users SET status = :st, email_verified_at = NOW() WHERE id = :id')->execute(['st' => 'active', 'id' => $userId]); + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + + return $userId; + } + + public function createResetCode(string $email): array + { + $pdo = $this->pdo(); + $email = strtolower(trim($email)); + + $stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1'); + $stmt->execute(['email' => $email]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + if (!$row) { + throw new \RuntimeException('E-Mail ist nicht registriert.'); + } + + $userId = (int)$row['id']; + $displayName = (string)($row['display_name'] ?? $email); + $code = $this->generateCode(6); + $hash = hash('sha256', $code); + + $pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'reset']); + $stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 2 HOUR), NOW())'); + $stmt->execute([ + 'uid' => $userId, + 'type' => 'reset', + 'code' => $code, + 'hash' => $hash, + ]); + + return ['user_id' => $userId, 'code' => $code, 'display_name' => $displayName]; + } + + public function verifyResetCode(string $email, string $code): int + { + $pdo = $this->pdo(); + $email = strtolower(trim($email)); + $hash = hash('sha256', $code); + + $stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1'); + $stmt->execute(['type' => 'reset', 'email' => $email]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + if (!$row || !hash_equals((string)$row['token_hash'], $hash)) { + throw new \RuntimeException('Code ist ungültig oder abgelaufen.'); + } + + $userId = (int)$row['id']; + $tid = (int)$row['tid']; + + $pdo->beginTransaction(); + try { + $pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]); + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + + return $userId; + } + + public function resetPassword(int $userId, string $password): void + { + $pdo = $this->pdo(); + if ($password === '' || strlen($password) < 8) { + throw new \InvalidArgumentException('Passwort muss mindestens 8 Zeichen haben.'); + } + $hash = password_hash($password, PASSWORD_ARGON2ID); + $stmt = $pdo->prepare('UPDATE users SET password_hash = :pw, status = :status, updated_at = NOW() WHERE id = :id'); + $stmt->execute([ + 'pw' => $hash, + 'status' => 'active', + 'id' => $userId, + ]); + } + + private function generateCode(int $len = 6): string + { + $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + $out = ''; + for ($i = 0; $i < $len; $i++) { + $out .= $chars[random_int(0, strlen($chars) - 1)]; + } + return $out; + } + + public function login(string $email, string $password): array + { + $pdo = $this->pdo(); + $email = strtolower(trim($email)); + + $stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1'); + $stmt->execute(['email' => $email]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + + if (!$row) { + throw new \RuntimeException('E-Mail oder Passwort ist falsch.'); + } + if (!password_verify($password, (string)$row['password_hash'])) { + throw new \RuntimeException('E-Mail oder Passwort ist falsch.'); + } + + $userId = (int)$row['id']; + $status = (string)$row['status']; + + if ($status === 'active') { + $upd = $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id'); + $upd->execute(['id' => $userId]); + } + + return ['id' => $userId, 'status' => $status]; + } +} diff --git a/src/App/Community.php b/src/App/Community.php index e5553d8..38661fe 100644 --- a/src/App/Community.php +++ b/src/App/Community.php @@ -1 +1,196 @@ -pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)'); + $stmt->execute([ + ':uid' => $userId, + ':title' => trim($title), + ':body' => trim($body), + ]); + } + + public function createPost(int $userId, int $threadId, string $body): void + { + $stmt = $this->pdo->prepare('INSERT INTO forum_posts (thread_id, user_id, body) VALUES (:tid, :uid, :body)'); + $stmt->execute([ + ':tid' => $threadId, + ':uid' => $userId, + ':body' => trim($body), + ]); + } + + public function searchThreads(string $query, int $limit = 50): array + { + $conditions = []; + $params = []; + $tokens = array_filter(preg_split('/\s+/', trim($query)) ?: [], fn($t) => $t !== ''); + $i = 0; + foreach ($tokens as $tok) { + $ph1 = ':t' . $i . 'a'; + $ph2 = ':t' . $i . 'b'; + $conditions[] = "(ft.title LIKE $ph1 OR ft.body LIKE $ph2)"; + $params[$ph1] = '%' . $tok . '%'; + $params[$ph2] = '%' . $tok . '%'; + $i++; + } + $where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : ''; + + $sql = "SELECT ft.id, ft.title, ft.body, ft.created_at, + u.id as uid, u.created_at as user_created, + p.display_name, + (SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers, + (SELECT COUNT(*) FROM forum_posts fp2 WHERE fp2.user_id = u.id) + + (SELECT COUNT(*) FROM forum_threads ft2 WHERE ft2.user_id = u.id) AS user_posts + FROM forum_threads ft + JOIN users u ON u.id = ft.user_id + LEFT JOIN user_profiles p ON p.user_id = u.id + WHERE 1=1 $where + ORDER BY ft.created_at DESC + LIMIT :lim"; + $stmt = $this->pdo->prepare($sql); + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v, \PDO::PARAM_STR); + } + $stmt->bindValue(':lim', $limit, \PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + public function listThreads(int $limit = 50): array + { + return $this->searchThreads('', $limit); + } + + public function getThread(int $id): ?array + { + $stmt = $this->pdo->prepare('SELECT ft.*, p.display_name FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id WHERE ft.id = :id'); + $stmt->execute([':id' => $id]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + return $row ?: null; + } + + public function listPosts(int $threadId): array + { + $stmt = $this->pdo->prepare('SELECT fp.*, p.display_name FROM forum_posts fp LEFT JOIN user_profiles p ON p.user_id = fp.user_id WHERE fp.thread_id = :id ORDER BY fp.created_at ASC'); + $stmt->execute([':id' => $threadId]); + return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + public function computePoints(int $userId): float + { + // Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points + $stmt = $this->pdo->prepare('SELECT total FROM user_points_totals WHERE user_id = :uid'); + $stmt->execute([':uid' => $userId]); + $total = $stmt->fetchColumn(); + if ($total !== false && $total !== null) { + return (float)$total; + } + + $stmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid'); + $stmt->execute([':uid' => $userId]); + return (float)$stmt->fetchColumn(); + } + + /** + * Vergibt Punkte persistent und berücksichtigt Caps/Bonis gemäß config actions. + */ + public function addPoints(int $userId, string $group, string $key, array $meta = []): float + { + $actions = $this->config['actions'][$group][$key] ?? null; + if (!$actions || empty($actions['points'])) { + return 0.0; + } + $basePoints = (float)$actions['points']; + + // Boni (einfacher first-Check) + $bonusPoints = 0.0; + if (!empty($actions['bonuses'])) { + if (isset($actions['bonuses']['first'])) { + $bonusPoints += (float)$actions['bonuses']['first']; + } + if (isset($actions['bonuses']['first_helpful_5']) && isset($meta['helpful_count']) && (int)$meta['helpful_count'] >= 5) { + $bonusPoints += (float)$actions['bonuses']['first_helpful_5']; + } + } + + $amount = $basePoints + $bonusPoints; + if ($amount <= 0) { + return 0.0; + } + + $caps = $actions['caps'] ?? []; + $capDaily = $caps['daily'] ?? null; + $capTotal = $caps['total'] ?? null; + + $todayStart = (new \DateTimeImmutable('today'))->format('Y-m-d 00:00:00'); + $todayEnd = (new \DateTimeImmutable('today'))->format('Y-m-d 23:59:59'); + + $actionKey = $group . '.' . $key; + + if ($capDaily !== null) { + $stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action AND created_at BETWEEN :s AND :e"); + $stmt->execute([ + ':uid' => $userId, + ':action' => $actionKey, + ':s' => $todayStart, + ':e' => $todayEnd, + ]); + $usedToday = (float)$stmt->fetchColumn(); + $remaining = max(0.0, (float)$capDaily - $usedToday); + if ($remaining <= 0) { + return 0.0; + } + $amount = min($amount, $remaining); + } + + if ($capTotal !== null) { + $stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action"); + $stmt->execute([':uid' => $userId, ':action' => $actionKey]); + $usedTotal = (float)$stmt->fetchColumn(); + $remaining = max(0.0, (float)$capTotal - $usedTotal); + if ($remaining <= 0) { + return 0.0; + } + $amount = min($amount, $remaining); + } + + $stmt = $this->pdo->prepare('INSERT INTO user_points (user_id, action, amount, meta) VALUES (:uid, :action, :amount, :meta)'); + $stmt->execute([ + ':uid' => $userId, + ':action' => $actionKey, + ':amount' => $amount, + ':meta' => $meta ? json_encode($meta) : null, + ]); + + $stmt = $this->pdo->prepare('INSERT INTO user_points_totals (user_id, total) VALUES (:uid, :amt) ON DUPLICATE KEY UPDATE total = total + VALUES(total)'); + $stmt->execute([':uid' => $userId, ':amt' => $amount]); + + return $amount; + } + + public function membershipLevel(float $points): array + { + $levels = $this->config['levels'] ?? []; + usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0)); + foreach ($levels as $lvl) { + if ($points >= (float)($lvl['min'] ?? 0)) { + return [ + 'label' => $lvl['label'] ?? 'New Daddy', + 'icon' => $lvl['icon'] ?? '', + ]; + } + } + $fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => '']; + return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? '']; + } +} diff --git a/src/App/Config.php b/src/App/Config.php index e5553d8..4f02ea5 100644 --- a/src/App/Config.php +++ b/src/App/Config.php @@ -1 +1,62 @@ -env === 'staging') { + return $this->prefix . '_stg_'; + } + return $this->prefix . '_'; + } + + public function cookieDomain(): string + { + // Leading dot for subdomain-wide cookies + return '.' . ltrim($this->primaryDomain, '.'); + } +} diff --git a/src/App/Crypto.php b/src/App/Crypto.php index e5553d8..c5d9dfa 100644 --- a/src/App/Crypto.php +++ b/src/App/Crypto.php @@ -1 +1,68 @@ -= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) { + $raw = $decoded; + } elseif (ctype_xdigit($raw) && strlen($raw) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES * 2) { + $raw = hex2bin($raw); + } + + if (strlen($raw) < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) { + throw new \RuntimeException('DATA_KEY invalid length'); + } + + $this->key = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES); + } + + public function encrypt(string $plaintext): string + { + if ($plaintext === '') { + return ''; + } + $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); + $cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key); + return base64_encode($nonce . $cipher); + } + + public function decrypt(?string $blob): string + { + if ($blob === null || $blob === '') { + return ''; + } + $raw = base64_decode($blob, true); + if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) { + return ''; + } + $nonce = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); + $cipher = substr($raw, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); + try { + $plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, '', $nonce, $this->key); + return $plain === false ? '' : $plain; + } catch (\Throwable) { + return ''; + } + } +} diff --git a/src/App/Database.php b/src/App/Database.php index e5553d8..27f9071 100644 --- a/src/App/Database.php +++ b/src/App/Database.php @@ -1 +1,123 @@ -dbEnabled) { + return null; + } + + $db = $config->db; + $driver = (string)($db['driver'] ?? ''); + + if ($driver === '') { + throw new \RuntimeException('DB enabled but config/db.php missing "driver"'); + } + + $dsn = match ($driver) { + 'mysql' => self::buildMysqlDsn($db), + 'pgsql' => self::buildPgsqlDsn($db), + 'sqlite' => self::buildSqliteDsn($db), + default => throw new \RuntimeException('Unsupported PDO driver: ' . $driver), + }; + + try { + $pdo = new \PDO( + $dsn, + // sqlite braucht user/pass nicht, PDO ignoriert es aber; wir geben leer zurück + (string)($db['user'] ?? ''), + (string)($db['password'] ?? ''), + (array)($db['options'] ?? []) + ); + + // Optional: PostgreSQL schema/search_path setzen + if ($driver === 'pgsql' && !empty($db['schema'])) { + // Minimaler Schutz gegen Injection über schema + $schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db['schema']); + if ($schema !== '') { + $pdo->exec('SET search_path TO ' . $schema); + } + } + + return $pdo; + } catch (\PDOException $e) { + // In Prod würdest du loggen; hier minimal + http_response_code(500); + echo 'Database connection error.'; + exit; + } + } + + private static function buildMysqlDsn(array $db): string + { + if (empty($db['dbname'])) { + throw new \RuntimeException('MySQL config missing "dbname"'); + } + + $charset = (string)($db['charset'] ?? 'utf8mb4'); + + // Unix socket takes precedence + if (!empty($db['unix_socket'])) { + return sprintf( + 'mysql:unix_socket=%s;dbname=%s;charset=%s', + (string)$db['unix_socket'], + (string)$db['dbname'], + $charset + ); + } + + $host = (string)($db['host'] ?? 'localhost'); + $port = (int)($db['port'] ?? 3306); + + return sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $host, + $port, + (string)$db['dbname'], + $charset + ); + } + + private static function buildPgsqlDsn(array $db): string + { + if (empty($db['dbname'])) { + throw new \RuntimeException('PostgreSQL config missing "dbname"'); + } + + $host = (string)($db['host'] ?? 'localhost'); + $port = (int)($db['port'] ?? 5432); + + // Hinweis: charset gehört bei pgsql nicht in den DSN + return sprintf( + 'pgsql:host=%s;port=%d;dbname=%s', + $host, + $port, + (string)$db['dbname'] + ); + } + + private static function buildSqliteDsn(array $db): string + { + // SQLite kann :memory: oder einen Pfad nutzen + $path = (string)($db['path'] ?? ''); + + if ($path === '') { + // Default: Memory-DB + $path = ':memory:'; + } + + // Wenn es ein Pfad ist, stelle sicher, dass das Verzeichnis existiert. + if ($path !== ':memory:') { + $dir = \dirname($path); + if ($dir && !is_dir($dir)) { + @mkdir($dir, 0775, true); + } + } + + return 'sqlite:' . $path; + } +} diff --git a/src/App/Flash.php b/src/App/Flash.php index e5553d8..39efc44 100644 --- a/src/App/Flash.php +++ b/src/App/Flash.php @@ -1 +1,33 @@ -session->start(); + $_SESSION['flash'] = [ + 'type' => $type, + 'message' => $message, + ]; + } + + public function get(): ?array + { + $this->session->start(); + if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) { + return null; + } + $f = $_SESSION['flash']; + unset($_SESSION['flash']); + + return [ + 'type' => (string)($f['type'] ?? 'info'), + 'message' => (string)($f['message'] ?? ''), + ]; + } +} diff --git a/src/App/I18n.php b/src/App/I18n.php index e5553d8..4a34f6e 100644 --- a/src/App/I18n.php +++ b/src/App/I18n.php @@ -1 +1,59 @@ -fallback = [ + 'common' => [ + 'title' => '3D-Druck Materialmatrix', + 'intro' => 'Schnell prüfen, welche Filamente auf welchen Druckern laufen.', + ], + 'cta' => [ + 'primary' => 'Weiter', + ], + ]; + + $this->current = $this->fallback; + } + + private function traverse(array $data, string $key): mixed + { + $node = $data; + foreach (explode('.', $key) as $seg) { + if (!is_array($node) || !array_key_exists($seg, $node)) { + return null; + } + $node = $node[$seg]; + } + return $node; + } + + public function get(string $key, $default = '', array $vars = []): string + { + $val = $this->traverse($this->current, $key); + if ($val === null) { + $val = $this->traverse($this->fallback, $key); + } + if (!is_string($val)) { + $val = (string)($default ?? ''); + } + + // Built-ins + $val = str_replace('{year}', date('Y'), $val); + $val = str_replace('{{primary_url}}', $this->config->primaryUrl, $val); + + foreach ($vars as $k => $v) { + $val = str_replace('{' . $k . '}', (string)$v, $val); + $val = str_replace('{{' . $k . '}}', (string)$v, $val); + } + return $val; + } +} diff --git a/src/App/Mailer.php b/src/App/Mailer.php index e5553d8..a150e4e 100644 --- a/src/App/Mailer.php +++ b/src/App/Mailer.php @@ -1 +1,352 @@ -logFile = $base . '/debug/mailer_debug.log'; + } + + private function log(string $msg, array $ctx = []): void + { + if (!defined('APP_DEBUG') || APP_DEBUG !== true) { + return; + } + $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg; + if ($ctx) { + $line .= ' ' . json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + $line .= "\n"; + $dir = dirname($this->logFile); + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + // For clarity keep only the latest run in the log: truncate once per request + if ($this->logCleared === false) { + @file_put_contents($this->logFile, ''); + $this->logCleared = true; + } + @file_put_contents($this->logFile, $line, FILE_APPEND); + } + + private function templates(): array + { + $env = $this->app->config()->env; + $root = __DIR__ . '/../../config/emailtemplates.php'; + $envPath = __DIR__ . "/../../config/{$env}/emailtemplates.php"; + $file = is_file($root) ? $root : $envPath; + $emailtemplates = []; + if (is_file($file)) { + /** @noinspection PhpIncludeInspection */ + include $file; // populates $emailtemplates variable from included file + } + return is_array($emailtemplates ?? null) ? $emailtemplates : []; + } + + private function renderTemplate(string $key, array $vars): array + { + $templates = $this->templates(); + $id = $templates[$key] ?? $key; + $this->log('template_resolved_id', ['key' => $key, 'id' => $id]); + + $apiBase = getenv('EMAILTEMPLATE_API_BASE') ?: ''; + $apiToken = getenv('EMAILTEMPLATE_API_TOKEN') ?: ''; + + if ($apiBase && $apiToken) { + $payload = [ + 'template' => $id, + 'placeholders' => $vars, + ]; + $payload['token'] = $apiToken; + + $payloadForLog = $payload; + $payloadForLog['token'] = '[hidden length ' . strlen((string)$apiToken) . ']'; + $this->log('template_api_request_payload', [ + 'url' => $apiBase, + 'payload' => $payloadForLog, + ]); + + $this->log('template_api_request', ['template' => $id, 'placeholders' => array_keys($vars)]); + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'timeout' => 15, + 'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ], + ]); + $resp = @file_get_contents($apiBase, false, $ctx); + if ($resp !== false) { + $status = null; + if (isset($http_response_header) && is_array($http_response_header)) { + foreach ($http_response_header as $hdr) { + if (preg_match('~^HTTP/\\S+\\s+(\\d+)~i', $hdr, $m)) { + $status = (int)$m[1]; + break; + } + } + } + $this->log('template_api_response_raw', [ + 'status' => $status, + 'body' => $resp, + ]); + $decoded = json_decode($resp, true); + if (is_array($decoded) && !empty($decoded['ok']) && !empty($decoded['html'])) { + $this->log('template_api_success', ['template' => $id, 'subject' => $decoded['subject'] ?? null, 'html_len' => strlen((string)$decoded['html'])]); + return [ + 'id' => $id, + 'subject' => $decoded['subject'] ?? '3D-Druck Materialmatrix', + 'html' => $decoded['html'], + ]; + } + $this->log('template_api_response_invalid', ['template' => $id, 'response' => $decoded]); + } else { + $this->log('template_api_unreachable', ['template' => $id]); + } + } + + // Fallback: einfacher Text + $subject = '3D-Druck Materialmatrix'; + $body = $id; + foreach ($vars as $k => $v) { + $body = str_replace(['{' . $k . '}', '{{' . $k . '}}'], (string)$v, $body); + } + $this->log('template_fallback_used', ['template' => $id]); + return [ + 'id' => $id, + 'subject' => $subject, + 'html' => nl2br(htmlspecialchars($body, ENT_QUOTES)), + ]; + } + + public function sendTemplate(string $templateKey, string $to, array $vars = []): void + { + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid recipient email.'); + } + + $tpl = $this->renderTemplate($templateKey, $vars); + $resolvedId = $tpl['id'] ?? $templateKey; + $subject = $tpl['subject'] ?? '3D-Druck Materialmatrix'; + $html = $tpl['html'] ?? ''; + + $this->log('mail_rendered_template', [ + 'template_key' => $templateKey, + 'template_id' => $resolvedId, + 'subject' => $subject, + 'html_len' => strlen((string)$html), + 'html_preview' => substr((string)$html, 0, 200), + ]); + + $transport = getenv('MAIL_TRANSPORT') ?: 'mail'; + $fromEmail = getenv('MAIL_FROM') ?: 'no-reply@' . $this->app->config()->primaryDomain; + $fromName = getenv('MAIL_FROM_NAME') ?: '3D-Druck Materialmatrix'; + + $this->log('mail_send_start', [ + 'template_key' => $templateKey, + 'template_id' => $resolvedId, + 'to' => $to, + 'transport' => $transport, + 'subject' => $subject + ]); + if ($transport === 'smtp') { + $this->sendSmtp($to, $subject, $html, $fromEmail, $fromName); + } else { + $this->sendMailFn($to, $subject, $html, $fromEmail, $fromName); + } + } + + private function sendMailFn(string $to, string $subject, string $html, string $from, string $fromName): void + { + $headers = []; + if ($from) { + $headers[] = 'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $from); + } + $headers[] = 'Content-Type: text/html; charset=utf-8'; + $ok = @mail($to, $subject, $html, implode("\r\n", $headers)); + $this->log('mail_mail_transport', ['to' => $to, 'ok' => $ok]); + if (!$ok) { + throw new \RuntimeException('mail() transport failed'); + } + } + + private function sendSmtp(string $to, string $subject, string $html, string $from, string $fromName): void + { + $host = getenv('SMTP_HOST') ?: ''; + $port = (int)(getenv('SMTP_PORT') ?: 587); + $user = getenv('SMTP_USER') ?: ''; + $pass = getenv('SMTP_PASS') ?: ''; + $secure = strtolower(getenv('SMTP_SECURE') ?: 'tls'); // tls|ssl|none + + if (!$host) { + $this->log('mail_smtp_missing_host_fallback_mail', []); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + + $proto = ($secure === 'ssl') ? 'ssl://' : ''; + $fp = @stream_socket_client($proto . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT); + if (!$fp) { + $this->log('mail_smtp_connect_failed', ['host' => $host, 'port' => $port, 'error' => $errstr]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + stream_set_timeout($fp, 15); + + $transcript = []; + $readResponse = function (array $expectCodes = [], string $label = 'read') use ($fp, &$transcript): array { + $lines = []; + while (($line = fgets($fp, 515)) !== false) { + $line = rtrim($line, "\r\n"); + $lines[] = $line; + $transcript[] = $label . ': ' . $line; + // SMTP multiline: code + '-' means more lines, code + ' ' means end + if (strlen($line) >= 4 && $line[3] === ' ') { + break; + } + } + $code = 0; + if ($lines) { + $code = (int)substr($lines[0], 0, 3); + } + return [ + 'ok' => !$expectCodes || in_array($code, $expectCodes, true), + 'code' => $code, + 'lines' => $lines, + ]; + }; + $write = function (string $cmd, string $label = 'write', bool $mask = false) use ($fp, &$transcript): void { + $transcript[] = $label . ': ' . ($mask ? '[omitted]' : $cmd); + fwrite($fp, $cmd . "\r\n"); + }; + + $resp = $readResponse([220], 'greeting'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_greeting_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + + $write('EHLO ' . $this->app->config()->primaryDomain); + $resp = $readResponse([250], 'ehlo'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_ehlo_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + + if ($secure === 'tls') { + $write('STARTTLS'); + $resp = $readResponse([220], 'starttls'); + if (!$resp['ok'] || !stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + fclose($fp); + $this->log('mail_smtp_starttls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $write('EHLO ' . $this->app->config()->primaryDomain); + $resp = $readResponse([250], 'ehlo-tls'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_ehlo_tls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + } + + if ($user !== '') { + $write('AUTH LOGIN'); + $resp = $readResponse([334], 'auth-login'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_auth_login_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $write(base64_encode($user), 'auth-user', true); + $resp = $readResponse([334], 'auth-user'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_auth_user_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $write(base64_encode($pass), 'auth-pass', true); + $resp = $readResponse([235], 'auth-pass'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_auth_pass_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + } + + $write('MAIL FROM: <' . $from . '>'); + $resp = $readResponse([250], 'mail-from'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_mailfrom_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + + $write('RCPT TO: <' . $to . '>'); + $resp = $readResponse([250, 251], 'rcpt-to'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_rcpt_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + + $write('DATA'); + $resp = $readResponse([354], 'data-start'); + if (!$resp['ok']) { + fclose($fp); + $this->log('mail_smtp_data_start_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + + $msg = "From: {$fromName} <{$from}>\r\n"; + $msg .= "To: <{$to}>\r\n"; + $msg .= "Subject: {$subject}\r\n"; + $msg .= "MIME-Version: 1.0\r\n"; + $msg .= "Content-Type: text/html; charset=utf-8\r\n\r\n"; + $msg .= $html . "\r\n.\r\n"; + $write($msg, 'data', false); + $resp = $readResponse([250], 'data-end'); + + $write('QUIT'); + $readResponse([221], 'quit'); + fclose($fp); + + $this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]); + + if (!$resp['ok']) { + $this->log('mail_smtp_send_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $this->log('mail_smtp_sent', ['to' => $to, 'host' => $host, 'port' => $port, 'secure' => $secure]); + } +} diff --git a/src/App/Repository/MaterialMatrixRepository.php b/src/App/Repository/MaterialMatrixRepository.php new file mode 100644 index 0000000..5b199a7 --- /dev/null +++ b/src/App/Repository/MaterialMatrixRepository.php @@ -0,0 +1,44 @@ +pdo->query('SELECT * FROM printers WHERE is_active = 1 ORDER BY name'); + return $stmt->fetchAll(); + } + + public function listActiveMaterials(): array + { + $stmt = $this->pdo->query('SELECT * FROM materials WHERE is_active = 1 ORDER BY code'); + return $stmt->fetchAll(); + } + + public function getPrinterById(int $printerId): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM printers WHERE id = :id'); + $stmt->execute(['id' => $printerId]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function listMaterialsForPrinter(int $printerId): array + { + $sql = 'SELECT m.*, pms.support_level, pms.partial_reason, pms.extra_info + FROM materials m + LEFT JOIN printer_material_support pms + ON pms.material_id = m.id AND pms.printer_id = :pid + WHERE m.is_active = 1 + ORDER BY m.code'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute(['pid' => $printerId]); + return $stmt->fetchAll(); + } +} diff --git a/src/App/Request.php b/src/App/Request.php index e5553d8..f4a78ea 100644 --- a/src/App/Request.php +++ b/src/App/Request.php @@ -1 +1,47 @@ -scheme() . '://' . $this->host(); + } + + public function path(): string + { + return (string) strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?'); + } + + public function currentUrl(bool $withQuery = true): string + { + $base = $this->baseUrl(); + $uri = (string)($_SERVER['REQUEST_URI'] ?? '/'); + if ($withQuery) { + return $base . $uri; + } + return $base . (string) strtok($uri, '?'); + } +} diff --git a/src/App/Search.php b/src/App/Search.php index e5553d8..1d29d8b 100644 --- a/src/App/Search.php +++ b/src/App/Search.php @@ -1 +1,241 @@ -pdo) return []; + + $q = trim($query); + $hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']); + if ($q === '' && !$hasGeo) return []; + + $tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== ''); + if (!$tokens) { + $tokens = [$q]; + } + // Nur Tokens ab 3 Zeichen für fuzzy/LIKE berücksichtigen + $tokens = array_values(array_filter($tokens, fn($t) => mb_strlen($t) >= 3)); + if (!$tokens && !$hasGeo) return []; + + $conditions = []; + $bindTokens = []; + $i = 0; + foreach ($tokens as $tok) { + $tok = trim($tok); + if ($tok === '') continue; + // LIKE + phonetic (SOUNDEX) to allow partial and typo-tolerant matches + $conditions[] = "(title LIKE CONCAT('%', ?, '%') OR teaser_public LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%') OR city LIKE CONCAT('%', ?, '%') OR region LIKE CONCAT('%', ?, '%') OR zip LIKE CONCAT('%', ?, '%') OR SOUNDEX(title)=SOUNDEX(?) OR SOUNDEX(teaser_public)=SOUNDEX(?) OR SOUNDEX(description)=SOUNDEX(?) OR SOUNDEX(city)=SOUNDEX(?) OR SOUNDEX(region)=SOUNDEX(?))"; + // LIKE bindings + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $bindTokens[] = $tok; + // SOUNDEX bindings + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $bindTokens[] = $tok; + $i++; + } + + $whereParts = [ + "starts_at >= NOW()", + "status != 'cancelled'", + ]; + if ($conditions) { + // "OR" so that partial matches across tokens are allowed + $whereParts[] = '(' . implode(' OR ', $conditions) . ')'; + } + + $distanceFiltering = false; + $bind = []; + if ($hasGeo) { + $sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, + (6371 * ACOS(LEAST(1, + COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) + + SIN(RADIANS(?)) * SIN(RADIANS(lat)) + ))) AS distance_km"; + $lat = (float)$geo['lat']; + $lng = (float)$geo['lng']; + $radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0; + $distanceFiltering = true; + + $latRange = $radius / 111.0; + $lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180))); + $whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)"; + $whereParts[] = "(lat BETWEEN ? AND ?)"; + $whereParts[] = "(lng BETWEEN ? AND ?)"; + // Haversine params (order must match SQL): first three + $bind[] = $lat; // COS(RADIANS(?)) + $bind[] = $lng; // COS(RADIANS(lng) - RADIANS(?)) + $bind[] = $lat; // SIN(RADIANS(?)) + // THEN token binds + $bind = array_merge($bind, $bindTokens); + // Bounding box + $bind[] = $lat - $latRange; + $bind[] = $lat + $latRange; + $bind[] = $lng - $lngRange; + $bind[] = $lng + $lngRange; + // Radius for HAVING + $bind[] = $radius; + } else { + $sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, NULL AS distance_km"; + $bind = $bindTokens; + } + + $where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : ''; + $sql .= " FROM events $where"; + if ($distanceFiltering) { + $sql .= " HAVING distance_km <= ?"; + $sql .= " ORDER BY distance_km ASC, starts_at ASC"; + } else { + $sql .= " ORDER BY starts_at ASC"; + } + $limit = (int)$limit; + $sql .= " LIMIT {$limit}"; + + $stmt = $this->pdo->prepare($sql); + try { + $stmt->execute($bind); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + if (!$hasGeo) { + foreach ($rows as &$r) { + unset($r['distance_km']); + } + unset($r); + } + // Fuzzy filter: allow slight typos (Levenshtein <= 1 or 2) + if ($tokens) { + $rows = array_values(array_filter($rows, function ($row) use ($tokens) { + $haystack = strtolower( + ($row['title'] ?? '') . ' ' . + ($row['teaser_public'] ?? '') . ' ' . + ($row['description'] ?? '') . ' ' . + ($row['city'] ?? '') . ' ' . + ($row['region'] ?? '') + ); + $words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: []; + foreach ($tokens as $tok) { + $t = strtolower($tok); + if ($t === '') continue; + if (str_contains($haystack, $t)) { + return true; + } + foreach ($words as $w) { + if ($w === '') continue; + $dist = levenshtein($t, $w); + if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) { + return true; + } + } + } + return false; + })); + } + // Fallback: wenn keine Treffer, erneut ohne Token-Filter laden und nur fuzzy filtern + if (!$rows && $tokens) { + $wherePartsFallback = [ + "starts_at >= NOW()", + "status != 'cancelled'", + ]; + $bindFb = []; + $sqlFb = ''; + if ($hasGeo) { + $sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, + (6371 * ACOS(LEAST(1, + COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) + + SIN(RADIANS(?)) * SIN(RADIANS(lat)) + ))) AS distance_km"; + $lat = (float)$geo['lat']; + $lng = (float)$geo['lng']; + $radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0; + $latRange = $radius / 111.0; + $lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180))); + $wherePartsFallback[] = "(lat IS NOT NULL AND lng IS NOT NULL)"; + $wherePartsFallback[] = "(lat BETWEEN ? AND ?)"; + $wherePartsFallback[] = "(lng BETWEEN ? AND ?)"; + $bindFb[] = $lat; + $bindFb[] = $lng; + $bindFb[] = $lat; + $bindFb[] = $lat - $latRange; + $bindFb[] = $lat + $latRange; + $bindFb[] = $lng - $lngRange; + $bindFb[] = $lng + $lngRange; + $bindFb[] = $radius; + $havingFb = true; + } else { + $sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, 1 AS distance_km"; + $havingFb = false; + } + $whereFb = $wherePartsFallback ? ('WHERE ' . implode(' AND ', $wherePartsFallback)) : ''; + $sqlFb .= " FROM events $whereFb"; + if ($havingFb) { + $sqlFb .= " HAVING distance_km <= ?"; + $sqlFb .= " ORDER BY distance_km ASC, starts_at ASC"; + } else { + $sqlFb .= " ORDER BY starts_at ASC"; + } + $sqlFb .= " LIMIT {$limit}"; + $stmtFb = $this->pdo->prepare($sqlFb); + $stmtFb->execute($bindFb); + $rowsFb = $stmtFb->fetchAll(\PDO::FETCH_ASSOC) ?: []; + if ($rowsFb) { + $rows = array_values(array_filter($rowsFb, function ($row) use ($tokens) { + $haystack = strtolower( + ($row['title'] ?? '') . ' ' . + ($row['teaser_public'] ?? '') . ' ' . + ($row['description'] ?? '') . ' ' . + ($row['city'] ?? '') . ' ' . + ($row['region'] ?? '') + ); + $words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: []; + foreach ($tokens as $tok) { + $t = strtolower($tok); + if ($t === '') continue; + if (str_contains($haystack, $t)) { + return true; + } + foreach ($words as $w) { + if ($w === '') continue; + $dist = levenshtein($t, $w); + if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) { + return true; + } + } + } + return false; + })); + } + } + if (defined('APP_ENV') && APP_ENV === 'staging') { + $logOk = [ + 'status' => 'ok', + 'sql' => $sql, + 'bind' => $bind, + 'count' => count($rows), + 'fallback' => ($rows ? 'primary' : 'fallback'), + ]; + @file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logOk, true)); + } + return $rows; + } catch (\PDOException $e) { + // Log into /debug/search_debug.log and continue with empty results + $logErr = [ + 'error' => $e->getMessage(), + 'sql' => $sql, + 'bind' => $bind, + ]; + @file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logErr, true)); + return []; + } + } +} diff --git a/src/App/SessionManager.php b/src/App/SessionManager.php index e5553d8..5e57034 100644 --- a/src/App/SessionManager.php +++ b/src/App/SessionManager.php @@ -1 +1,71 @@ -cookiePrefix(); + $this->sessionCookieName = $prefix . 'session'; + $this->clientCookieName = $prefix . 'client'; + } + + public function start(): void + { + if (PHP_SAPI === 'cli') { + return; + } + if (session_status() !== PHP_SESSION_NONE) { + return; + } + + session_name($this->sessionCookieName); + + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'); + + session_set_cookie_params([ + 'lifetime' => 0, + 'path' => '/', + 'domain' => $this->config->cookieDomain(), + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + + session_start(); + } + + public function ensureClientId(int $lifetimeSeconds = 31536000): string + { + if (PHP_SAPI === 'cli') { + return 'cli'; + } + + $id = $_COOKIE[$this->clientCookieName] ?? null; + if (!is_string($id) || !preg_match('/^[a-f0-9]{64}$/', $id)) { + $id = bin2hex(random_bytes(32)); + + $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'); + + setcookie($this->clientCookieName, $id, [ + 'expires' => time() + $lifetimeSeconds, + 'path' => '/', + 'domain' => $this->config->cookieDomain(), + 'secure' => $secure, + 'httponly' => false, // accessible to JS if needed + 'samesite' => 'Lax', + ]); + + $_COOKIE[$this->clientCookieName] = $id; + } + + return $id; + } +} diff --git a/src/README.md b/src/README.md deleted file mode 100644 index b493024..0000000 --- a/src/README.md +++ /dev/null @@ -1 +0,0 @@ -# src\n\nTODO: Core backend/business logic.\n diff --git a/src/config.php b/src/config.php deleted file mode 100755 index 7ea6e0a..0000000 --- a/src/config.php +++ /dev/null @@ -1,2 +0,0 @@ -i18n()->get($key, $default, $vars); +} + +function tpl(string $file, string $type = 'structure', string $site = 'main'): void +{ + $base = __DIR__ . '/../partials/'; + + // very small validation + foreach ([$file, $type, $site] as $v) { + if (preg_match('/[^a-zA-Z0-9_\-]/', $v)) { + echo ""; + return; + } + } + + if ($type === 'landing') { + $path = $base . "landing/$site/$file.php"; + } else { + $path = $base . "structure/$file.php"; + } + + if (file_exists($path)) { + include $path; + } else { + echo ""; + } +} + +function app_primary_domain(): string +{ + if (defined('APP_DOMAIN_PRIMARY')) { + return APP_DOMAIN_PRIMARY; + } + if (defined('APP_DOMAIN_NAME')) { + return APP_DOMAIN_NAME; + } + return $_SERVER['HTTP_HOST'] ?? ''; +} + +function app_fakecheck_domain(): string +{ + if (defined('APP_DOMAIN_FAKECHECK')) { + return APP_DOMAIN_FAKECHECK; + } + return app_primary_domain(); +} + +function asset_styles(): void +{ + $styles = app()->assets()->styles(); + + // simple priority order + $order = ['early' => 0, 'normal' => 1, 'late' => 2]; + usort($styles, fn($a,$b) => ($order[$a['priority']] ?? 1) <=> ($order[$b['priority']] ?? 1)); + + foreach ($styles as $s) { + $href = $s['href']; + $v = $s['version']; + if ($v !== null && $v !== '') { + $sep = (str_contains($href, '?') ? '&' : '?'); + $href = $href . $sep . 'v=' . rawurlencode((string)$v); + } + echo '' . "\n"; + } +} + +function asset_scripts(string $pos = 'footer'): void +{ + $scripts = ($pos === 'header') ? app()->assets()->headerScripts() : app()->assets()->footerScripts(); + + foreach ($scripts as $s) { + $src = $s['src']; + $v = $s['version']; + if ($v !== null && $v !== '') { + $sep = (str_contains($src, '?') ? '&' : '?'); + $src = $src . $sep . 'v=' . rawurlencode((string)$v); + } + + $attrs = ''; + if (!empty($s['defer'])) $attrs .= ' defer'; + if (!empty($s['async'])) $attrs .= ' async'; + + echo '' . "\n"; + } +} + +function redirect(string $path): void +{ + header('Location: ' . $path, true, 303); + exit; +} diff --git a/tools/.gitkeep b/tools/.gitkeep index 8b13789..e69de29 100644 --- a/tools/.gitkeep +++ b/tools/.gitkeep @@ -1 +0,0 @@ - diff --git a/tools/db.php b/tools/db.php deleted file mode 100644 index 7cac6f8..0000000 --- a/tools/db.php +++ /dev/null @@ -1,21 +0,0 @@ - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]; - - return new PDO($dsn, $cfg['db_user'], $cfg['db_pass'], $options); -} diff --git a/tools/printers.php b/tools/printers.php deleted file mode 100644 index 674261c..0000000 --- a/tools/printers.php +++ /dev/null @@ -1,6 +0,0 @@ -query("SELECT * FROM printers WHERE is_active = 1 ORDER BY name"); - return $stmt->fetchAll(); -}