|
|
<?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;
|
|
|
}
|