From 811d3df7a055f551e975612613066414551a231f Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Fri, 26 Dec 2025 02:44:15 +0100 Subject: [PATCH] yxxx --- config/config.php | 2 +- config/prod/emailtemplates.php | 7 + config/staging/emailtemplates.php | 7 + partials/landing/account/dashboard.php | 276 +++++++++++++++++++------ partials/landing/account/login.php | 15 +- partials/landing/account/register.php | 12 +- partials/landing/account/verify.php | 91 ++++++++ public/page/verify.php | 4 + schema.sql | 3 +- src/App/Auth.php | 73 ++++++- src/App/Mailer.php | 174 ++++++++++++++++ 11 files changed, 589 insertions(+), 75 deletions(-) create mode 100644 config/prod/emailtemplates.php create mode 100644 config/staging/emailtemplates.php create mode 100644 partials/landing/account/verify.php create mode 100644 public/page/verify.php create mode 100644 src/App/Mailer.php diff --git a/config/config.php b/config/config.php index 0a395dc..cdc97a2 100644 --- a/config/config.php +++ b/config/config.php @@ -10,7 +10,7 @@ error_reporting(E_ALL); $appEnvFromEnv = getenv('APP_ENV') ?: 'prod'; $envDir = rtrim(__DIR__, '/\\') . '/' . $appEnvFromEnv; -foreach (['domaindata.php','settings.php'] as $cfgFile) { +foreach (['domaindata.php','settings.php','emailtemplates.php'] as $cfgFile) { $rootPath = __DIR__ . '/' . $cfgFile; $envPath = $envDir . '/' . $cfgFile; diff --git a/config/prod/emailtemplates.php b/config/prod/emailtemplates.php new file mode 100644 index 0000000..95079c3 --- /dev/null +++ b/config/prod/emailtemplates.php @@ -0,0 +1,7 @@ + 'Neues Template neue Logik', + 'registration_welcome' => 'Template 1', + 'registration_resend_code' => 'template mit block', + 'password_reset' => 'template mit block', +]; \ No newline at end of file diff --git a/config/staging/emailtemplates.php b/config/staging/emailtemplates.php new file mode 100644 index 0000000..95079c3 --- /dev/null +++ b/config/staging/emailtemplates.php @@ -0,0 +1,7 @@ + 'Neues Template neue Logik', + 'registration_welcome' => 'Template 1', + 'registration_resend_code' => 'template mit block', + 'password_reset' => 'template mit block', +]; \ No newline at end of file diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index f3f8080..12c8eb6 100644 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -1,117 +1,273 @@ pdo(); +$flash = $app->flash()->get(); +$userId = (int)$_SESSION['user_id']; +$error = ''; +$info = ''; + +// POST Aktionen +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + try { + if ($action === 'profile') { + $stmt = $pdo->prepare('UPDATE user_profiles SET display_name=:name, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, updated_at=NOW() WHERE user_id=:id'); + $stmt->execute([ + 'name' => trim((string)$_POST['display_name']), + 'zip' => trim((string)$_POST['zip']), + 'city' => trim((string)$_POST['city']), + 'prof' => trim((string)$_POST['profession']), + 'langs' => trim((string)$_POST['languages']), + 'about' => trim((string)$_POST['about']), + 'id' => $userId, + ]); + $info = 'Profil gespeichert.'; + } elseif ($action === 'child_add') { + $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' => trim((string)$_POST['first_name']), + 'note' => trim((string)$_POST['note']), + ]); + $info = 'Kind hinzugefügt.'; + } elseif ($action === 'event_add') { + $stmt = $pdo->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :zip, :city, :region, NULL, NULL, :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']), + 'zip' => trim((string)$_POST['zip']), + 'city' => trim((string)$_POST['city']), + 'region' => trim((string)$_POST['region']), + 'start' => $_POST['starts_at'] ?? null, + 'allow' => isset($_POST['allow_kids']) ? 1 : 0, + 'vis' => $_POST['visibility'] ?? 'public', + 'status' => 'published', + ]); + $info = 'Event gespeichert.'; + } + } catch (Throwable $e) { + $error = $e->getMessage(); + } +} + +// Daten laden +$profile = [ + 'display_name' => '', + 'zip' => '', + 'city' => '', + 'profession' => '', + 'languages' => '', + 'about' => '', +]; +$stmt = $pdo->prepare('SELECT u.email, u.status, p.display_name, p.zip, p.city, p.profession, p.languages, p.about 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)); +} + +$children = []; +$stmt = $pdo->prepare('SELECT id, encrypted_first_name AS first_name, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC'); +$stmt->execute(['id' => $userId]); +$children = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + +$events = []; +$stmt = $pdo->prepare('SELECT id, title, teaser_public, starts_at, city, visibility FROM events WHERE created_by = :id ORDER BY starts_at DESC'); +$stmt->execute(['id' => $userId]); +$events = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; ?>

