diff --git a/api/.gitkeep b/api/.gitkeep new file mode 100755 index 0000000..e69de29 diff --git a/config/config.php b/config/config.php new file mode 100755 index 0000000..0a395dc --- /dev/null +++ b/config/config.php @@ -0,0 +1,53 @@ +session()->start(); +$clientId = $app->session()->ensureClientId(); + +// Optionally expose a single global for templates if desired +$GLOBALS['client_id'] = $clientId; diff --git a/config/prod/db.php b/config/prod/db.php new file mode 100755 index 0000000..65c16d1 --- /dev/null +++ b/config/prod/db.php @@ -0,0 +1,96 @@ + '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 new file mode 100755 index 0000000..4479e5d --- /dev/null +++ b/config/prod/domaindata.php @@ -0,0 +1,12 @@ + '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 new file mode 100755 index 0000000..4479e5d --- /dev/null +++ b/config/staging/domaindata.php @@ -0,0 +1,12 @@ +assets()->addStyle('/assets/app.css', 'early'); +$app->assets()->addScript('/assets/app.js', 'footer', true); + +$flash = $app->flash()->get(); +?> +
+
env: config()->env, ENT_QUOTES) ?>
+

+ +

+ + +
+ : + +
+ + +
+
+

Runtime

+
Current URL: request()->currentUrl(), ENT_QUOTES) ?>
+
Client-ID:
+
+
+

Actions

+
+ +
+

Flash uses SessionManager, no direct globals.

+
+
+
diff --git a/partials/structure/layout_end.php b/partials/structure/layout_end.php new file mode 100755 index 0000000..1871bd7 --- /dev/null +++ b/partials/structure/layout_end.php @@ -0,0 +1,3 @@ + + + diff --git a/partials/structure/layout_start.php b/partials/structure/layout_start.php new file mode 100755 index 0000000..7d419fc --- /dev/null +++ b/partials/structure/layout_start.php @@ -0,0 +1,22 @@ + + + + + + + <?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?> + + + + + diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100755 index 0000000..e69de29 diff --git a/public/.htaccess b/public/.htaccess new file mode 100755 index 0000000..8ca4b75 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,32 @@ +# ------------------------------------------------- +# 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/assets/app.css b/public/assets/app.css new file mode 100755 index 0000000..1867f66 --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1 @@ +/* minimal css placeholder */ diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100755 index 0000000..bbe8027 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1 @@ +console.log('mini example loaded'); diff --git a/public/index.php b/public/index.php new file mode 100755 index 0000000..04c65ea --- /dev/null +++ b/public/index.php @@ -0,0 +1,88 @@ +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 new file mode 100755 index 0000000..bfb18a5 --- /dev/null +++ b/src/App/App.php @@ -0,0 +1,50 @@ +request = new Request(); + $this->session = new SessionManager($config); + $this->assets = new Assets($config); + $this->i18n = new I18n($config, 'en'); + $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 new file mode 100755 index 0000000..6bd655b --- /dev/null +++ b/src/App/Assets.php @@ -0,0 +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 new file mode 100755 index 0000000..5af7d63 --- /dev/null +++ b/src/App/Auth.php @@ -0,0 +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 new file mode 100755 index 0000000..38661fe --- /dev/null +++ b/src/App/Community.php @@ -0,0 +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 new file mode 100755 index 0000000..4f02ea5 --- /dev/null +++ b/src/App/Config.php @@ -0,0 +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 new file mode 100755 index 0000000..c5d9dfa --- /dev/null +++ b/src/App/Crypto.php @@ -0,0 +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 new file mode 100755 index 0000000..27f9071 --- /dev/null +++ b/src/App/Database.php @@ -0,0 +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 new file mode 100755 index 0000000..39efc44 --- /dev/null +++ b/src/App/Flash.php @@ -0,0 +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 new file mode 100755 index 0000000..44a9b60 --- /dev/null +++ b/src/App/I18n.php @@ -0,0 +1,59 @@ +fallback = [ + 'common' => [ + 'title' => 'Papa-Kind-Treff', + 'intro' => 'Väter vernetzen sich für Treffen mit und ohne Kinder.', + ], + '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 new file mode 100755 index 0000000..26bce31 --- /dev/null +++ b/src/App/Mailer.php @@ -0,0 +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'] ?? 'Papa-Kind-Treff', + '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 = 'Papa-Kind-Treff'; + $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'] ?? 'Papa-Kind-Treff'; + $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') ?: 'Papa-Kind-Treff'; + + $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/Request.php b/src/App/Request.php new file mode 100755 index 0000000..f4a78ea --- /dev/null +++ b/src/App/Request.php @@ -0,0 +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 new file mode 100755 index 0000000..1d29d8b --- /dev/null +++ b/src/App/Search.php @@ -0,0 +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 new file mode 100755 index 0000000..5e57034 --- /dev/null +++ b/src/App/SessionManager.php @@ -0,0 +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/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100755 index 0000000..579b30b --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,87 @@ + +pdo->prepare( + 'SELECT id, email, name, created_at + FROM users + WHERE email = :email + LIMIT 1' + ); + $stmt->execute(['email' => $email]); + + $row = $stmt->fetch(); + return $row ?: null; + } + + public function create(string $email, string $name): int + { + // DB-agnostischer INSERT + $stmt = $this->pdo->prepare( + 'INSERT INTO users (email, name, created_at) + VALUES (:email, :name, :created_at)' + ); + $stmt->execute([ + 'email' => $email, + 'name' => $name, + 'created_at' => date('Y-m-d H:i:s'), + ]); + + // ID-Ermittlung: DB-spezifische Unterschiede gekapselt + return $this->lastInsertIdSafe(); + } + + public function listLatest(int $limit = 10): array + { + // LIMIT ist bei mysql/pgsql/sqlite gleich + $stmt = $this->pdo->prepare( + 'SELECT id, email, name, created_at + FROM users + ORDER BY id DESC + LIMIT :limit' + ); + + // SQLite/MySQL/PG verstehen ints hier, aber PDO braucht oft PARAM_INT + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } + + private function driver(): string + { + return (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + } + + private function lastInsertIdSafe(): int + { + $driver = $this->driver(); + + if ($driver === 'pgsql') { + // Option A: Sequenzname (wenn du ihn kennst) + // Standard wäre oft users_id_seq, kann aber anders heißen. + // Wenn du "GENERATED ... AS IDENTITY" nutzt, ist RETURNING meist die bessere Option. + $id = $this->pdo->lastInsertId(); + if ($id !== '') { + return (int)$id; + } + + // Fallback: versuche typische Sequenz + $id = $this->pdo->lastInsertId('users_id_seq'); + return (int)$id; + } + + // mysql + sqlite + return (int)$this->pdo->lastInsertId(); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100755 index 0000000..acae534 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,103 @@ +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 new file mode 100755 index 0000000..e69de29