dudi/api.php
Simon Kühn fd473f00af Initial commit: Dudi habit tracker
Symfony 8 SPA with Doctrine ORM, Symfony Security, vanilla JS frontend.
Migrated from plain PHP (delight-im/auth + raw SQL) to full Symfony stack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 15:40:57 +02:00

294 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('error_log', '/tmp/zt.log');
set_exception_handler(function(\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage(), 'file' => basename($e->getFile()), 'line' => $e->getLine()]);
exit;
});
require_once __DIR__ . '/include/db.php';
require_once __DIR__ . '/include/mailer.php';
header('Content-Type: application/json; charset=utf-8');
// CORS für gleiche Domain nicht nötig; bei separaten Dev-Domains ggf. ergänzen
$method = $_SERVER['REQUEST_METHOD'];
$segments = explode('/', trim($_GET['_path'] ?? '', '/'));
$resource = $segments[0] ?? '';
$resourceId = isset($segments[1]) && $segments[1] !== '' ? (int)$segments[1] : null;
$body = json_decode(file_get_contents('php://input'), true) ?? [];
function json_out(array $data, int $status = 200): never {
http_response_code($status);
echo json_encode($data);
exit;
}
function require_auth(\Delight\Auth\Auth $auth): int {
if (!$auth->isLoggedIn()) {
json_out(['error' => 'Unauthorized'], 401);
}
return $auth->getUserId();
}
// ── POST /api/login ──────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'login') {
$email = trim($body['email'] ?? '');
$pass = $body['password'] ?? '';
try {
$auth->login($email, $pass, 86400);
json_out(['ok' => true, 'email' => $auth->getEmail()]);
} catch (\Delight\Auth\InvalidEmailException
| \Delight\Auth\InvalidPasswordException
| \Delight\Auth\UserNotFound $e) {
json_out(['error' => 'Falsche E-Mail oder Passwort'], 401);
} catch (\Delight\Auth\TooManyRequestsException $e) {
json_out(['error' => 'Zu viele Versuche, bitte warten'], 429);
}
}
// ── POST /api/logout ─────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'logout') {
$auth->logOut();
json_out(['ok' => true]);
}
// ── GET /api/me ───────────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'me') {
if ($auth->isLoggedIn()) {
$name = $conn->fetchOne('SELECT username FROM users WHERE id = ?', [$auth->getUserId()]);
json_out(['ok' => true, 'email' => $auth->getEmail(), 'id' => $auth->getUserId(), 'name' => $name ?: '']);
}
json_out(['ok' => false], 401);
}
// ── PATCH /api/me ─────────────────────────────────────────────────────────────
if ($method === 'PATCH' && $resource === 'me') {
$uid = require_auth($auth);
$name = trim($body['name'] ?? '');
if (!$name) json_out(['error' => 'Name fehlt'], 400);
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $uid]);
json_out(['ok' => true, 'name' => $name]);
}
// ── POST /api/register ────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'register') {
$email = trim($body['email'] ?? '');
$pass = $body['password'] ?? '';
$token = trim($body['token'] ?? '');
if (!$email || !$pass || !$token) {
json_out(['error' => 'Fehlende Felder'], 400);
}
// Token validieren
$invite = $conn->fetchAssociative(
'SELECT * FROM invites WHERE token = ? AND used_by IS NULL AND expires_at > NOW()',
[$token]
);
if (!$invite) {
json_out(['error' => 'Ungültiger oder abgelaufener Einladungslink'], 400);
}
$name = trim($body['name'] ?? '');
try {
$userId = $auth->register($email, $pass);
} catch (\Delight\Auth\InvalidEmailException $e) {
json_out(['error' => 'Ungültige E-Mail'], 400);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
} catch (\Delight\Auth\UserAlreadyExistsException $e) {
json_out(['error' => 'E-Mail bereits registriert'], 409);
}
// Token als verwendet markieren
$conn->executeStatement(
'UPDATE invites SET used_by = ?, used_at = NOW() WHERE token = ?',
[$userId, $token]
);
if ($name) {
$conn->executeStatement('UPDATE users SET username = ? WHERE id = ?', [$name, $userId]);
}
// Direkt einloggen
$auth->login($email, $pass, 86400);
json_out(['ok' => true, 'email' => $auth->getEmail(), 'name' => $name]);
}
// ── POST /api/reset-request ──────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'reset-request') {
$email = trim($body['email'] ?? '');
if (!$email) json_out(['error' => 'E-Mail fehlt'], 400);
try {
$auth->requestPasswordReset($email, function (string $selector, string $token) use ($email) {
$url = APP_URL . '/?reset_selector=' . rawurlencode($selector) . '&reset_token=' . rawurlencode($token);
$text = "Hallo,\n\nklicke auf den folgenden Link um dein Passwort zurückzusetzen (gültig 24h):\n\n$url\n\nFalls du das nicht angefragt hast, ignoriere diese E-Mail.";
sendMail($email, 'Durchzieh-Dienst Passwort zurücksetzen', $text);
});
} catch (\Delight\Auth\InvalidEmailException $e) {
// Keinen Hinweis geben ob die E-Mail existiert
} catch (\Exception $e) {
json_out(['error' => 'Mail konnte nicht gesendet werden'], 500);
}
json_out(['ok' => true]); // immer ok, auch wenn E-Mail unbekannt
}
// ── POST /api/reset-password ──────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'reset-password') {
$selector = $body['selector'] ?? '';
$token = $body['token'] ?? '';
$pass = $body['password'] ?? '';
if (!$selector || !$token || !$pass) json_out(['error' => 'Fehlende Felder'], 400);
try {
$auth->resetPassword($selector, $token, $pass);
json_out(['ok' => true]);
} catch (\Delight\Auth\InvalidSelectorTokenPairException $e) {
json_out(['error' => 'Ungültiger oder abgelaufener Reset-Link'], 400);
} catch (\Delight\Auth\TokenExpiredException $e) {
json_out(['error' => 'Reset-Link abgelaufen, bitte neu anfordern'], 400);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Passwort zu kurz (min. 8 Zeichen)'], 400);
}
}
// ── POST /api/change-password ─────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'change-password') {
$uid = require_auth($auth);
$oldPass = $body['old_password'] ?? '';
$newPass = $body['new_password'] ?? '';
if (!$oldPass || !$newPass) json_out(['error' => 'Fehlende Felder'], 400);
try {
$auth->changePassword($oldPass, $newPass);
json_out(['ok' => true]);
} catch (\Delight\Auth\NotLoggedInException $e) {
json_out(['error' => 'Nicht eingeloggt'], 401);
} catch (\Delight\Auth\InvalidPasswordException $e) {
json_out(['error' => 'Neues Passwort zu kurz (min. 8 Zeichen)'], 400);
} catch (\Delight\Auth\TooManyRequestsException $e) {
json_out(['error' => 'Zu viele Versuche'], 429);
}
}
// ── POST /api/invite ─────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'invite') {
$uid = require_auth($auth);
$token = bin2hex(random_bytes(32));
$note = trim($body['note'] ?? '');
$conn->executeStatement(
'INSERT INTO invites (token, note, created_by, expires_at) VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL 7 DAY))',
[$token, $note ?: null, $uid]
);
json_out(['url' => APP_URL . '/?invite=' . $token]);
}
// ── GET /api/invites ──────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'invites') {
$uid = require_auth($auth);
$rows = $conn->fetchAllAssociative(
'SELECT i.token, i.created_at, i.expires_at, i.used_at, i.note, u.email AS used_by_email
FROM invites i
LEFT JOIN users u ON u.id = i.used_by
WHERE i.created_by = ?
ORDER BY i.created_at DESC',
[$uid]
);
$result = array_map(function ($r) {
if ($r['used_by_email'] !== null) {
$status = 'used';
} elseif (strtotime($r['expires_at']) < time()) {
$status = 'expired';
} else {
$status = 'pending';
}
return [
'url' => $status === 'pending' ? APP_URL . '/?invite=' . $r['token'] : null,
'created_at' => $r['created_at'],
'expires_at' => $r['expires_at'],
'used_at' => $r['used_at'],
'note' => $r['note'],
'used_by_email' => $r['used_by_email'],
'status' => $status,
];
}, $rows);
json_out($result);
}
// ── GET /api/goals ────────────────────────────────────────────────────────────
if ($method === 'GET' && $resource === 'goals') {
$uid = require_auth($auth);
$rows = $conn->fetchAllAssociative(
'SELECT id, name, unit, daily, days, start, sets FROM goals WHERE user_id = ? ORDER BY created_at ASC',
[$uid]
);
$goals = array_map(function ($r) {
$r['sets'] = json_decode($r['sets'], true) ?: [];
$r['id'] = (string)$r['id'];
return $r;
}, $rows);
json_out($goals);
}
// ── POST /api/goals ───────────────────────────────────────────────────────────
if ($method === 'POST' && $resource === 'goals') {
$uid = require_auth($auth);
$name = trim($body['name'] ?? '');
$unit = trim($body['unit'] ?? 'Stück');
$daily = (float)($body['daily'] ?? 1);
$days = (int) ($body['days'] ?? 30);
try { $start = (new DateTime($body['start'] ?? 'now'))->format('Y-m-d H:i:s'); }
catch (\Exception $e) { $start = date('Y-m-d H:i:s'); }
$setsJson = isset($body['sets']) && is_array($body['sets']) ? json_encode((object)$body['sets']) : '{}';
if (!$name) json_out(['error' => 'Name fehlt'], 400);
$conn->executeStatement(
'INSERT INTO goals (user_id, name, unit, daily, days, start, sets) VALUES (?, ?, ?, ?, ?, ?, ?)',
[$uid, $name, $unit, $daily, $days, $start, $setsJson]
);
$id = (string)$conn->lastInsertId();
json_out(['id' => $id, 'name' => $name, 'unit' => $unit, 'daily' => $daily, 'days' => $days, 'start' => $start, 'sets' => json_decode($setsJson, true)]);
}
// ── PATCH /api/goals/{id} ─────────────────────────────────────────────────────
if ($method === 'PATCH' && $resource === 'goals' && $resourceId !== null) {
$uid = require_auth($auth);
// Sicherstellen dass das Ziel dem User gehört
$existing = $conn->fetchOne('SELECT id FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
if (!$existing) json_out(['error' => 'Nicht gefunden'], 404);
$fields = [];
$params = [];
foreach (['name', 'unit'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (string)$body[$f]; }
}
foreach (['daily'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (float)$body[$f]; }
}
foreach (['days'] as $f) {
if (isset($body[$f])) { $fields[] = "$f = ?"; $params[] = (int)$body[$f]; }
}
if (isset($body['sets'])) {
$fields[] = 'sets = ?';
$params[] = json_encode($body['sets']);
}
if (!$fields) json_out(['ok' => true]);
$params[] = $resourceId;
$params[] = $uid;
$conn->executeStatement('UPDATE goals SET ' . implode(', ', $fields) . ' WHERE id = ? AND user_id = ?', $params);
json_out(['ok' => true]);
}
// ── DELETE /api/goals/{id} ────────────────────────────────────────────────────
if ($method === 'DELETE' && $resource === 'goals' && $resourceId !== null) {
$uid = require_auth($auth);
$conn->executeStatement('DELETE FROM goals WHERE id = ? AND user_id = ?', [$resourceId, $uid]);
json_out(['ok' => true]);
}
json_out(['error' => 'Not found'], 404);