upload, kleine thumbs

This commit is contained in:
2025-10-23 20:58:16 +02:00
parent bede0000a8
commit 66c20e628f
9 changed files with 1175 additions and 78 deletions
+685
View File
@@ -0,0 +1,685 @@
<?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); }
}
// --- 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); }
}
// /images/{imgId}
if (preg_match('#^/images/(\d+)$#', $path, $m)) {
$imgId = (int)$m[1];
if ($method === 'DELETE') { return igel_images_delete($pdo, $uid, $imgId); }
}
// /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 aus DB holen (für Response, damit Frontend sofort beides hat)
$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,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE,
INDEX (igel_id)
) 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;
*/
?>