You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1248 lines
37 KiB
PHP

<?php
/**
* Single-file PHP API for multi-user "Igel" management
* - Auth (JWT + Refresh)
* - Igel CRUD (+ Sharing: viewer/editor/owner)
* - Bilder (Liste/Upload/Löschen)
* - Messwerte (Liste/Anlegen/Aktualisieren/Löschen)
*
* Konfiguration: require_once 'hedgehogs-settings.php';
*/
declare(strict_types=1);
ini_set('display_errors', '0'); // API: keine HTML-Fehler ausgeben (bricht JSON)
ini_set('display_startup_errors', '0');
ini_set('log_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,PATCH,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);
}
}
// --- Sharing-Routen --------------------------------------------------------
// /igel/{id}/shares
if (preg_match('#^/igel/(\d+)/shares$#', $path, $m)) {
$uid = require_user($pdo);
$hid = (int) $m[1];
if ($method === 'GET') {
return shares_list($pdo, $uid, $hid);
}
if ($method === 'POST') {
return shares_create($pdo, $uid, $hid);
}
}
// /shares/{shareId}
if (preg_match('#^/shares/(\d+)$#', $path, $m)) {
$uid = require_user($pdo);
$sid = (int) $m[1];
if ($method === 'PATCH') {
return shares_update_role($pdo, $uid, $sid);
}
if ($method === 'DELETE') {
return shares_revoke_or_leave($pdo, $uid, $sid);
}
}
// /invites/accept
if ($path === '/invites/accept' && $method === 'POST') {
$uid = require_user($pdo);
return invites_accept($pdo, $uid);
}
// /me/shared
if ($path === '/me/shared' && $method === 'GET') {
$uid = require_user($pdo);
return me_shared($pdo, $uid);
}
// --- 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];
}
// =============================================================================
// 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 <20>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;
}