'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]; } // ============================================================================= // SHARING: Ownership & Access Checks // ============================================================================= function is_owner(PDO $pdo, int $userId, int $hedgehogId): bool { $st = $pdo->prepare('SELECT 1 FROM igel WHERE id=? AND user_id=?'); $st->execute([$hedgehogId, $userId]); return (bool) $st->fetchColumn(); } function can_view(PDO $pdo, int $userId, int $hedgehogId): bool { if (is_owner($pdo, $userId, $hedgehogId)) return true; $st = $pdo->prepare("SELECT 1 FROM hedgehog_shares WHERE hedgehog_id=? AND status='accepted' AND role IN ('viewer','editor') AND target_user_id=?"); $st->execute([$hedgehogId, $userId]); return (bool) $st->fetchColumn(); } function can_edit(PDO $pdo, int $userId, int $hedgehogId): bool { if (is_owner($pdo, $userId, $hedgehogId)) return true; $st = $pdo->prepare("SELECT 1 FROM hedgehog_shares WHERE hedgehog_id=? AND status='accepted' AND role='editor' AND target_user_id=?"); $st->execute([$hedgehogId, $userId]); return (bool) $st->fetchColumn(); } // ============================================================================= // IGEL // ============================================================================= function igel_list(PDO $pdo, int $uid): void { $stmt = $pdo->prepare('SELECT id, name, gender, feature, note, rescued_at, location, 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; $rescuedAt = isset($in['rescued_at']) ? (string) $in['rescued_at'] : null; // "YYYY-MM-DD" $location = isset($in['location']) ? trim((string) $in['location']) : null; if ($name === '') { json(['error' => 'Name required'], 422); return; } if ($rescuedAt !== null && $rescuedAt !== '' && strtotime($rescuedAt) === false) { json(['error' => 'Invalid rescued_at (expected YYYY-MM-DD)'], 422); return; } if ($location !== null && strlen($location) > 255) { json(['error' => 'Location too long (max 255)'], 422); return; } $stmt = $pdo->prepare( 'INSERT INTO igel(user_id, name, gender, feature, note, rescued_at, location) VALUES(?,?,?,?,?,?,?)' ); $stmt->execute([$uid, $name, $gender, $feature, $note, $rescuedAt ?: null, $location ?: null]); $id = (int) $pdo->lastInsertId(); json([ 'id' => $id, 'name' => $name, 'gender' => $gender, 'feature' => $feature, 'note' => $note, 'rescued_at' => $rescuedAt, 'location' => $location ], 201); } function igel_get(PDO $pdo, int $uid, int $id): void { if (!can_view($pdo, $uid, $id)) { json(['error' => 'Not found'], 404); return; } $stmt = $pdo->prepare('SELECT id, name, gender, feature, note, rescued_at, location, created_at, updated_at FROM igel WHERE id=?'); $stmt->execute([$id]); $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 { if (!can_edit($pdo, $uid, $id)) { json(['error' => 'Forbidden'], 403); return; } $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; $rescuedAt = array_key_exists('rescued_at', $in) ? (string) $in['rescued_at'] : null; $location = array_key_exists('location', $in) ? trim((string) $in['location']) : null; if ($name === '') { json(['error' => 'Name required'], 422); return; } if ($rescuedAt !== null && $rescuedAt !== '' && strtotime($rescuedAt) === false) { json(['error' => 'Invalid rescued_at (expected YYYY-MM-DD)'], 422); return; } if ($location !== null && strlen($location) > 255) { json(['error' => 'Location too long (max 255)'], 422); return; } $stmt = $pdo->prepare( 'UPDATE igel SET name=?, gender=?, feature=?, note=?, rescued_at=?, location=? WHERE id=?' ); $stmt->execute([$name, $gender, $feature, $note, $rescuedAt ?: null, $location ?: null, $id]); json(['ok' => true]); } function igel_delete(PDO $pdo, int $uid, int $id): void { if (!is_owner($pdo, $uid, $id)) { json(['error' => 'Forbidden'], 403); return; } $stmt = $pdo->prepare('DELETE FROM igel WHERE id=?'); $stmt->execute([$id]); json(['ok' => true]); } // ============================================================================= // IGEL BILDER (Liste/Upload/Löschen) // ============================================================================= function igel_images_list(PDO $pdo, int $uid, int $igId): void { if (!can_view($pdo, $uid, $igId)) { 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 { if (!can_edit($pdo, $uid, $igId)) { json(['error' => 'Forbidden'], 403); 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 = []; // Hilfsfunktion: Extension/MIME erkennen (inkl. Videos) $detectMedia = function (string $origName, string $fileType): array { $lower = strtolower($origName); $ext = '.bin'; $mime = 'application/octet-stream'; $isImage = false; $extFromLower = function (array $map) use ($lower): ?array { foreach ($map as $pattern => $info) { if (preg_match($pattern, $lower)) { return $info; } } return null; }; // bevorzugt Endung, fallback auf gelieferten Content-Type $info = $extFromLower([ '/\.(jpg|jpeg)$/' => ['.jpg', 'image/jpeg', true], '/\.png$/' => ['.png', 'image/png', true], '/\.webp$/' => ['.webp', 'image/webp', true], '/\.gif$/' => ['.gif', 'image/gif', true], '/\.heic$/' => ['.heic', 'image/heic', true], '/\.heif$/' => ['.heif', 'image/heif', true], '/\.(mp4|m4v)$/' => ['.mp4', 'video/mp4', false], '/\.mov$/' => ['.mov', 'video/quicktime', false], '/\.avi$/' => ['.avi', 'video/x-msvideo', false], '/\.mkv$/' => ['.mkv', 'video/x-matroska', false], '/\.webm$/' => ['.webm', 'video/webm', false], '/\.(3gp|3gpp)$/' => ['.3gp', 'video/3gpp', false], ]); if ($info) { [$ext, $mime, $isImage] = $info; } elseif ($fileType) { $t = strtolower($fileType); if (str_starts_with($t, 'image/')) { $mime = $t; $ext = '.jpg'; $isImage = true; } elseif (str_starts_with($t, 'video/')) { $mime = $t; $ext = '.mp4'; $isImage = false; } } return [$ext, $mime, $isImage]; }; $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]; $maxSize = defined('MAX_MEDIA_SIZE') ? MAX_MEDIA_SIZE : MAX_IMAGE_SIZE; if ($size <= 0 || $size > $maxSize) continue; // MIME/Extension erkennen $fileType = is_array($files['type']) && isset($files['type'][$i]) ? (string) $files['type'][$i] : ''; [$ext, $mime, $isImage] = $detectMedia($orig, $fileType); // 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 (für Bilder echtes Thumbnail, für Videos Frame/Placeholder) $thumbUrl = null; $thumbBase = $base . ($isImage ? $ext : '.png'); if ($isImage) { try { $thumbDir = rtrim(UPLOAD_THUMB_DIR, '/'); if (!is_dir($thumbDir)) { @mkdir($thumbDir, 0755, true); } $thumbPath = $thumbDir . '/' . $thumbBase; create_thumbnail($dest, $thumbPath, 512, 512); // Quadrat-Box $thumbUrl = rtrim(UPLOAD_BASE_URL, '/') . '/thumbs/' . $thumbBase; } catch (Throwable $e) { $thumbUrl = null; // ok } } else { try { $thumbDir = rtrim(UPLOAD_THUMB_DIR, '/'); if (!is_dir($thumbDir)) { @mkdir($thumbDir, 0755, true); } $thumbPath = $thumbDir . '/' . $thumbBase; try { create_video_thumbnail($dest, $thumbPath, 512, 512); } catch (Throwable $e) { create_video_placeholder($thumbPath, 512, 288); } $thumbUrl = rtrim(UPLOAD_BASE_URL, '/') . '/thumbs/' . $thumbBase; } catch (Throwable $e) { $thumbUrl = null; } } $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 { // Bild + zugehörigen Igel finden $stmt = $pdo->prepare('SELECT i.id, i.url, i.thumb_url, i.igel_id FROM igel_images i WHERE i.id=?'); $stmt->execute([$imgId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { json(['error' => 'Not found'], 404); return; } $igelId = (int) $row['igel_id']; if (!can_edit($pdo, $uid, $igelId)) { json(['error' => 'Forbidden'], 403); 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); } // Placeholder für Video-Thumbnails (ohne ffmpeg) function create_video_placeholder(string $dest, int $w = 512, int $h = 288): void { if (!extension_loaded('gd')) throw new Exception('GD not loaded'); $im = imagecreatetruecolor($w, $h); $bg = imagecolorallocate($im, 34, 34, 34); imagefilledrectangle($im, 0, 0, $w, $h, $bg); $accent = imagecolorallocate($im, 240, 240, 240); $txt = imagecolorallocate($im, 180, 180, 180); // Play-Icon (Dreieck) $size = (int) min($w, $h) * 0.3; $cx = (int) ($w / 2); $cy = (int) ($h / 2); $half = (int) ($size / 2); $points = [ $cx - (int) ($half * 0.7), $cy - $half, $cx - (int) ($half * 0.7), $cy + $half, $cx + $half, $cy, ]; imagefilledpolygon($im, $points, 3, $accent); // Rand $border = imagecolorallocatealpha($im, 255, 255, 255, 60); imagerectangle($im, 0, 0, $w - 1, $h - 1, $border); // Text "VIDEO" $label = 'VIDEO'; $fontSize = 5; // built-in font $tw = imagefontwidth($fontSize) * strlen($label); $th = imagefontheight($fontSize); imagestring($im, $fontSize, (int) (($w - $tw) / 2), $h - $th - 6, $label, $txt); $ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION)); if ($ext === 'jpg' || $ext === 'jpeg') { imagejpeg($im, $dest, 85); } else { imagepng($im, $dest, 6); } imagedestroy($im); } // Video-Thumbnail �ber ffmpeg (erstes Frame) function create_video_thumbnail(string $src, string $dest, int $maxW, int $maxH): void { $ffmpeg = trim((string) @shell_exec('command -v ffmpeg')); if ($ffmpeg === '') { throw new Exception('ffmpeg not available'); } $srcEsc = escapeshellarg($src); $destEsc = escapeshellarg($dest); // Skaliert proportional, dann schwarze R�nder auf Quadrat $scale = sprintf( 'scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(%d-iw)/2:(%d-ih)/2', $maxW, $maxH, $maxW, $maxH, $maxW, $maxH ); $cmd = "$ffmpeg -y -v error -i $srcEsc -frames:v 1 -vf \"$scale\" $destEsc"; $out = []; $ret = 0; @exec($cmd, $out, $ret); if ($ret !== 0 || !is_file($dest)) { throw new Exception('ffmpeg failed'); } } // ============================================================================= // MESSWERTE // ============================================================================= function messwerte_list(PDO $pdo, int $uid, int $igId): void { if (!can_view($pdo, $uid, $igId)) { 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 { if (!can_edit($pdo, $uid, $igId)) { json(['error' => 'Forbidden'], 403); 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 { // igel_id bestimmen $own = $pdo->prepare('SELECT m.igel_id FROM messwerte m WHERE m.id=?'); $own->execute([$mid]); $row = $own->fetch(PDO::FETCH_ASSOC); if (!$row) { json(['error' => 'Not found'], 404); return; } if (!can_edit($pdo, $uid, (int) $row['igel_id'])) { json(['error' => 'Forbidden'], 403); 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 { // igel_id bestimmen $own = $pdo->prepare('SELECT m.igel_id FROM messwerte m WHERE m.id=?'); $own->execute([$mid]); $row = $own->fetch(PDO::FETCH_ASSOC); if (!$row) { json(['error' => 'Not found'], 404); return; } if (!can_edit($pdo, $uid, (int) $row['igel_id'])) { json(['error' => 'Forbidden'], 403); return; } $del = $pdo->prepare('DELETE FROM messwerte WHERE id=?'); $del->execute([$mid]); json(['ok' => true]); } // ============================================================================= // SHARES: Endpoints // ============================================================================= function shares_list(PDO $pdo, int $uid, int $hedgehogId): void { if (!can_view($pdo, $uid, $hedgehogId)) { json(['error' => 'Forbidden'], 403); return; } $st = $pdo->prepare(" SELECT s.id, s.hedgehog_id, s.owner_user_id, s.target_user_id, s.invited_email, tu.email AS target_email, -- <— NEU: E-Mail des akzeptierten Users s.role, s.status, s.created_at, s.updated_at FROM hedgehog_shares s LEFT JOIN users tu ON tu.id = s.target_user_id WHERE s.hedgehog_id = ? AND s.status IN ('pending','accepted') ORDER BY s.created_at DESC "); $st->execute([$hedgehogId]); json($st->fetchAll()); } function shares_create(PDO $pdo, int $uid, int $hedgehogId): void { // nur Owner darf teilen if (!is_owner($pdo, $uid, $hedgehogId)) { json(['error' => 'Forbidden'], 403); return; } $in = body_json(); $email = strtolower(trim((string) ($in['email'] ?? ''))); $role = in_array(($in['role'] ?? 'viewer'), ['viewer', 'editor'], true) ? $in['role'] : 'viewer'; if ($email === '') { json(['error' => 'Email required'], 422); return; } // existiert der User schon? $st = $pdo->prepare('SELECT id FROM users WHERE email=?'); $st->execute([$email]); $target = $st->fetch(); if ($target) { // sofort akzeptiert $ins = $pdo->prepare("INSERT INTO hedgehog_shares (hedgehog_id, owner_user_id, target_user_id, role, status) VALUES (?,?,?,?, 'accepted')"); $ins->execute([$hedgehogId, $uid, (int) $target['id'], $role]); json(['status' => 'accepted'], 201); } else { $token = bin2hex(random_bytes(32)); $ins = $pdo->prepare("INSERT INTO hedgehog_shares (hedgehog_id, owner_user_id, invited_email, role, status, invite_token, expires_at) VALUES (?,?,?,?, 'pending', ?, DATE_ADD(NOW(), INTERVAL 7 DAY))"); $ins->execute([$hedgehogId, $uid, $email, $role, $token]); // TODO: sendInviteEmail($email, $token, $hedgehogId); json(['status' => 'pending'], 201); } } function shares_update_role(PDO $pdo, int $uid, int $shareId): void { $st = $pdo->prepare('SELECT id, hedgehog_id FROM hedgehog_shares WHERE id=?'); $st->execute([$shareId]); $share = $st->fetch(); if (!$share) { json(['error' => 'Not found'], 404); return; } // nur Owner des Igels if (!is_owner($pdo, $uid, (int) $share['hedgehog_id'])) { json(['error' => 'Forbidden'], 403); return; } $in = body_json(); $role = (string) ($in['role'] ?? ''); if (!in_array($role, ['viewer', 'editor'], true)) { json(['error' => 'Invalid role'], 422); return; } $up = $pdo->prepare("UPDATE hedgehog_shares SET role=?, updated_at=NOW() WHERE id=?"); $up->execute([$role, $shareId]); json(['ok' => true]); } function shares_revoke_or_leave(PDO $pdo, int $uid, int $shareId): void { $st = $pdo->prepare('SELECT id, hedgehog_id, target_user_id FROM hedgehog_shares WHERE id=?'); $st->execute([$shareId]); $s = $st->fetch(); if (!$s) { json(['error' => 'Not found'], 404); return; } $hedgehogId = (int) $s['hedgehog_id']; $targetId = (int) ($s['target_user_id'] ?? 0); // Owner darf immer; eingeladener User darf seine eigene Freigabe beenden if (!is_owner($pdo, $uid, $hedgehogId) && $uid !== $targetId) { json(['error' => 'Forbidden'], 403); return; } $up = $pdo->prepare("UPDATE hedgehog_shares SET status='revoked', updated_at=NOW() WHERE id=?"); $up->execute([$shareId]); json(['ok' => true]); } function invites_accept(PDO $pdo, int $uid): void { $in = body_json(); $token = (string) ($in['token'] ?? ''); if ($token === '') { json(['error' => 'Token required'], 422); return; } $st = $pdo->prepare("SELECT * FROM hedgehog_shares WHERE invite_token=? AND status='pending' AND (expires_at IS NULL OR expires_at>NOW())"); $st->execute([$token]); $s = $st->fetch(); if (!$s) { json(['error' => 'Invalid or expired'], 400); return; } $up = $pdo->prepare("UPDATE hedgehog_shares SET target_user_id=?, status='accepted', invite_token=NULL, updated_at=NOW() WHERE id=?"); $up->execute([$uid, (int) $s['id']]); json(['ok' => true]); } function me_shared(PDO $pdo, int $uid): void { $st = $pdo->prepare("SELECT s.hedgehog_id AS id, COALESCE(i.name, CONCAT('Igel #', s.hedgehog_id)) AS name, s.role, s.owner_user_id, u.email AS owner_email FROM hedgehog_shares s JOIN igel i ON i.id = s.hedgehog_id JOIN users u ON u.id = s.owner_user_id WHERE s.target_user_id=? AND s.status='accepted' ORDER BY i.id DESC"); $st->execute([$uid]); json($st->fetchAll()); } // ============================================================================= // 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; }