Files
hedgehog/api/hedgehogs.php
T
2025-10-23 22:30:34 +02:00

690 lines
26 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
/**
* Single-file PHP API for multi-user "Igel" management
* - Auth (JWT + Refresh)
* - Igel CRUD
* - Bilder (Liste/Upload/Löschen) – nutzt UPLOAD_* aus hedgehogs-settings.php
* - Messwerte pro Igel (Liste/Anlegen/Aktualisieren/Löschen)
*
* Konfiguration: require_once 'hedgehogs-settings.php';
*/
declare(strict_types=1);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
// --- Load settings -----------------------------------------------------------
require_once __DIR__ . '/hedgehogs-settings.php';
// --- PHP 7 polyfills ---------------------------------------------------------
if (!function_exists('str_starts_with')) {
function str_starts_with($haystack, $needle) { return $needle === '' || strpos($haystack, $needle) === 0; }
}
if (!function_exists('str_ends_with')) {
function str_ends_with($haystack, $needle) {
if ($needle === '') return true;
$len = strlen($needle);
return $len <= strlen($haystack) && substr($haystack, -$len) === $needle;
}
}
// --- CORS --------------------------------------------------------------------
header('Vary: Origin');
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin && origin_allowed($origin, WH_ALLOWED_ORIGINS)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
}
header('Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(204); exit; }
// --- DB ----------------------------------------------------------------------
$pdo = db();
// --- Router ------------------------------------------------------------------
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
// Optional: alternative Routen-Param ?r=/path
if (isset($_GET['r']) && is_string($_GET['r']) && $_GET['r'] !== '') {
$path = $_GET['r'];
}
// strip leading script name if fronted by /hedgehogs.php/...
$script = $_SERVER['SCRIPT_NAME'] ?? '';
if ($script && str_starts_with($path, $script)) {
$path = substr($path, strlen($script));
if ($path === '') $path = '/';
}
try {
// --- Auth ------------------------------------------------------------------
if ($path === '/auth/register' && $method === 'POST') { return auth_register($pdo); }
if ($path === '/auth/login' && $method === 'POST') { return auth_login($pdo); }
if ($path === '/auth/refresh' && $method === 'POST') { return auth_refresh($pdo); }
if ($path === '/auth/logout' && $method === 'POST') { return auth_logout($pdo); }
// --- Messwerte Update/Delete (TOP-LEVEL!) ---------------------------------
// /messwerte/{id} → PUT/DELETE
if (preg_match('#^/messwerte/(\d+)$#', $path, $m)) {
$uid = require_user($pdo);
$mid = (int)$m[1];
if ($method === 'PUT') { return messwerte_update($pdo, $uid, $mid); }
if ($method === 'DELETE') { return messwerte_delete($pdo, $uid, $mid); }
}
// --- Einzelnes Bild löschen (TOP-LEVEL!) -----------------------------------
// /images/{imgId} → DELETE
if (preg_match('#^/images/(\d+)$#', $path, $m)) {
$uid = require_user($pdo);
$imgId = (int)$m[1];
if ($method === 'DELETE') { return igel_images_delete($pdo, $uid, $imgId); }
}
// --- Igel + Unterressourcen -----------------------------------------------
if (str_starts_with($path, '/igel')) {
$uid = require_user($pdo);
// /igel
if ($path === '/igel' && $method === 'GET') { return igel_list($pdo, $uid); }
if ($path === '/igel' && $method === 'POST') { return igel_create($pdo, $uid); }
// /igel/{id}
if (preg_match('#^/igel/(\d+)$#', $path, $m)) {
$id = (int)$m[1];
if ($method === 'GET') { return igel_get($pdo, $uid, $id); }
if ($method === 'PUT') { return igel_update($pdo, $uid, $id); }
if ($method === 'DELETE') { return igel_delete($pdo, $uid, $id); }
}
// /igel/{id}/images
if (preg_match('#^/igel/(\d+)/images$#', $path, $m)) {
$igId = (int)$m[1];
if ($method === 'GET') { return igel_images_list($pdo, $uid, $igId); }
if ($method === 'POST') { return igel_images_upload($pdo, $uid, $igId); }
}
// /igel/{id}/messwerte (Liste + Neu)
if (preg_match('#^/igel/(\d+)/messwerte$#', $path, $m)) {
$igId = (int)$m[1];
if ($method === 'GET') { return messwerte_list($pdo, $uid, $igId); }
if ($method === 'POST') { return messwerte_create($pdo, $uid, $igId); }
}
}
json(['error' => 'Not Found', 'path' => $path], 404);
} catch (Throwable $e) {
error_log('[hedgehogs.php] Exception: '.$e->getMessage());
json(['error' => 'Server error'], 500);
}
// =============================================================================
// AUTH
// =============================================================================
function auth_register(PDO $pdo): void {
$in = body_json();
$email = strtolower(trim((string)($in['email'] ?? '')));
$pass = (string)($in['password'] ?? '');
// E-Mail-Check ohne filter-Extension
$emailOk = (bool)preg_match('/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $email);
if (!$emailOk || strlen($pass) < 8) { json(['error' => 'Invalid input'], 422); return; }
$hash = password_hash($pass, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT);
try {
$stmt = $pdo->prepare('INSERT INTO users(email, password_hash) VALUES(?, ?)');
$stmt->execute([$email, $hash]);
} catch (PDOException $e) {
if ((int)($e->errorInfo[1] ?? 0) === 1062) { json(['error' => 'Email already exists'], 409); return; }
throw $e;
}
json(['ok' => true], 201);
}
function auth_login(PDO $pdo): void {
$in = body_json();
$email = strtolower(trim((string)($in['email'] ?? '')));
$pass = (string)($in['password'] ?? '');
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || !password_verify($pass, (string)$row['password_hash'])) {
json(['error' => 'Invalid credentials'], 401); return;
}
$uid = (int)$row['id'];
[$access, $refresh] = issue_tokens($pdo, $uid);
json(['access_token' => $access, 'refresh_token' => $refresh]);
}
function auth_refresh(PDO $pdo): void {
$in = body_json();
$refresh = (string)($in['refresh_token'] ?? '');
$stmt = $pdo->prepare('SELECT user_id FROM refresh_tokens WHERE token = ? AND expires_at > NOW()');
$stmt->execute([$refresh]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) { json(['error' => 'Invalid refresh'], 401); return; }
$uid = (int)$row['user_id'];
$now = time();
$access = jwt_encode(['iss' => JWT_ISSUER, 'iat' => $now, 'exp' => $now + JWT_ACCESS_TTL, 'sub' => $uid], JWT_SECRET);
json(['access_token' => $access]);
}
function auth_logout(PDO $pdo): void {
$in = body_json();
$refresh = (string)($in['refresh_token'] ?? '');
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token = ?');
$stmt->execute([$refresh]);
json(['ok' => true]);
}
function require_user(PDO $pdo): int {
$hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/Bearer\s+(.*)/i', $hdr, $m)) { json(['error' => 'Unauthorized'], 401); exit; }
try {
$payload = jwt_decode($m[1], JWT_SECRET);
if (($payload['iss'] ?? null) !== JWT_ISSUER) throw new Exception('bad iss');
$sub = (int)($payload['sub'] ?? 0);
if ($sub <= 0) throw new Exception('bad sub');
return $sub;
} catch (Throwable $e) {
json(['error' => 'Unauthorized'], 401); exit;
}
}
function issue_tokens(PDO $pdo, int $uid): array {
$now = time();
$access = jwt_encode(['iss' => JWT_ISSUER, 'iat' => $now, 'exp' => $now + JWT_ACCESS_TTL, 'sub' => $uid], JWT_SECRET);
$refresh = bin2hex(random_bytes(32));
$stmt = $pdo->prepare('INSERT INTO refresh_tokens(user_id, token, expires_at) VALUES(?,?, FROM_UNIXTIME(?))');
$stmt->execute([$uid, $refresh, $now + JWT_REFRESH_TTL]);
return [$access, $refresh];
}
// =============================================================================
// IGEL
// =============================================================================
function igel_list(PDO $pdo, int $uid): void {
$stmt = $pdo->prepare('SELECT id, name, gender, feature, note, created_at, updated_at FROM igel WHERE user_id = ? ORDER BY created_at DESC');
$stmt->execute([$uid]);
json($stmt->fetchAll(PDO::FETCH_ASSOC));
}
function igel_create(PDO $pdo, int $uid): void {
$in = body_json();
$name = trim((string)($in['name'] ?? ''));
$gender = isset($in['gender']) ? (string)$in['gender'] : null;
$feature = isset($in['feature']) ? (string)$in['feature'] : null;
$note = isset($in['note']) ? (string)$in['note'] : null;
if ($name === '') { json(['error' => 'Name required'], 422); return; }
$stmt = $pdo->prepare('INSERT INTO igel(user_id, name, gender, feature, note) VALUES(?,?,?,?,?)');
$stmt->execute([$uid, $name, $gender, $feature, $note]);
$id = (int)$pdo->lastInsertId();
json(['id' => $id, 'name' => $name, 'gender' => $gender, 'feature' => $feature, 'note' => $note], 201);
}
function igel_get(PDO $pdo, int $uid, int $id): void {
$stmt = $pdo->prepare('SELECT id, name, gender, feature, note, created_at, updated_at FROM igel WHERE id=? AND user_id=?');
$stmt->execute([$id, $uid]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) { json(['error' => 'Not found'], 404); return; }
json($row);
}
function igel_update(PDO $pdo, int $uid, int $id): void {
$in = body_json();
$name = trim((string)($in['name'] ?? ''));
$gender = isset($in['gender']) ? (string)$in['gender'] : null;
$feature = isset($in['feature']) ? (string)$in['feature'] : null;
$note = isset($in['note']) ? (string)$in['note'] : null;
if ($name === '') { json(['error' => 'Name required'], 422); return; }
$stmt = $pdo->prepare('UPDATE igel SET name=?, gender=?, feature=?, note=? WHERE id=? AND user_id=?');
$stmt->execute([$name, $gender, $feature, $note, $id, $uid]);
json(['ok' => true]);
}
function igel_delete(PDO $pdo, int $uid, int $id): void {
$stmt = $pdo->prepare('DELETE FROM igel WHERE id=? AND user_id=?');
$stmt->execute([$id, $uid]);
json(['ok' => true]);
}
// =============================================================================
// IGEL BILDER (Liste/Upload/Löschen)
// =============================================================================
function igel_images_list(PDO $pdo, int $uid, int $igId): void {
// Besitz prüfen
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
try {
$stmt=$pdo->prepare('SELECT id,url,thumb_url,original_name,mime,size_bytes,created_at,taken_at
FROM igel_images WHERE igel_id=? ORDER BY id DESC');
$stmt->execute([$igId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
// Fallback, falls thumb_url-Spalte fehlt
$stmt=$pdo->prepare('SELECT id,url,original_name,mime,size_bytes,created_at,taken_at
FROM igel_images WHERE igel_id=? ORDER BY id DESC');
$stmt->execute([$igId]);
$tmp = $stmt->fetchAll(PDO::FETCH_ASSOC);
$rows = [];
foreach ($tmp as $r) {
$r['thumb_url'] = $r['url'];
$rows[] = $r;
}
}
json($rows);
}
function igel_images_upload(PDO $pdo, int $uid, int $igId): void {
// Besitz prüfen
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
// multipart/form-data: files[]
if (!isset($_FILES['files'])) { json(['error' => 'No files'], 400); return; }
$files = $_FILES['files'];
// Optionales paralleles Feld: taken_at[] (ISO-8601 vom Client aus EXIF)
$takenArr = [];
if (isset($_POST['taken_at'])) {
$takenArr = is_array($_POST['taken_at']) ? $_POST['taken_at'] : [$_POST['taken_at']];
}
$out = [];
$count = is_array($files['name']) ? count($files['name']) : 0;
for ($i=0; $i<$count; $i++) {
if ((int)$files['error'][$i] !== UPLOAD_ERR_OK) continue;
$tmp = (string)$files['tmp_name'][$i];
$orig = (string)$files['name'][$i];
$size = (int)$files['size'][$i];
if ($size <= 0 || $size > MAX_IMAGE_SIZE) continue;
// MIME grob anhand Endung
$lower = strtolower($orig);
$ext = '.bin';
$mime = 'application/octet-stream';
if (preg_match('/\.(jpg|jpeg)$/', $lower)) { $ext = '.jpg'; $mime='image/jpeg'; }
elseif (preg_match('/\.png$/', $lower)) { $ext = '.png'; $mime='image/png'; }
elseif (preg_match('/\.webp$/', $lower)) { $ext = '.webp'; $mime='image/webp'; }
elseif (preg_match('/\.gif$/', $lower)) { $ext = '.gif'; $mime='image/gif'; }
// sichere Dateinamen
$base = bin2hex(random_bytes(8));
$fn = $base . $ext;
$destDir = rtrim(UPLOAD_DIR, '/');
if (!is_dir($destDir)) { @mkdir($destDir, 0755, true); }
$dest = $destDir . '/' . $fn;
if (!move_uploaded_file($tmp, $dest)) continue;
// Thumb
$thumbUrl = null;
try {
$thumbDir = rtrim(UPLOAD_THUMB_DIR, '/');
if (!is_dir($thumbDir)) { @mkdir($thumbDir, 0755, true); }
$thumbPath = $thumbDir . '/' . $fn;
create_thumbnail($dest, $thumbPath, 512, 512); // Quadrat-Box
$thumbUrl = rtrim(UPLOAD_BASE_URL,'/') . '/thumbs/' . $fn;
} catch (Throwable $e) {
$thumbUrl = null; // ok
}
$url = rtrim(UPLOAD_BASE_URL,'/') . '/' . $fn;
// EXIF-Aufnahmezeit (taken_at[]) → DATETIME oder NULL
$takenAtMysql = null;
if (isset($takenArr[$i])) {
$raw = (string)$takenArr[$i];
$ts = strtotime($raw);
if ($ts !== false) {
$takenAtMysql = date('Y-m-d H:i:s', $ts);
}
}
// DB: created_at via DEFAULT CURRENT_TIMESTAMP, taken_at separat speichern
$stmt = $pdo->prepare('INSERT INTO igel_images
(igel_id, url, thumb_url, original_name, mime, size_bytes, taken_at)
VALUES(?,?,?,?,?,?,?)');
$stmt->execute([$igId, $url, $thumbUrl, $orig, $mime, $size, $takenAtMysql]);
$id = (int)$pdo->lastInsertId();
// created_at/taken_at für Response aus DB holen
$row = $pdo->prepare('SELECT created_at, taken_at FROM igel_images WHERE id=?');
$row->execute([$id]);
$times = $row->fetch(PDO::FETCH_ASSOC) ?: ['created_at'=>null,'taken_at'=>null];
$out[] = [
'id' => $id,
'url' => $url,
'thumb_url' => $thumbUrl,
'original_name' => $orig,
'mime' => $mime,
'size_bytes' => $size,
'created_at' => (string)($times['created_at'] ?? ''),
'taken_at' => (string)($times['taken_at'] ?? ''),
];
}
json($out, 201);
}
function igel_images_delete(PDO $pdo, int $uid, int $imgId): void {
// Besitz prüfen (Join)
$stmt=$pdo->prepare('SELECT i.id, i.url, i.thumb_url
FROM igel_images i
JOIN igel g ON g.id = i.igel_id
WHERE i.id=? AND g.user_id=?');
$stmt->execute([$imgId,$uid]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) { json(['error'=>'Not found'],404); return; }
// Dateien optional entfernen
try {
$url = (string)$row['url'];
$thumb = (string)($row['thumb_url'] ?? '');
$fn = basename(parse_url($url, PHP_URL_PATH) ?? '');
$fnT = $thumb ? basename(parse_url($thumb, PHP_URL_PATH) ?? '') : '';
$p = rtrim(UPLOAD_DIR,'/').'/'.$fn;
if (is_file($p)) @unlink($p);
if ($fnT !== '') {
$pt = rtrim(UPLOAD_THUMB_DIR,'/').'/'.$fnT;
if (is_file($pt)) @unlink($pt);
}
} catch (Throwable $e) {}
$del=$pdo->prepare('DELETE FROM igel_images WHERE id=?');
$del->execute([$imgId]);
json(['ok'=>true]);
}
// --- Thumbnail Helper (GD) ---------------------------------------------------
function create_thumbnail(string $src, string $dest, int $maxW, int $maxH): void {
if (!extension_loaded('gd')) throw new Exception('GD not loaded');
[$w,$h,$type] = getimagesize($src);
if (!$w || !$h) throw new Exception('bad image');
switch ($type) {
case IMAGETYPE_JPEG: $im = imagecreatefromjpeg($src); break;
case IMAGETYPE_PNG: $im = imagecreatefrompng($src); break;
case IMAGETYPE_WEBP: if (function_exists('imagecreatefromwebp')) { $im = imagecreatefromwebp($src); } else { throw new Exception('webp not supported'); } break;
case IMAGETYPE_GIF: $im = imagecreatefromgif($src); break;
default: throw new Exception('unsupported type');
}
$ratio = min($maxW/$w, $maxH/$h, 1.0);
$nw = (int)round($w*$ratio);
$nh = (int)round($h*$ratio);
$thumb = imagecreatetruecolor($nw, $nh);
// transparent für PNG/GIF
if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF], true)) {
imagecolortransparent($thumb, imagecolorallocatealpha($thumb, 0, 0, 0, 127));
imagealphablending($thumb, false);
imagesavealpha($thumb, true);
}
imagecopyresampled($thumb, $im, 0,0,0,0, $nw,$nh,$w,$h);
$ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
if ($ext === 'png') imagepng($thumb, $dest, 6);
elseif ($ext === 'gif') imagegif($thumb, $dest);
elseif ($ext === 'webp' && function_exists('imagewebp')) imagewebp($thumb, $dest, 85);
else imagejpeg($thumb, $dest, 85);
imagedestroy($im);
imagedestroy($thumb);
}
// =============================================================================
// MESSWERTE
// =============================================================================
function messwerte_list(PDO $pdo, int $uid, int $igId): void {
// Besitz
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
$stmt=$pdo->prepare('SELECT id, igel_id, DATE_FORMAT(datum, "%Y-%m-%dT%H:%i:%sZ") AS datum,
gewicht, behandlung, bemerkung, created_at
FROM messwerte
WHERE igel_id=? ORDER BY datum DESC, id DESC');
$stmt->execute([$igId]);
json($stmt->fetchAll(PDO::FETCH_ASSOC));
}
function messwerte_create(PDO $pdo, int $uid, int $igId): void {
// Besitz
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
$own->execute([$igId,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
$in = body_json();
$datumRaw = (string)($in['datum'] ?? '');
$gewicht = (int)($in['gewicht'] ?? 0);
$behandlung = isset($in['behandlung']) ? (string)$in['behandlung'] : null;
$bemerkung = isset($in['bemerkung']) ? (string)$in['bemerkung'] : null;
// Datum akzeptiert ISO-8601 oder "YYYY-MM-DD HH:MM"
$ts = $datumRaw !== '' ? strtotime($datumRaw) : time();
if ($ts === false) { json(['error'=>'Invalid date'],422); return; }
if ($gewicht < 1 || $gewicht > 100000) { json(['error'=>'Invalid weight'],422); return; }
$mysql = date('Y-m-d H:i:s', $ts);
$stmt=$pdo->prepare('INSERT INTO messwerte (igel_id, datum, gewicht, behandlung, bemerkung) VALUES(?,?,?,?,?)');
$stmt->execute([$igId, $mysql, $gewicht, $behandlung, $bemerkung]);
$id = (int)$pdo->lastInsertId();
json([
'id'=>$id,
'igel_id'=>$igId,
'datum'=>gmdate('Y-m-d\TH:i:s\Z', $ts),
'gewicht'=>$gewicht,
'behandlung'=>$behandlung,
'bemerkung'=>$bemerkung
], 201);
}
function messwerte_update(PDO $pdo, int $uid, int $mid): void {
// Besitz via Join prüfen
$own=$pdo->prepare('SELECT m.id, m.igel_id FROM messwerte m JOIN igel g ON g.id=m.igel_id WHERE m.id=? AND g.user_id=?');
$own->execute([$mid,$uid]);
$row=$own->fetch(PDO::FETCH_ASSOC);
if(!$row) { json(['error'=>'Not found'],404); return; }
$in = body_json();
// Alle Felder optional, aber validieren, falls vorhanden
$set = [];
$args= [];
if (isset($in['datum'])) {
$ts = strtotime((string)$in['datum']);
if ($ts === false) { json(['error'=>'Invalid date'],422); return; }
$set[]='datum=?'; $args[]=date('Y-m-d H:i:s',$ts);
}
if (isset($in['gewicht'])) {
$gewicht=(int)$in['gewicht'];
if ($gewicht < 1 || $gewicht > 100000) { json(['error'=>'Invalid weight'],422); return; }
$set[]='gewicht=?'; $args[]=$gewicht;
}
if (array_key_exists('behandlung',$in)) { $set[]='behandlung=?'; $args[]=(string)$in['behandlung']; }
if (array_key_exists('bemerkung',$in)) { $set[]='bemerkung=?'; $args[]=(string)$in['bemerkung']; }
if (empty($set)) { json(['error'=>'No fields'],400); return; }
$args[]=$mid;
$sql='UPDATE messwerte SET '.implode(',', $set).' WHERE id=?';
$stmt=$pdo->prepare($sql);
$stmt->execute($args);
json(['ok'=>true]);
}
function messwerte_delete(PDO $pdo, int $uid, int $mid): void {
// Besitz via Join prüfen
$own=$pdo->prepare('SELECT m.id FROM messwerte m JOIN igel g ON g.id=m.igel_id WHERE m.id=? AND g.user_id=?');
$own->execute([$mid,$uid]);
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
$del=$pdo->prepare('DELETE FROM messwerte WHERE id=?');
$del->execute([$mid]);
json(['ok'=>true]);
}
// =============================================================================
// Utilities
// =============================================================================
function json($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
}
function body_json(): array {
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') return [];
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
function db(): PDO {
$dsn = 'mysql:host=' . DATABASE_HOST . ';dbname=' . DATABASE_NAME . ';charset=utf8mb4';
$pdo = new PDO($dsn, DATABASE_USER, DATABASE_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
return $pdo;
}
function origin_allowed(string $origin, array $allowed): bool {
$u = parse_url($origin);
if (!$u || !isset($u['scheme'], $u['host'])) return false;
$oScheme = $u['scheme']; $oHost = $u['host']; $oPort = (string)($u['port'] ?? '');
foreach ($allowed as $pat) {
$pu = parse_url($pat);
if (!$pu || !isset($pu['scheme'])) continue;
if ($pu['scheme'] !== $oScheme) continue;
$pHost = $pu['host'] ?? '';
$pPort = $pu['port'] ?? '';
$hostOk = false;
if ($pHost === $oHost) $hostOk = true;
elseif (str_starts_with($pHost, '*.' )) { $suffix = substr($pHost, 1); if (str_ends_with($oHost, $suffix)) $hostOk = true; }
elseif ($pHost === '' && isset($pu['path'])) {
$p = $pu['path']; // z.B. localhost:*
if ($p === $oHost || (str_starts_with($p, '*.') && str_ends_with($oHost, substr($p,1)))) $hostOk = true;
}
if (!$hostOk) continue;
$patHasWildcardPort = str_ends_with($pat, ':*');
$portOk = $patHasWildcardPort || ($pPort !== '' && (string)$pPort === $oPort) || ($pPort === '' && $oPort === '');
if ($portOk) return true;
}
return false;
}
// --- Minimal JWT HS256 -------------------------------------------------------
function b64url_encode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
function b64url_decode(string $data): string { return base64_decode(strtr($data, '-_', '+/')) ?: ''; }
function jwt_encode(array $payload, string $secret): string {
$header = ['typ' => 'JWT', 'alg' => 'HS256'];
$segments = [b64url_encode(json_encode($header)), b64url_encode(json_encode($payload))];
$signingInput = implode('.', $segments);
$signature = hash_hmac('sha256', $signingInput, $secret, true);
$segments[] = b64url_encode($signature);
return implode('.', $segments);
}
function jwt_decode(string $token, string $secret): array {
$parts = explode('.', $token);
if (count($parts) !== 3) throw new Exception('bad token');
[$h64, $p64, $s64] = $parts;
$header = json_decode(b64url_decode($h64), true) ?: [];
if (($header['alg'] ?? '') !== 'HS256') throw new Exception('alg');
$payload = json_decode(b64url_decode($p64), true) ?: [];
$sig = b64url_decode($s64);
$expected = hash_hmac('sha256', "$h64.$p64", $secret, true);
if (!hash_equals($expected, $sig)) throw new Exception('sig');
if (isset($payload['exp']) && time() >= (int)$payload['exp']) throw new Exception('exp');
return $payload;
}
/*
-- SQL Reference (run once)
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(191) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE refresh_tokens (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX (user_id), INDEX (token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE igel (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(120) NOT NULL,
gender VARCHAR(30) NULL,
feature VARCHAR(255) NULL,
note TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX (user_id), INDEX (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE igel_images (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
igel_id BIGINT UNSIGNED NOT NULL,
url VARCHAR(500) NOT NULL,
thumb_url VARCHAR(500) NULL,
original_name VARCHAR(255) NULL,
mime VARCHAR(100) NULL,
size_bytes BIGINT UNSIGNED NULL,
taken_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE,
INDEX (igel_id),
INDEX (taken_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE messwerte (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
igel_id BIGINT UNSIGNED NOT NULL,
datum DATETIME NOT NULL,
gewicht INT UNSIGNED NOT NULL,
behandlung VARCHAR(255) NULL,
bemerkung TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE,
INDEX (igel_id), INDEX (datum)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
*/
?>