commit basic
This commit is contained in:
398
src/App/AccountPages.php
Executable file
398
src/App/AccountPages.php
Executable file
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class AccountPages
|
||||
{
|
||||
public static function register(App $app): array
|
||||
{
|
||||
$flash = $app->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];
|
||||
}
|
||||
}
|
||||
50
src/App/App.php
Executable file
50
src/App/App.php
Executable file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class App
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
private Request $request;
|
||||
private SessionManager $session;
|
||||
private Assets $assets;
|
||||
private I18n $i18n;
|
||||
private Flash $flash;
|
||||
private ?\PDO $pdo;
|
||||
|
||||
private function __construct(private Config $config)
|
||||
{
|
||||
$this->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; }
|
||||
}
|
||||
44
src/App/Assets.php
Executable file
44
src/App/Assets.php
Executable file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Assets
|
||||
{
|
||||
private array $styles = [];
|
||||
private array $scriptsHeader = [];
|
||||
private array $scriptsFooter = [];
|
||||
|
||||
public function __construct(private Config $config) {}
|
||||
|
||||
public function addStyle(string $href, string $priority = 'normal', ?string $version = null): void
|
||||
{
|
||||
$version ??= $this->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; }
|
||||
}
|
||||
217
src/App/Auth.php
Executable file
217
src/App/Auth.php
Executable file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(private App $app) {}
|
||||
|
||||
private function pdo(): \PDO
|
||||
{
|
||||
$pdo = $this->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];
|
||||
}
|
||||
}
|
||||
196
src/App/Community.php
Executable file
196
src/App/Community.php
Executable file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Community
|
||||
{
|
||||
public function __construct(private \PDO $pdo, private array $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function createThread(int $userId, string $title, string $body): void
|
||||
{
|
||||
$stmt = $this->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'] ?? ''];
|
||||
}
|
||||
}
|
||||
62
src/App/Config.php
Executable file
62
src/App/Config.php
Executable file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Config
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $env,
|
||||
public readonly string $prefix,
|
||||
public readonly string $primaryDomain,
|
||||
public readonly string $primaryUrl,
|
||||
public readonly string $apiBase,
|
||||
public readonly string $assetVersion,
|
||||
public readonly bool $dbEnabled,
|
||||
public readonly array $db,
|
||||
) {}
|
||||
|
||||
public static function fromPhpConstants(string $configDir): self
|
||||
{
|
||||
// config.php defines these constants.
|
||||
$env = defined('APP_ENV') ? (string) APP_ENV : 'prod';
|
||||
$prefix = defined('APP_PREFIX') ? (string) APP_PREFIX : 'app';
|
||||
$primaryDom = defined('APP_DOMAIN_PRIMARY') ? (string) APP_DOMAIN_PRIMARY : 'example.test';
|
||||
$primaryUrl = defined('APP_URL_PRIMARY') ? (string) APP_URL_PRIMARY : 'https://example.test';
|
||||
$apiBase = defined('APP_API_BASE') ? (string) APP_API_BASE : ($primaryUrl . '/api');
|
||||
$assetVersion = defined('ASSET_VERSION') ? (string) ASSET_VERSION : '';
|
||||
|
||||
$dbEnabled = defined('APP_DB_ENABLED') ? (bool) APP_DB_ENABLED : false;
|
||||
|
||||
$dbFileRoot = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . 'db.php';
|
||||
$dbFileEnv = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . $env . DIRECTORY_SEPARATOR . 'db.php';
|
||||
$dbFile = file_exists($dbFileRoot) ? $dbFileRoot : (file_exists($dbFileEnv) ? $dbFileEnv : null);
|
||||
$db = $dbFile ? (array) require $dbFile : [];
|
||||
|
||||
return new self(
|
||||
env: $env,
|
||||
prefix: $prefix,
|
||||
primaryDomain: $primaryDom,
|
||||
primaryUrl: rtrim($primaryUrl, '/'),
|
||||
apiBase: rtrim($apiBase, '/'),
|
||||
assetVersion: $assetVersion,
|
||||
dbEnabled: $dbEnabled,
|
||||
db: $db
|
||||
);
|
||||
}
|
||||
|
||||
public function cookiePrefix(): string
|
||||
{
|
||||
// Example: add suffix for staging
|
||||
if ($this->env === 'staging') {
|
||||
return $this->prefix . '_stg_';
|
||||
}
|
||||
return $this->prefix . '_';
|
||||
}
|
||||
|
||||
public function cookieDomain(): string
|
||||
{
|
||||
// Leading dot for subdomain-wide cookies
|
||||
return '.' . ltrim($this->primaryDomain, '.');
|
||||
}
|
||||
}
|
||||
68
src/App/Crypto.php
Executable file
68
src/App/Crypto.php
Executable file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Crypto
|
||||
{
|
||||
private string $key;
|
||||
|
||||
public function __construct(Config $config)
|
||||
{
|
||||
if (!extension_loaded('sodium')) {
|
||||
throw new \RuntimeException('libsodium extension not available');
|
||||
}
|
||||
|
||||
$raw = getenv('DATA_KEY') ?: '';
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
throw new \RuntimeException('DATA_KEY env not set');
|
||||
}
|
||||
|
||||
// base64?
|
||||
if (str_starts_with($raw, 'base64:')) {
|
||||
$raw = substr($raw, 7);
|
||||
}
|
||||
$decoded = base64_decode($raw, true);
|
||||
if ($decoded !== false && strlen($decoded) >= 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 '';
|
||||
}
|
||||
}
|
||||
}
|
||||
123
src/App/Database.php
Executable file
123
src/App/Database.php
Executable file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Database
|
||||
{
|
||||
public static function createPdo(Config $config): ?\PDO
|
||||
{
|
||||
if (!$config->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;
|
||||
}
|
||||
}
|
||||
33
src/App/Flash.php
Executable file
33
src/App/Flash.php
Executable file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Flash
|
||||
{
|
||||
public function __construct(private SessionManager $session) {}
|
||||
|
||||
public function set(string $type, string $message): void
|
||||
{
|
||||
$this->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'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
59
src/App/I18n.php
Executable file
59
src/App/I18n.php
Executable file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class I18n
|
||||
{
|
||||
private array $current = [];
|
||||
private array $fallback = [];
|
||||
|
||||
public function __construct(private Config $config, private string $lang = 'en')
|
||||
{
|
||||
// Minimal example translations (normally load JSON/PHP arrays from disk)
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
352
src/App/Mailer.php
Executable file
352
src/App/Mailer.php
Executable file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Mailer
|
||||
{
|
||||
private string $logFile;
|
||||
private bool $logCleared = false;
|
||||
|
||||
public function __construct(private App $app)
|
||||
{
|
||||
$base = dirname(__DIR__, 2);
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
47
src/App/Request.php
Executable file
47
src/App/Request.php
Executable file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Request
|
||||
{
|
||||
public function scheme(): string
|
||||
{
|
||||
// Proxy / LB
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||
$proto = strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||
if ($proto === 'https' || $proto === 'http') {
|
||||
return $proto;
|
||||
}
|
||||
}
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return 'https';
|
||||
}
|
||||
return 'http';
|
||||
}
|
||||
|
||||
public function host(): string
|
||||
{
|
||||
return (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return $this->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, '?');
|
||||
}
|
||||
}
|
||||
241
src/App/Search.php
Executable file
241
src/App/Search.php
Executable file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Search
|
||||
{
|
||||
public function __construct(private ?\PDO $pdo) {}
|
||||
|
||||
public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array
|
||||
{
|
||||
if (!$this->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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/App/SessionManager.php
Executable file
71
src/App/SessionManager.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class SessionManager
|
||||
{
|
||||
private string $sessionCookieName;
|
||||
private string $clientCookieName;
|
||||
|
||||
public function __construct(private Config $config)
|
||||
{
|
||||
$prefix = $config->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user