Mitgliederbereich

-

Hallo, Papa!

+

Hallo, !

Verwalte dein Profil, Kinder, Events und Teilnahmen.

-
- Profil - Kinder - Events - Teilnahmen -
-
+ +
+ + +
+ + +
Fehler:
+ +
+ +
+
Profil

Deine Angaben

-
    -
  • Anzeigename: Papa Alex
  • -
  • Ort: Berlin, 10437
  • -
  • Beruf: Entwickler
  • -
  • Sprachen: Deutsch, Englisch
  • -
-
- - -
+
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
Kinder

Deine Kids

-
    -
  • Emma, 4 Jahre (weiblich)
  • -
  • Max, 7 Jahre (männlich)
  • -
-
- - -
-
- -
-
Teilnahmen
-

Nächste Termine

-
    -
  • Spielplatzrunde – 10.08., Prenzlauer Berg
  • -
  • Erste Hilfe Kids – 20.08., Köln
  • -
-
- -
+ +

Noch keine Kinder eingetragen.

+ +
    + +
  • ,
  • + +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
Deine Events
+ +

Noch keine Events angelegt.

+ +
    + +
  • , ()
  • + +
+ +
+
+ +
Eigenes Event

Neuen Termin erstellen

+
- +
- - + +
+
+ + +
- +
- - + +
-
- - -
- - + +
- - +
+
+
+
+ + +
+
+ +
+
diff --git a/partials/landing/account/login.php b/partials/landing/account/login.php index bc1a127..d0fd6bb 100644 --- a/partials/landing/account/login.php +++ b/partials/landing/account/login.php @@ -15,8 +15,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $password = (string)($_POST['password'] ?? ''); try { $auth = new \App\Auth($app); - $userId = $auth->login($email, $password); - $_SESSION['user_id'] = $userId; + $res = $auth->login($email, $password); + if ($res['status'] === 'pending') { + $code = $auth->createVerifyCode($res['id'], $email); + $mailer = new \App\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) { diff --git a/partials/landing/account/register.php b/partials/landing/account/register.php index a4aef53..1fed310 100644 --- a/partials/landing/account/register.php +++ b/partials/landing/account/register.php @@ -24,9 +24,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { try { $auth = new \App\Auth($app); $userId = $auth->register($displayName, $email, $password); - $_SESSION['user_id'] = $userId; - $app->flash()->set('success', 'Willkommen! Dein Account wurde erstellt.'); - redirect('/dashboard'); + $code = $auth->createVerifyCode($userId, $email); + $mailer = new \App\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(); } diff --git a/partials/landing/account/verify.php b/partials/landing/account/verify.php new file mode 100644 index 0000000..d61b9ee --- /dev/null +++ b/partials/landing/account/verify.php @@ -0,0 +1,91 @@ +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 \App\Auth($app); + $mailer = new \App\Mailer($app); + + if ($action === 'resend') { + try { + $stmt = $app->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(); + } + } +} +?> +
+
+
+
Bestätigung
+

Registrierung bestätigen

+

Wir haben dir einen 6-stelligen Code gesendet. Bitte gib ihn hier ein.

+ +
+ + +
+ + +
Fehler:
+ +
+
+ + +
+
+ + +
+ + +
+
+ + + +
+
+ + +
+
diff --git a/public/page/verify.php b/public/page/verify.php new file mode 100644 index 0000000..94c8399 --- /dev/null +++ b/public/page/verify.php @@ -0,0 +1,4 @@ +execute([ 'email' => $email, 'pw' => $hash, - 'status' => 'active', + 'status' => 'pending', ]); $userId = (int)$pdo->lastInsertId(); @@ -59,7 +59,64 @@ final class Auth } } - public function login(string $email, string $password): int + 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; + } + + 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)); @@ -71,18 +128,18 @@ final class Auth if (!$row) { throw new \RuntimeException('E-Mail oder Passwort ist falsch.'); } - if ($row['status'] !== 'active') { - throw new \RuntimeException('Account ist nicht aktiv.'); - } 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']; - $upd = $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id'); - $upd->execute(['id' => $userId]); + if ($status === 'active') { + $upd = $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id'); + $upd->execute(['id' => $userId]); + } - return $userId; + return ['id' => $userId, 'status' => $status]; } } diff --git a/src/App/Mailer.php b/src/App/Mailer.php new file mode 100644 index 0000000..af0df3d --- /dev/null +++ b/src/App/Mailer.php @@ -0,0 +1,174 @@ +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 */ + $emailtemplates = include $file; + } + return is_array($emailtemplates ?? null) ? $emailtemplates : []; + } + + private function renderTemplate(string $key, array $vars): array + { + $templates = $this->templates(); + $id = $templates[$key] ?? $key; + + $apiBase = getenv('EMAILTEMPLATE_API_BASE') ?: ''; + $apiToken = getenv('EMAILTEMPLATE_API_TOKEN') ?: ''; + + if ($apiBase && $apiToken) { + $payload = [ + 'template' => $id, + 'placeholders' => $vars, + ]; + $payload['token'] = $apiToken; + $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) { + $decoded = json_decode($resp, true); + if (is_array($decoded) && !empty($decoded['ok']) && !empty($decoded['html'])) { + return [ + 'subject' => $decoded['subject'] ?? 'Papakind', + 'html' => $decoded['html'], + ]; + } + } + } + + // Fallback: einfacher Text + $subject = 'Papa-Kind-Treff'; + $body = $id; + foreach ($vars as $k => $v) { + $body = str_replace(['{' . $k . '}', '{{' . $k . '}}'], (string)$v, $body); + } + return [ + '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); + $subject = $tpl['subject'] ?? 'Papa-Kind-Treff'; + $html = $tpl['html'] ?? ''; + + $transport = getenv('MAIL_TRANSPORT') ?: 'mail'; + $fromEmail = getenv('MAIL_FROM') ?: 'no-reply@' . $this->app->config()->primaryDomain; + $fromName = getenv('MAIL_FROM_NAME') ?: 'Papa-Kind-Treff'; + + 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'; + if (!@mail($to, $subject, $html, implode("\r\n", $headers))) { + 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->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->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + stream_set_timeout($fp, 15); + + $read = function () use ($fp) { + return fgets($fp, 515); + }; + $write = function (string $cmd) use ($fp) { + fwrite($fp, $cmd . "\r\n"); + }; + + $read(); + $write('EHLO ' . $this->app->config()->primaryDomain); + $read(); + + if ($secure === 'tls') { + $write('STARTTLS'); + $read(); + if (!stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { + fclose($fp); + $this->sendMailFn($to, $subject, $html, $from, $fromName); + return; + } + $write('EHLO ' . $this->app->config()->primaryDomain); + $read(); + } + + if ($user !== '') { + $write('AUTH LOGIN'); + $read(); + $write(base64_encode($user)); + $read(); + $write(base64_encode($pass)); + $read(); + } + + $write('MAIL FROM: <' . $from . '>'); + $read(); + $write('RCPT TO: <' . $to . '>'); + $read(); + $write('DATA'); + $read(); + + $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); + $read(); + $write('QUIT'); + fclose($fp); + } +}