upload, kleine thumbs
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
// CORS – wie in deinem Beispiel (Wildcard-Ports & Subdomains unterstützt)
|
||||
const WH_ALLOWED_ORIGINS = [
|
||||
// Produktion
|
||||
'https://windesign.at',
|
||||
'https://hedgehogs.windesign.at',
|
||||
'https://api.windesign.at',
|
||||
|
||||
// FlutLab
|
||||
'https://preview.flutlab.io',
|
||||
'https://*.flutlab.io',
|
||||
|
||||
// Entwicklung – beliebiger Port:
|
||||
'http://localhost:*',
|
||||
'http://127.0.0.1:*',
|
||||
'https://localhost:*',
|
||||
'https://127.0.0.1:*',
|
||||
];
|
||||
|
||||
// DB
|
||||
define('DATABASE_NAME', 'hedgehogs');
|
||||
define('DATABASE_USER', 'hedgehogs');
|
||||
define('DATABASE_PASSWORD', 'WeissIchNicht8');
|
||||
define('DATABASE_HOST', 'localhost');
|
||||
|
||||
// Uploads (Dateisystem-Pfade relativ zu hedgehogs.php im Webroot)
|
||||
define('UPLOAD_DIR', __DIR__ . '/hedgehogs/uploads');
|
||||
define('UPLOAD_THUMB_DIR', __DIR__ . '/hedgehogs/uploads/thumbs');
|
||||
|
||||
// Öffentliche Basis-URL zu genau diesen Ordnern
|
||||
define('UPLOAD_BASE_URL', 'https://api.windesign.at/hedgehogs/uploads');
|
||||
|
||||
define('MAX_IMAGE_SIZE', 8 * 1024 * 1024);
|
||||
if (!defined('ALLOWED_MIME')) {
|
||||
define('ALLOWED_MIME', json_encode(['image/jpeg','image/png','image/webp','image/gif']));
|
||||
}
|
||||
|
||||
// JWT / Auth
|
||||
define('JWT_SECRET', 'sehrlangesjwtsecretdasswirklichsehrgeheimist');
|
||||
define('JWT_ISSUER', 'https://windesign.at');
|
||||
define('JWT_ACCESS_TTL', 900); // 15 Minuten
|
||||
define('JWT_REFRESH_TTL', 60*60*24*30); // 30 Tage
|
||||
|
||||
// Mail-Konfiguration
|
||||
define('SMTP_HOST', 'smtp-mail.outlook.com');
|
||||
define('SMTP_PORT', 587);
|
||||
define('SMTP_USER', 'Herwig.Birke@windesign.at');
|
||||
define('SMTP_PASS', 'GehtDichNixAn1');
|
||||
define('SMTP_SECURE', 'tls'); // oder 'ssl'
|
||||
define('MAIL_FROM', 'Herwig.Birke@windesign.at');
|
||||
define('MAIL_FROM_NAME', 'Hedgehogs');
|
||||
define('VERIFY_BASE_URL', 'https://api.windesign.at/hedgehogs.php');
|
||||
define('EMAIL_DEV_MODE', true);
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,685 @@
|
||||
<?php
|
||||
/**
|
||||
* Single-file PHP API for multi-user "Igel" management
|
||||
* - Auth (JWT + Refresh)
|
||||
* - Igel CRUD
|
||||
* - Bilder (Liste/Upload/Löschen) – nutzt UPLOAD_* aus hedgehogs-settings.php
|
||||
* - Messwerte pro Igel (Liste/Anlegen/Aktualisieren/Löschen)
|
||||
*
|
||||
* Konfiguration: require_once 'hedgehogs-settings.php';
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
ini_set('display_errors', '1');
|
||||
ini_set('display_startup_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
|
||||
// --- Load settings -----------------------------------------------------------
|
||||
require_once __DIR__ . '/hedgehogs-settings.php';
|
||||
|
||||
// --- PHP 7 polyfills ---------------------------------------------------------
|
||||
if (!function_exists('str_starts_with')) {
|
||||
function str_starts_with($haystack, $needle) { return $needle === '' || strpos($haystack, $needle) === 0; }
|
||||
}
|
||||
if (!function_exists('str_ends_with')) {
|
||||
function str_ends_with($haystack, $needle) {
|
||||
if ($needle === '') return true;
|
||||
$len = strlen($needle);
|
||||
return $len <= strlen($haystack) && substr($haystack, -$len) === $needle;
|
||||
}
|
||||
}
|
||||
|
||||
// --- CORS --------------------------------------------------------------------
|
||||
header('Vary: Origin');
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if ($origin && origin_allowed($origin, WH_ALLOWED_ORIGINS)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
header('Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { http_response_code(204); exit; }
|
||||
|
||||
// --- DB ----------------------------------------------------------------------
|
||||
$pdo = db();
|
||||
|
||||
// --- Router ------------------------------------------------------------------
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
|
||||
// Optional: alternative Routen-Param ?r=/path
|
||||
if (isset($_GET['r']) && is_string($_GET['r']) && $_GET['r'] !== '') {
|
||||
$path = $_GET['r'];
|
||||
}
|
||||
|
||||
// strip leading script name if fronted by /hedgehogs.php/...
|
||||
$script = $_SERVER['SCRIPT_NAME'] ?? '';
|
||||
if ($script && str_starts_with($path, $script)) {
|
||||
$path = substr($path, strlen($script));
|
||||
if ($path === '') $path = '/';
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Auth ------------------------------------------------------------------
|
||||
if ($path === '/auth/register' && $method === 'POST') { return auth_register($pdo); }
|
||||
if ($path === '/auth/login' && $method === 'POST') { return auth_login($pdo); }
|
||||
if ($path === '/auth/refresh' && $method === 'POST') { return auth_refresh($pdo); }
|
||||
if ($path === '/auth/logout' && $method === 'POST') { return auth_logout($pdo); }
|
||||
|
||||
// --- Messwerte Update/Delete (TOP-LEVEL!) ---------------------------------
|
||||
// /messwerte/{id} → PUT/DELETE
|
||||
if (preg_match('#^/messwerte/(\d+)$#', $path, $m)) {
|
||||
$uid = require_user($pdo);
|
||||
$mid = (int)$m[1];
|
||||
if ($method === 'PUT') { return messwerte_update($pdo, $uid, $mid); }
|
||||
if ($method === 'DELETE') { return messwerte_delete($pdo, $uid, $mid); }
|
||||
}
|
||||
|
||||
// --- Igel + Unterressourcen -----------------------------------------------
|
||||
if (str_starts_with($path, '/igel')) {
|
||||
$uid = require_user($pdo);
|
||||
|
||||
// /igel
|
||||
if ($path === '/igel' && $method === 'GET') { return igel_list($pdo, $uid); }
|
||||
if ($path === '/igel' && $method === 'POST') { return igel_create($pdo, $uid); }
|
||||
|
||||
// /igel/{id}
|
||||
if (preg_match('#^/igel/(\d+)$#', $path, $m)) {
|
||||
$id = (int)$m[1];
|
||||
if ($method === 'GET') { return igel_get($pdo, $uid, $id); }
|
||||
if ($method === 'PUT') { return igel_update($pdo, $uid, $id); }
|
||||
if ($method === 'DELETE') { return igel_delete($pdo, $uid, $id); }
|
||||
}
|
||||
|
||||
// /igel/{id}/images
|
||||
if (preg_match('#^/igel/(\d+)/images$#', $path, $m)) {
|
||||
$igId = (int)$m[1];
|
||||
if ($method === 'GET') { return igel_images_list($pdo, $uid, $igId); }
|
||||
if ($method === 'POST') { return igel_images_upload($pdo, $uid, $igId); }
|
||||
}
|
||||
|
||||
// /images/{imgId}
|
||||
if (preg_match('#^/images/(\d+)$#', $path, $m)) {
|
||||
$imgId = (int)$m[1];
|
||||
if ($method === 'DELETE') { return igel_images_delete($pdo, $uid, $imgId); }
|
||||
}
|
||||
|
||||
// /igel/{id}/messwerte (Liste + Neu)
|
||||
if (preg_match('#^/igel/(\d+)/messwerte$#', $path, $m)) {
|
||||
$igId = (int)$m[1];
|
||||
if ($method === 'GET') { return messwerte_list($pdo, $uid, $igId); }
|
||||
if ($method === 'POST') { return messwerte_create($pdo, $uid, $igId); }
|
||||
}
|
||||
}
|
||||
|
||||
json(['error' => 'Not Found', 'path' => $path], 404);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[hedgehogs.php] Exception: '.$e->getMessage());
|
||||
json(['error' => 'Server error'], 500);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUTH
|
||||
// =============================================================================
|
||||
|
||||
function auth_register(PDO $pdo): void {
|
||||
$in = body_json();
|
||||
$email = strtolower(trim((string)($in['email'] ?? '')));
|
||||
$pass = (string)($in['password'] ?? '');
|
||||
|
||||
// E-Mail-Check ohne filter-Extension
|
||||
$emailOk = (bool)preg_match('/^[^\s@]+@[^\s@]+\.[^\s@]+$/', $email);
|
||||
if (!$emailOk || strlen($pass) < 8) { json(['error' => 'Invalid input'], 422); return; }
|
||||
|
||||
$hash = password_hash($pass, defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_DEFAULT);
|
||||
try {
|
||||
$stmt = $pdo->prepare('INSERT INTO users(email, password_hash) VALUES(?, ?)');
|
||||
$stmt->execute([$email, $hash]);
|
||||
} catch (PDOException $e) {
|
||||
if ((int)($e->errorInfo[1] ?? 0) === 1062) { json(['error' => 'Email already exists'], 409); return; }
|
||||
throw $e;
|
||||
}
|
||||
json(['ok' => true], 201);
|
||||
}
|
||||
|
||||
function auth_login(PDO $pdo): void {
|
||||
$in = body_json();
|
||||
$email = strtolower(trim((string)($in['email'] ?? '')));
|
||||
$pass = (string)($in['password'] ?? '');
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = ?');
|
||||
$stmt->execute([$email]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row || !password_verify($pass, (string)$row['password_hash'])) {
|
||||
json(['error' => 'Invalid credentials'], 401); return;
|
||||
}
|
||||
$uid = (int)$row['id'];
|
||||
[$access, $refresh] = issue_tokens($pdo, $uid);
|
||||
json(['access_token' => $access, 'refresh_token' => $refresh]);
|
||||
}
|
||||
|
||||
function auth_refresh(PDO $pdo): void {
|
||||
$in = body_json();
|
||||
$refresh = (string)($in['refresh_token'] ?? '');
|
||||
$stmt = $pdo->prepare('SELECT user_id FROM refresh_tokens WHERE token = ? AND expires_at > NOW()');
|
||||
$stmt->execute([$refresh]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) { json(['error' => 'Invalid refresh'], 401); return; }
|
||||
$uid = (int)$row['user_id'];
|
||||
$now = time();
|
||||
$access = jwt_encode(['iss' => JWT_ISSUER, 'iat' => $now, 'exp' => $now + JWT_ACCESS_TTL, 'sub' => $uid], JWT_SECRET);
|
||||
json(['access_token' => $access]);
|
||||
}
|
||||
|
||||
function auth_logout(PDO $pdo): void {
|
||||
$in = body_json();
|
||||
$refresh = (string)($in['refresh_token'] ?? '');
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token = ?');
|
||||
$stmt->execute([$refresh]);
|
||||
json(['ok' => true]);
|
||||
}
|
||||
|
||||
function require_user(PDO $pdo): int {
|
||||
$hdr = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (!preg_match('/Bearer\s+(.*)/i', $hdr, $m)) { json(['error' => 'Unauthorized'], 401); exit; }
|
||||
try {
|
||||
$payload = jwt_decode($m[1], JWT_SECRET);
|
||||
if (($payload['iss'] ?? null) !== JWT_ISSUER) throw new Exception('bad iss');
|
||||
$sub = (int)($payload['sub'] ?? 0);
|
||||
if ($sub <= 0) throw new Exception('bad sub');
|
||||
return $sub;
|
||||
} catch (Throwable $e) {
|
||||
json(['error' => 'Unauthorized'], 401); exit;
|
||||
}
|
||||
}
|
||||
|
||||
function issue_tokens(PDO $pdo, int $uid): array {
|
||||
$now = time();
|
||||
$access = jwt_encode(['iss' => JWT_ISSUER, 'iat' => $now, 'exp' => $now + JWT_ACCESS_TTL, 'sub' => $uid], JWT_SECRET);
|
||||
$refresh = bin2hex(random_bytes(32));
|
||||
$stmt = $pdo->prepare('INSERT INTO refresh_tokens(user_id, token, expires_at) VALUES(?,?, FROM_UNIXTIME(?))');
|
||||
$stmt->execute([$uid, $refresh, $now + JWT_REFRESH_TTL]);
|
||||
return [$access, $refresh];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IGEL
|
||||
// =============================================================================
|
||||
|
||||
function igel_list(PDO $pdo, int $uid): void {
|
||||
$stmt = $pdo->prepare('SELECT id, name, gender, feature, note, created_at, updated_at FROM igel WHERE user_id = ? ORDER BY created_at DESC');
|
||||
$stmt->execute([$uid]);
|
||||
json($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
function igel_create(PDO $pdo, int $uid): void {
|
||||
$in = body_json();
|
||||
$name = trim((string)($in['name'] ?? ''));
|
||||
$gender = isset($in['gender']) ? (string)$in['gender'] : null;
|
||||
$feature = isset($in['feature']) ? (string)$in['feature'] : null;
|
||||
$note = isset($in['note']) ? (string)$in['note'] : null;
|
||||
|
||||
if ($name === '') { json(['error' => 'Name required'], 422); return; }
|
||||
$stmt = $pdo->prepare('INSERT INTO igel(user_id, name, gender, feature, note) VALUES(?,?,?,?,?)');
|
||||
$stmt->execute([$uid, $name, $gender, $feature, $note]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
json(['id' => $id, 'name' => $name, 'gender' => $gender, 'feature' => $feature, 'note' => $note], 201);
|
||||
}
|
||||
|
||||
function igel_get(PDO $pdo, int $uid, int $id): void {
|
||||
$stmt = $pdo->prepare('SELECT id, name, gender, feature, note, created_at, updated_at FROM igel WHERE id=? AND user_id=?');
|
||||
$stmt->execute([$id, $uid]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) { json(['error' => 'Not found'], 404); return; }
|
||||
json($row);
|
||||
}
|
||||
|
||||
function igel_update(PDO $pdo, int $uid, int $id): void {
|
||||
$in = body_json();
|
||||
$name = trim((string)($in['name'] ?? ''));
|
||||
$gender = isset($in['gender']) ? (string)$in['gender'] : null;
|
||||
$feature = isset($in['feature']) ? (string)$in['feature'] : null;
|
||||
$note = isset($in['note']) ? (string)$in['note'] : null;
|
||||
|
||||
if ($name === '') { json(['error' => 'Name required'], 422); return; }
|
||||
$stmt = $pdo->prepare('UPDATE igel SET name=?, gender=?, feature=?, note=? WHERE id=? AND user_id=?');
|
||||
$stmt->execute([$name, $gender, $feature, $note, $id, $uid]);
|
||||
json(['ok' => true]);
|
||||
}
|
||||
|
||||
function igel_delete(PDO $pdo, int $uid, int $id): void {
|
||||
$stmt = $pdo->prepare('DELETE FROM igel WHERE id=? AND user_id=?');
|
||||
$stmt->execute([$id, $uid]);
|
||||
json(['ok' => true]);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IGEL BILDER (Liste/Upload/Löschen)
|
||||
// =============================================================================
|
||||
|
||||
function igel_images_list(PDO $pdo, int $uid, int $igId): void {
|
||||
// Besitz prüfen
|
||||
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
|
||||
$own->execute([$igId,$uid]);
|
||||
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
|
||||
|
||||
try {
|
||||
$stmt=$pdo->prepare('SELECT id,url,thumb_url,original_name,mime,size_bytes,created_at,taken_at
|
||||
FROM igel_images WHERE igel_id=? ORDER BY id DESC');
|
||||
$stmt->execute([$igId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
// Fallback, falls thumb_url-Spalte fehlt
|
||||
$stmt=$pdo->prepare('SELECT id,url,original_name,mime,size_bytes,created_at,taken_at
|
||||
FROM igel_images WHERE igel_id=? ORDER BY id DESC');
|
||||
$stmt->execute([$igId]);
|
||||
$tmp = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = [];
|
||||
foreach ($tmp as $r) {
|
||||
$r['thumb_url'] = $r['url'];
|
||||
$rows[] = $r;
|
||||
}
|
||||
}
|
||||
json($rows);
|
||||
}
|
||||
|
||||
function igel_images_upload(PDO $pdo, int $uid, int $igId): void {
|
||||
// Besitz prüfen
|
||||
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
|
||||
$own->execute([$igId,$uid]);
|
||||
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
|
||||
|
||||
// multipart/form-data: files[]
|
||||
if (!isset($_FILES['files'])) { json(['error' => 'No files'], 400); return; }
|
||||
$files = $_FILES['files'];
|
||||
|
||||
// Optionales paralleles Feld: taken_at[] (ISO-8601 vom Client aus EXIF)
|
||||
$takenArr = [];
|
||||
if (isset($_POST['taken_at'])) {
|
||||
$takenArr = is_array($_POST['taken_at']) ? $_POST['taken_at'] : [$_POST['taken_at']];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$count = is_array($files['name']) ? count($files['name']) : 0;
|
||||
for ($i=0; $i<$count; $i++) {
|
||||
if ((int)$files['error'][$i] !== UPLOAD_ERR_OK) continue;
|
||||
$tmp = (string)$files['tmp_name'][$i];
|
||||
$orig = (string)$files['name'][$i];
|
||||
$size = (int)$files['size'][$i];
|
||||
|
||||
if ($size <= 0 || $size > MAX_IMAGE_SIZE) continue;
|
||||
|
||||
// MIME grob anhand Endung
|
||||
$lower = strtolower($orig);
|
||||
$ext = '.bin';
|
||||
$mime = 'application/octet-stream';
|
||||
if (preg_match('/\.(jpg|jpeg)$/', $lower)) { $ext = '.jpg'; $mime='image/jpeg'; }
|
||||
elseif (preg_match('/\.png$/', $lower)) { $ext = '.png'; $mime='image/png'; }
|
||||
elseif (preg_match('/\.webp$/', $lower)) { $ext = '.webp'; $mime='image/webp'; }
|
||||
elseif (preg_match('/\.gif$/', $lower)) { $ext = '.gif'; $mime='image/gif'; }
|
||||
|
||||
// sichere Dateinamen
|
||||
$base = bin2hex(random_bytes(8));
|
||||
$fn = $base . $ext;
|
||||
$destDir = rtrim(UPLOAD_DIR, '/');
|
||||
if (!is_dir($destDir)) { @mkdir($destDir, 0755, true); }
|
||||
$dest = $destDir . '/' . $fn;
|
||||
|
||||
if (!move_uploaded_file($tmp, $dest)) continue;
|
||||
|
||||
// Thumb
|
||||
$thumbUrl = null;
|
||||
try {
|
||||
$thumbDir = rtrim(UPLOAD_THUMB_DIR, '/');
|
||||
if (!is_dir($thumbDir)) { @mkdir($thumbDir, 0755, true); }
|
||||
$thumbPath = $thumbDir . '/' . $fn;
|
||||
create_thumbnail($dest, $thumbPath, 512, 512); // Quadrat-Box
|
||||
$thumbUrl = rtrim(UPLOAD_BASE_URL,'/') . '/thumbs/' . $fn;
|
||||
} catch (Throwable $e) {
|
||||
$thumbUrl = null; // ok
|
||||
}
|
||||
|
||||
$url = rtrim(UPLOAD_BASE_URL,'/') . '/' . $fn;
|
||||
|
||||
// EXIF-Aufnahmezeit (taken_at[]) → DATETIME oder NULL
|
||||
$takenAtMysql = null;
|
||||
if (isset($takenArr[$i])) {
|
||||
$raw = (string)$takenArr[$i];
|
||||
$ts = strtotime($raw);
|
||||
if ($ts !== false) {
|
||||
$takenAtMysql = date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
}
|
||||
|
||||
// DB: created_at via DEFAULT CURRENT_TIMESTAMP, taken_at separat speichern
|
||||
$stmt = $pdo->prepare('INSERT INTO igel_images
|
||||
(igel_id, url, thumb_url, original_name, mime, size_bytes, taken_at)
|
||||
VALUES(?,?,?,?,?,?,?)');
|
||||
$stmt->execute([$igId, $url, $thumbUrl, $orig, $mime, $size, $takenAtMysql]);
|
||||
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
|
||||
// created_at aus DB holen (für Response, damit Frontend sofort beides hat)
|
||||
$row = $pdo->prepare('SELECT created_at, taken_at FROM igel_images WHERE id=?');
|
||||
$row->execute([$id]);
|
||||
$times = $row->fetch(PDO::FETCH_ASSOC) ?: ['created_at'=>null,'taken_at'=>null];
|
||||
|
||||
$out[] = [
|
||||
'id' => $id,
|
||||
'url' => $url,
|
||||
'thumb_url' => $thumbUrl,
|
||||
'original_name' => $orig,
|
||||
'mime' => $mime,
|
||||
'size_bytes' => $size,
|
||||
'created_at' => (string)($times['created_at'] ?? ''),
|
||||
'taken_at' => (string)($times['taken_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
json($out, 201);
|
||||
}
|
||||
|
||||
function igel_images_delete(PDO $pdo, int $uid, int $imgId): void {
|
||||
// Besitz prüfen (Join)
|
||||
$stmt=$pdo->prepare('SELECT i.id, i.url, i.thumb_url
|
||||
FROM igel_images i
|
||||
JOIN igel g ON g.id = i.igel_id
|
||||
WHERE i.id=? AND g.user_id=?');
|
||||
$stmt->execute([$imgId,$uid]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) { json(['error'=>'Not found'],404); return; }
|
||||
|
||||
// Dateien optional entfernen
|
||||
try {
|
||||
$url = (string)$row['url'];
|
||||
$thumb = (string)($row['thumb_url'] ?? '');
|
||||
$fn = basename(parse_url($url, PHP_URL_PATH) ?? '');
|
||||
$fnT = $thumb ? basename(parse_url($thumb, PHP_URL_PATH) ?? '') : '';
|
||||
$p = rtrim(UPLOAD_DIR,'/').'/'.$fn;
|
||||
if (is_file($p)) @unlink($p);
|
||||
if ($fnT !== '') {
|
||||
$pt = rtrim(UPLOAD_THUMB_DIR,'/').'/'.$fnT;
|
||||
if (is_file($pt)) @unlink($pt);
|
||||
}
|
||||
} catch (Throwable $e) {}
|
||||
|
||||
$del=$pdo->prepare('DELETE FROM igel_images WHERE id=?');
|
||||
$del->execute([$imgId]);
|
||||
json(['ok'=>true]);
|
||||
}
|
||||
|
||||
// --- Thumbnail Helper (GD) ---------------------------------------------------
|
||||
|
||||
function create_thumbnail(string $src, string $dest, int $maxW, int $maxH): void {
|
||||
if (!extension_loaded('gd')) throw new Exception('GD not loaded');
|
||||
[$w,$h,$type] = getimagesize($src);
|
||||
if (!$w || !$h) throw new Exception('bad image');
|
||||
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG: $im = imagecreatefromjpeg($src); break;
|
||||
case IMAGETYPE_PNG: $im = imagecreatefrompng($src); break;
|
||||
case IMAGETYPE_WEBP: if (function_exists('imagecreatefromwebp')) { $im = imagecreatefromwebp($src); } else { throw new Exception('webp not supported'); } break;
|
||||
case IMAGETYPE_GIF: $im = imagecreatefromgif($src); break;
|
||||
default: throw new Exception('unsupported type');
|
||||
}
|
||||
|
||||
$ratio = min($maxW/$w, $maxH/$h, 1.0);
|
||||
$nw = (int)round($w*$ratio);
|
||||
$nh = (int)round($h*$ratio);
|
||||
$thumb = imagecreatetruecolor($nw, $nh);
|
||||
|
||||
// transparent für PNG/GIF
|
||||
if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF], true)) {
|
||||
imagecolortransparent($thumb, imagecolorallocatealpha($thumb, 0, 0, 0, 127));
|
||||
imagealphablending($thumb, false);
|
||||
imagesavealpha($thumb, true);
|
||||
}
|
||||
|
||||
imagecopyresampled($thumb, $im, 0,0,0,0, $nw,$nh,$w,$h);
|
||||
|
||||
$ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
|
||||
if ($ext === 'png') imagepng($thumb, $dest, 6);
|
||||
elseif ($ext === 'gif') imagegif($thumb, $dest);
|
||||
elseif ($ext === 'webp' && function_exists('imagewebp')) imagewebp($thumb, $dest, 85);
|
||||
else imagejpeg($thumb, $dest, 85);
|
||||
|
||||
imagedestroy($im);
|
||||
imagedestroy($thumb);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MESSWERTE
|
||||
// =============================================================================
|
||||
|
||||
function messwerte_list(PDO $pdo, int $uid, int $igId): void {
|
||||
// Besitz
|
||||
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
|
||||
$own->execute([$igId,$uid]);
|
||||
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
|
||||
|
||||
$stmt=$pdo->prepare('SELECT id, igel_id, DATE_FORMAT(datum, "%Y-%m-%dT%H:%i:%sZ") AS datum,
|
||||
gewicht, behandlung, bemerkung, created_at
|
||||
FROM messwerte
|
||||
WHERE igel_id=? ORDER BY datum DESC, id DESC');
|
||||
$stmt->execute([$igId]);
|
||||
json($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
function messwerte_create(PDO $pdo, int $uid, int $igId): void {
|
||||
// Besitz
|
||||
$own=$pdo->prepare('SELECT id FROM igel WHERE id=? AND user_id=?');
|
||||
$own->execute([$igId,$uid]);
|
||||
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
|
||||
|
||||
$in = body_json();
|
||||
$datumRaw = (string)($in['datum'] ?? '');
|
||||
$gewicht = (int)($in['gewicht'] ?? 0);
|
||||
$behandlung = isset($in['behandlung']) ? (string)$in['behandlung'] : null;
|
||||
$bemerkung = isset($in['bemerkung']) ? (string)$in['bemerkung'] : null;
|
||||
|
||||
// Datum akzeptiert ISO-8601 oder "YYYY-MM-DD HH:MM"
|
||||
$ts = $datumRaw !== '' ? strtotime($datumRaw) : time();
|
||||
if ($ts === false) { json(['error'=>'Invalid date'],422); return; }
|
||||
if ($gewicht < 1 || $gewicht > 100000) { json(['error'=>'Invalid weight'],422); return; }
|
||||
|
||||
$mysql = date('Y-m-d H:i:s', $ts);
|
||||
|
||||
$stmt=$pdo->prepare('INSERT INTO messwerte (igel_id, datum, gewicht, behandlung, bemerkung) VALUES(?,?,?,?,?)');
|
||||
$stmt->execute([$igId, $mysql, $gewicht, $behandlung, $bemerkung]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
|
||||
json([
|
||||
'id'=>$id,
|
||||
'igel_id'=>$igId,
|
||||
'datum'=>gmdate('Y-m-d\TH:i:s\Z', $ts),
|
||||
'gewicht'=>$gewicht,
|
||||
'behandlung'=>$behandlung,
|
||||
'bemerkung'=>$bemerkung
|
||||
], 201);
|
||||
}
|
||||
|
||||
function messwerte_update(PDO $pdo, int $uid, int $mid): void {
|
||||
// Besitz via Join prüfen
|
||||
$own=$pdo->prepare('SELECT m.id, m.igel_id FROM messwerte m JOIN igel g ON g.id=m.igel_id WHERE m.id=? AND g.user_id=?');
|
||||
$own->execute([$mid,$uid]);
|
||||
$row=$own->fetch(PDO::FETCH_ASSOC);
|
||||
if(!$row) { json(['error'=>'Not found'],404); return; }
|
||||
|
||||
$in = body_json();
|
||||
// Alle Felder optional, aber validieren, falls vorhanden
|
||||
$set = [];
|
||||
$args= [];
|
||||
|
||||
if (isset($in['datum'])) {
|
||||
$ts = strtotime((string)$in['datum']);
|
||||
if ($ts === false) { json(['error'=>'Invalid date'],422); return; }
|
||||
$set[]='datum=?'; $args[]=date('Y-m-d H:i:s',$ts);
|
||||
}
|
||||
if (isset($in['gewicht'])) {
|
||||
$gewicht=(int)$in['gewicht'];
|
||||
if ($gewicht < 1 || $gewicht > 100000) { json(['error'=>'Invalid weight'],422); return; }
|
||||
$set[]='gewicht=?'; $args[]=$gewicht;
|
||||
}
|
||||
if (array_key_exists('behandlung',$in)) { $set[]='behandlung=?'; $args[]=(string)$in['behandlung']; }
|
||||
if (array_key_exists('bemerkung',$in)) { $set[]='bemerkung=?'; $args[]=(string)$in['bemerkung']; }
|
||||
|
||||
if (empty($set)) { json(['error'=>'No fields'],400); return; }
|
||||
|
||||
$args[]=$mid;
|
||||
$sql='UPDATE messwerte SET '.implode(',', $set).' WHERE id=?';
|
||||
$stmt=$pdo->prepare($sql);
|
||||
$stmt->execute($args);
|
||||
json(['ok'=>true]);
|
||||
}
|
||||
|
||||
function messwerte_delete(PDO $pdo, int $uid, int $mid): void {
|
||||
// Besitz via Join prüfen
|
||||
$own=$pdo->prepare('SELECT m.id FROM messwerte m JOIN igel g ON g.id=m.igel_id WHERE m.id=? AND g.user_id=?');
|
||||
$own->execute([$mid,$uid]);
|
||||
if(!$own->fetch()) { json(['error'=>'Not found'],404); return; }
|
||||
|
||||
$del=$pdo->prepare('DELETE FROM messwerte WHERE id=?');
|
||||
$del->execute([$mid]);
|
||||
json(['ok'=>true]);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utilities
|
||||
// =============================================================================
|
||||
|
||||
function json($data, int $code = 200): void {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
function body_json(): array {
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') return [];
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
function db(): PDO {
|
||||
$dsn = 'mysql:host=' . DATABASE_HOST . ';dbname=' . DATABASE_NAME . ';charset=utf8mb4';
|
||||
$pdo = new PDO($dsn, DATABASE_USER, DATABASE_PASSWORD, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function origin_allowed(string $origin, array $allowed): bool {
|
||||
$u = parse_url($origin);
|
||||
if (!$u || !isset($u['scheme'], $u['host'])) return false;
|
||||
$oScheme = $u['scheme']; $oHost = $u['host']; $oPort = (string)($u['port'] ?? '');
|
||||
foreach ($allowed as $pat) {
|
||||
$pu = parse_url($pat);
|
||||
if (!$pu || !isset($pu['scheme'])) continue;
|
||||
if ($pu['scheme'] !== $oScheme) continue;
|
||||
$pHost = $pu['host'] ?? '';
|
||||
$pPort = $pu['port'] ?? '';
|
||||
$hostOk = false;
|
||||
if ($pHost === $oHost) $hostOk = true;
|
||||
elseif (str_starts_with($pHost, '*.' )) { $suffix = substr($pHost, 1); if (str_ends_with($oHost, $suffix)) $hostOk = true; }
|
||||
elseif ($pHost === '' && isset($pu['path'])) {
|
||||
$p = $pu['path']; // z.B. localhost:*
|
||||
if ($p === $oHost || (str_starts_with($p, '*.') && str_ends_with($oHost, substr($p,1)))) $hostOk = true;
|
||||
}
|
||||
if (!$hostOk) continue;
|
||||
$patHasWildcardPort = str_ends_with($pat, ':*');
|
||||
$portOk = $patHasWildcardPort || ($pPort !== '' && (string)$pPort === $oPort) || ($pPort === '' && $oPort === '');
|
||||
if ($portOk) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Minimal JWT HS256 -------------------------------------------------------
|
||||
|
||||
function b64url_encode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
|
||||
function b64url_decode(string $data): string { return base64_decode(strtr($data, '-_', '+/')) ?: ''; }
|
||||
|
||||
function jwt_encode(array $payload, string $secret): string {
|
||||
$header = ['typ' => 'JWT', 'alg' => 'HS256'];
|
||||
$segments = [b64url_encode(json_encode($header)), b64url_encode(json_encode($payload))];
|
||||
$signingInput = implode('.', $segments);
|
||||
$signature = hash_hmac('sha256', $signingInput, $secret, true);
|
||||
$segments[] = b64url_encode($signature);
|
||||
return implode('.', $segments);
|
||||
}
|
||||
|
||||
function jwt_decode(string $token, string $secret): array {
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) throw new Exception('bad token');
|
||||
[$h64, $p64, $s64] = $parts;
|
||||
$header = json_decode(b64url_decode($h64), true) ?: [];
|
||||
if (($header['alg'] ?? '') !== 'HS256') throw new Exception('alg');
|
||||
$payload = json_decode(b64url_decode($p64), true) ?: [];
|
||||
$sig = b64url_decode($s64);
|
||||
$expected = hash_hmac('sha256', "$h64.$p64", $secret, true);
|
||||
if (!hash_equals($expected, $sig)) throw new Exception('sig');
|
||||
if (isset($payload['exp']) && time() >= (int)$payload['exp']) throw new Exception('exp');
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/*
|
||||
-- SQL Reference (run once)
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
email VARCHAR(191) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE refresh_tokens (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX (user_id), INDEX (token)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE igel (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
gender VARCHAR(30) NULL,
|
||||
feature VARCHAR(255) NULL,
|
||||
note TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX (user_id), INDEX (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE igel_images (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
igel_id BIGINT UNSIGNED NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
thumb_url VARCHAR(500) NULL,
|
||||
original_name VARCHAR(255) NULL,
|
||||
mime VARCHAR(100) NULL,
|
||||
size_bytes BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE,
|
||||
INDEX (igel_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE messwerte (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
igel_id BIGINT UNSIGNED NOT NULL,
|
||||
datum DATETIME NOT NULL,
|
||||
gewicht INT UNSIGNED NOT NULL,
|
||||
behandlung VARCHAR(255) NULL,
|
||||
bemerkung TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (igel_id) REFERENCES igel(id) ON DELETE CASCADE,
|
||||
INDEX (igel_id), INDEX (datum)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
*/
|
||||
?>
|
||||
@@ -0,0 +1,250 @@
|
||||
-- phpMyAdmin SQL Dump
|
||||
-- version 5.1.1deb5ubuntu1
|
||||
-- https://www.phpmyadmin.net/
|
||||
--
|
||||
-- Host: localhost:3306
|
||||
-- Generation Time: Oct 23, 2025 at 06:57 PM
|
||||
-- Server version: 10.6.22-MariaDB-ubu2204-log
|
||||
-- PHP Version: 8.2.28
|
||||
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
START TRANSACTION;
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
|
||||
--
|
||||
-- Database: `hedgehogs`
|
||||
--
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `email_verifications`
|
||||
--
|
||||
|
||||
CREATE TABLE `email_verifications` (
|
||||
`id` bigint(20) UNSIGNED NOT NULL,
|
||||
`user_id` bigint(20) UNSIGNED NOT NULL,
|
||||
`token` varchar(255) NOT NULL,
|
||||
`expires_at` datetime NOT NULL,
|
||||
`sent_at` datetime NOT NULL DEFAULT current_timestamp(),
|
||||
`used_at` datetime DEFAULT NULL,
|
||||
`ip` varchar(64) DEFAULT NULL,
|
||||
`user_agent` varchar(255) DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `igel`
|
||||
--
|
||||
|
||||
CREATE TABLE `igel` (
|
||||
`id` bigint(20) UNSIGNED NOT NULL,
|
||||
`user_id` bigint(20) UNSIGNED NOT NULL,
|
||||
`name` varchar(120) NOT NULL,
|
||||
`gender` varchar(10) DEFAULT NULL,
|
||||
`note` text DEFAULT NULL,
|
||||
`feature` text DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `igel_images`
|
||||
--
|
||||
|
||||
CREATE TABLE `igel_images` (
|
||||
`id` bigint(20) UNSIGNED NOT NULL,
|
||||
`igel_id` bigint(20) UNSIGNED NOT NULL,
|
||||
`url` varchar(500) NOT NULL,
|
||||
`thumb_url` varchar(500) DEFAULT NULL,
|
||||
`original_name` varchar(255) DEFAULT NULL,
|
||||
`mime` varchar(100) DEFAULT NULL,
|
||||
`size_bytes` bigint(20) UNSIGNED DEFAULT NULL,
|
||||
`taken_at` datetime DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `messwerte`
|
||||
--
|
||||
|
||||
CREATE TABLE `messwerte` (
|
||||
`id` bigint(20) UNSIGNED NOT NULL,
|
||||
`igel_id` bigint(20) UNSIGNED NOT NULL,
|
||||
`datum` datetime NOT NULL,
|
||||
`gewicht` int(10) UNSIGNED NOT NULL,
|
||||
`behandlung` varchar(255) DEFAULT NULL,
|
||||
`bemerkung` text DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `refresh_tokens`
|
||||
--
|
||||
|
||||
CREATE TABLE `refresh_tokens` (
|
||||
`id` bigint(20) UNSIGNED NOT NULL,
|
||||
`user_id` bigint(20) UNSIGNED NOT NULL,
|
||||
`token` varchar(255) NOT NULL,
|
||||
`expires_at` datetime NOT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
|
||||
--
|
||||
-- Table structure for table `users`
|
||||
--
|
||||
|
||||
CREATE TABLE `users` (
|
||||
`id` bigint(20) UNSIGNED NOT NULL,
|
||||
`email` varchar(191) NOT NULL,
|
||||
`password_hash` varchar(255) NOT NULL,
|
||||
`verified_at` datetime DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT current_timestamp()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
|
||||
--
|
||||
-- Indexes for dumped tables
|
||||
--
|
||||
|
||||
--
|
||||
-- Indexes for table `email_verifications`
|
||||
--
|
||||
ALTER TABLE `email_verifications`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `token` (`token`),
|
||||
ADD KEY `user_id` (`user_id`),
|
||||
ADD KEY `token_2` (`token`);
|
||||
|
||||
--
|
||||
-- Indexes for table `igel`
|
||||
--
|
||||
ALTER TABLE `igel`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `user_id` (`user_id`),
|
||||
ADD KEY `name` (`name`);
|
||||
|
||||
--
|
||||
-- Indexes for table `igel_images`
|
||||
--
|
||||
ALTER TABLE `igel_images`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `igel_id` (`igel_id`),
|
||||
ADD KEY `idx_igel_images_taken_at` (`taken_at`);
|
||||
|
||||
--
|
||||
-- Indexes for table `messwerte`
|
||||
--
|
||||
ALTER TABLE `messwerte`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD KEY `igel_id` (`igel_id`),
|
||||
ADD KEY `datum` (`datum`);
|
||||
|
||||
--
|
||||
-- Indexes for table `refresh_tokens`
|
||||
--
|
||||
ALTER TABLE `refresh_tokens`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `token` (`token`),
|
||||
ADD KEY `user_id` (`user_id`),
|
||||
ADD KEY `token_2` (`token`);
|
||||
|
||||
--
|
||||
-- Indexes for table `users`
|
||||
--
|
||||
ALTER TABLE `users`
|
||||
ADD PRIMARY KEY (`id`),
|
||||
ADD UNIQUE KEY `email` (`email`);
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for dumped tables
|
||||
--
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `email_verifications`
|
||||
--
|
||||
ALTER TABLE `email_verifications`
|
||||
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `igel`
|
||||
--
|
||||
ALTER TABLE `igel`
|
||||
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `igel_images`
|
||||
--
|
||||
ALTER TABLE `igel_images`
|
||||
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `messwerte`
|
||||
--
|
||||
ALTER TABLE `messwerte`
|
||||
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `refresh_tokens`
|
||||
--
|
||||
ALTER TABLE `refresh_tokens`
|
||||
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- AUTO_INCREMENT for table `users`
|
||||
--
|
||||
ALTER TABLE `users`
|
||||
MODIFY `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT;
|
||||
|
||||
--
|
||||
-- Constraints for dumped tables
|
||||
--
|
||||
|
||||
--
|
||||
-- Constraints for table `email_verifications`
|
||||
--
|
||||
ALTER TABLE `email_verifications`
|
||||
ADD CONSTRAINT `email_verifications_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `igel`
|
||||
--
|
||||
ALTER TABLE `igel`
|
||||
ADD CONSTRAINT `igel_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `igel_images`
|
||||
--
|
||||
ALTER TABLE `igel_images`
|
||||
ADD CONSTRAINT `igel_images_ibfk_1` FOREIGN KEY (`igel_id`) REFERENCES `igel` (`id`) ON DELETE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `messwerte`
|
||||
--
|
||||
ALTER TABLE `messwerte`
|
||||
ADD CONSTRAINT `messwerte_ibfk_1` FOREIGN KEY (`igel_id`) REFERENCES `igel` (`id`) ON DELETE CASCADE;
|
||||
|
||||
--
|
||||
-- Constraints for table `refresh_tokens`
|
||||
--
|
||||
ALTER TABLE `refresh_tokens`
|
||||
ADD CONSTRAINT `refresh_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
|
||||
COMMIT;
|
||||
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
@@ -2,46 +2,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../shared/api_client.dart';
|
||||
|
||||
class IgelImage {
|
||||
final int id;
|
||||
final String url;
|
||||
final String? thumbUrl;
|
||||
final String? originalName;
|
||||
final String? mime;
|
||||
final int? sizeBytes;
|
||||
final DateTime? createdAt; // 🆕
|
||||
|
||||
IgelImage({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.thumbUrl,
|
||||
this.originalName,
|
||||
this.mime,
|
||||
this.sizeBytes,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory IgelImage.fromMap(Map<String, dynamic> m) {
|
||||
// created_at kann als "YYYY-MM-DD HH:mm:ss" kommen
|
||||
DateTime? parsed;
|
||||
final raw = m['created_at'];
|
||||
if (raw is String && raw.isNotEmpty) {
|
||||
parsed = DateTime.tryParse(raw) ??
|
||||
DateTime.tryParse(raw.replaceFirst(' ', 'T'));
|
||||
}
|
||||
|
||||
return IgelImage(
|
||||
id: (m['id'] as num).toInt(),
|
||||
url: (m['url'] ?? '') as String,
|
||||
thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?,
|
||||
originalName: m['original_name'] as String?,
|
||||
mime: m['mime'] as String?,
|
||||
sizeBytes: (m['size_bytes'] as num?)?.toInt(),
|
||||
createdAt: parsed,
|
||||
);
|
||||
}
|
||||
}
|
||||
import '../../igel/domain/igel_image.dart';
|
||||
|
||||
class IgelImagesRepository {
|
||||
final ApiClient api;
|
||||
@@ -60,7 +21,10 @@ class IgelImagesRepository {
|
||||
|
||||
/// Multipart-Upload. Gibt die neu hochgeladenen Bilder zurück.
|
||||
Future<List<IgelImage>> upload(
|
||||
int igelId, List<http.MultipartFile> files) async {
|
||||
int igelId,
|
||||
List<http.MultipartFile> files, {
|
||||
List<DateTime?>? takenAt, // 🆕 optional: Aufnahmedaten pro Bild
|
||||
}) async {
|
||||
final uri = Uri.parse('${api.baseUrl}/igel/$igelId/images');
|
||||
final req = http.MultipartRequest('POST', uri);
|
||||
|
||||
@@ -69,19 +33,28 @@ class IgelImagesRepository {
|
||||
req.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
// WICHTIG: PHP erwartet ein Array-Feld: "files[]"
|
||||
// Wir bauen für jedes geleiferte MultipartFile ein NEUES mit dem Namen "files[]".
|
||||
// Dateien (Name MUSS "files[]" sein!)
|
||||
for (final f in files) {
|
||||
final mf = http.MultipartFile(
|
||||
'files[]', // <-- entscheidend!
|
||||
f.finalize(), // Stream vom bestehenden MultipartFile übernehmen
|
||||
f.length, // Länge übernehmen (ist bei http >=1.x ein int)
|
||||
'files[]',
|
||||
f.finalize(),
|
||||
f.length,
|
||||
filename: f.filename,
|
||||
contentType: f.contentType,
|
||||
);
|
||||
req.files.add(mf);
|
||||
}
|
||||
|
||||
// Aufnahmedaten als paralleles Array "taken_at[]"
|
||||
if (takenAt != null && takenAt.isNotEmpty) {
|
||||
for (final dt in takenAt) {
|
||||
req.files.add(http.MultipartFile.fromString(
|
||||
'taken_at[]',
|
||||
dt?.toIso8601String() ?? '',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final streamRes = await req.send();
|
||||
final body = await streamRes.stream.bytesToString();
|
||||
|
||||
@@ -95,7 +68,7 @@ class IgelImagesRepository {
|
||||
final uploaded = list
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(IgelImage.fromMap)
|
||||
// Fallback, wenn der Upload-Response kein created_at enthält:
|
||||
// Falls Upload-Response kein created_at liefert: fallback auf now()
|
||||
.map((img) => img.createdAt == null
|
||||
? IgelImage(
|
||||
id: img.id,
|
||||
@@ -109,12 +82,9 @@ class IgelImagesRepository {
|
||||
: img)
|
||||
.toList();
|
||||
|
||||
// Sicherheitsleine: Wenn der Server nichts verarbeitet hat, als Fehler behandeln.
|
||||
if (uploaded.isEmpty) {
|
||||
throw ApiException(
|
||||
500,
|
||||
'Upload fehlgeschlagen: Server hat keine Dateien empfangen (prüfe Feldname "files[]").',
|
||||
);
|
||||
500, 'Upload leer: Server hat keine Dateien übernommen.');
|
||||
}
|
||||
|
||||
return uploaded;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// lib/features/igel/domain/igel_image.dart
|
||||
class IgelImage {
|
||||
final int id;
|
||||
final String url;
|
||||
@@ -5,9 +6,14 @@ class IgelImage {
|
||||
final String? originalName;
|
||||
final String? mime;
|
||||
final int? sizeBytes;
|
||||
final DateTime? createdAt; // 🆕
|
||||
|
||||
IgelImage({
|
||||
/// Upload-Zeit auf dem Server (DB: created_at)
|
||||
final DateTime? createdAt;
|
||||
|
||||
/// Aufnahmezeit laut EXIF (DB: taken_at) – bevorzugt für die Anzeige
|
||||
final DateTime? takenAt;
|
||||
|
||||
const IgelImage({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.thumbUrl,
|
||||
@@ -15,24 +21,66 @@ class IgelImage {
|
||||
this.mime,
|
||||
this.sizeBytes,
|
||||
this.createdAt,
|
||||
this.takenAt,
|
||||
});
|
||||
|
||||
factory IgelImage.fromMap(Map<String, dynamic> m) => IgelImage(
|
||||
id: (m['id'] as num).toInt(),
|
||||
url: (m['url'] ?? '') as String,
|
||||
thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?,
|
||||
originalName: m['original_name'] as String?,
|
||||
mime: m['mime'] as String?,
|
||||
sizeBytes: (m['size_bytes'] as num?)?.toInt(),
|
||||
// PHP liefert created_at (TIMESTAMP) bei GET /igel/{id}/images
|
||||
createdAt: (() {
|
||||
final s = m['created_at'];
|
||||
if (s is String && s.isNotEmpty) {
|
||||
// z.B. "2025-10-21 12:34:56"
|
||||
return DateTime.tryParse(s) ??
|
||||
DateTime.tryParse(s.replaceFirst(' ', 'T'));
|
||||
}
|
||||
return null;
|
||||
})(),
|
||||
);
|
||||
static DateTime? _parseDate(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is DateTime) return v;
|
||||
if (v is String && v.isNotEmpty) {
|
||||
// akzeptiere "YYYY-MM-DD HH:MM:SS" (MySQL) und ISO-8601
|
||||
final s1 = DateTime.tryParse(v);
|
||||
if (s1 != null) return s1;
|
||||
// MySQL „YYYY-MM-DD HH:MM:SS“ -> „YYYY-MM-DDTHH:MM:SS“
|
||||
final s2 = DateTime.tryParse(v.replaceFirst(' ', 'T'));
|
||||
if (s2 != null) return s2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
factory IgelImage.fromMap(Map<String, dynamic> m) {
|
||||
return IgelImage(
|
||||
id: (m['id'] as num).toInt(),
|
||||
url: (m['url'] ?? '') as String,
|
||||
thumbUrl: (m['thumb_url'] ?? m['thumbUrl']) as String?,
|
||||
originalName: m['original_name'] as String?,
|
||||
mime: m['mime'] as String?,
|
||||
sizeBytes: (m['size_bytes'] as num?)?.toInt(),
|
||||
createdAt: _parseDate(m['created_at']),
|
||||
takenAt: _parseDate(m['taken_at']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'url': url,
|
||||
if (thumbUrl != null) 'thumb_url': thumbUrl,
|
||||
if (originalName != null) 'original_name': originalName,
|
||||
if (mime != null) 'mime': mime,
|
||||
if (sizeBytes != null) 'size_bytes': sizeBytes,
|
||||
if (createdAt != null) 'created_at': createdAt!.toIso8601String(),
|
||||
if (takenAt != null) 'taken_at': takenAt!.toIso8601String(),
|
||||
};
|
||||
|
||||
IgelImage copyWith({
|
||||
int? id,
|
||||
String? url,
|
||||
String? thumbUrl,
|
||||
String? originalName,
|
||||
String? mime,
|
||||
int? sizeBytes,
|
||||
DateTime? createdAt,
|
||||
DateTime? takenAt,
|
||||
}) {
|
||||
return IgelImage(
|
||||
id: id ?? this.id,
|
||||
url: url ?? this.url,
|
||||
thumbUrl: thumbUrl ?? this.thumbUrl,
|
||||
originalName: originalName ?? this.originalName,
|
||||
mime: mime ?? this.mime,
|
||||
sizeBytes: sizeBytes ?? this.sizeBytes,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
takenAt: takenAt ?? this.takenAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// lib/features/igel/presentation/igel_detail_screen.dart
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:exif/exif.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -15,6 +16,7 @@ import '../../../main.dart';
|
||||
import '../../igel/data/igel_images_repository.dart';
|
||||
import '../../igel/data/igel_repository.dart';
|
||||
import '../domain/igel.dart';
|
||||
import '../domain/igel_image.dart';
|
||||
|
||||
// Messwerte
|
||||
import '../../messwerte/domain/messwert.dart';
|
||||
@@ -173,21 +175,46 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
try {
|
||||
final picker = ImagePicker();
|
||||
final picks = await picker.pickMultiImage(
|
||||
maxWidth: 4096, maxHeight: 4096, imageQuality: 90);
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
imageQuality: 90,
|
||||
);
|
||||
if (picks.isEmpty) return;
|
||||
|
||||
final files = <http.MultipartFile>[];
|
||||
final taken = <DateTime?>[];
|
||||
|
||||
for (final x in picks) {
|
||||
// 1) EXIF-Aufnahmezeit lesen
|
||||
final exifDt = await _readExifTakenAt(x);
|
||||
taken.add(exifDt);
|
||||
|
||||
// 2) Datei-Part bauen (Name MUSS 'files[]' sein; Typ aus Dateiendung)
|
||||
if (kIsWeb) {
|
||||
final bytes = await x.readAsBytes();
|
||||
files.add(http.MultipartFile.fromBytes('files[]', bytes,
|
||||
filename: x.name, contentType: _mimeFromName(x.name)));
|
||||
files.add(http.MultipartFile.fromBytes(
|
||||
'files[]',
|
||||
bytes,
|
||||
filename: x.name,
|
||||
contentType: _mimeFromName(x.name), // nutzt deine bestehende Helper
|
||||
));
|
||||
} else {
|
||||
files.add(
|
||||
await http.MultipartFile.fromPath('files[]', File(x.path).path));
|
||||
files.add(await http.MultipartFile.fromPath(
|
||||
'files[]',
|
||||
File(x.path).path,
|
||||
contentType: _mimeFromName(x.name),
|
||||
));
|
||||
}
|
||||
}
|
||||
await imagesRepo.upload(widget.igelId, files);
|
||||
|
||||
// 3) Upload + taken_at[] mitgeben
|
||||
await imagesRepo.upload(
|
||||
widget.igelId,
|
||||
files,
|
||||
takenAt: taken, // <- entscheidend
|
||||
);
|
||||
|
||||
// 4) Liste neu laden & UI aktualisieren
|
||||
images = await imagesRepo.list(widget.igelId);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
@@ -240,6 +267,44 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<DateTime?> _readExifTakenAt(XFile x) async {
|
||||
try {
|
||||
// Bytes laden (Web & Mobile kompatibel)
|
||||
final bytes = await x.readAsBytes();
|
||||
final tags = await readExifFromBytes(bytes);
|
||||
|
||||
// Reihenfolge: Original -> Digitized -> Image DateTime (Fallback)
|
||||
final raw = tags['EXIF DateTimeOriginal']?.printable ??
|
||||
tags['EXIF DateTimeDigitized']?.printable ??
|
||||
tags['Image DateTime']?.printable;
|
||||
|
||||
if (raw is String && raw.isNotEmpty) {
|
||||
// EXIF-Format: "YYYY:MM:DD HH:MM:SS"
|
||||
// -> normalisieren auf "YYYY-MM-DDTHH:MM:SS"
|
||||
// erste zwei ':' in Datum durch '-' ersetzen, Space zu 'T'
|
||||
var s = raw;
|
||||
// die ersten beiden ':' ersetzen (Jahr:Monat:Tag)
|
||||
final first = s.indexOf(':');
|
||||
if (first > 0) {
|
||||
final second = s.indexOf(':', first + 1);
|
||||
if (second > 0) {
|
||||
s = s.substring(0, first) +
|
||||
'-' +
|
||||
s.substring(first + 1, second) +
|
||||
'-' +
|
||||
s.substring(second + 1);
|
||||
}
|
||||
}
|
||||
s = s.replaceFirst(' ', 'T');
|
||||
// jetzt sollte es parsebar sein
|
||||
return DateTime.tryParse(s);
|
||||
}
|
||||
} catch (_) {
|
||||
// EXIF fehlt oder nicht lesbar → null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData? _genderIcon(String? g) {
|
||||
switch (g) {
|
||||
case 'männlich':
|
||||
@@ -788,7 +853,12 @@ class _IgelDetailState extends ConsumerState<IgelDetailScreen> {
|
||||
final thumb = img.thumbUrl ?? img.url;
|
||||
|
||||
final String? ts = (() {
|
||||
final dt = img.createdAt;
|
||||
final dt =
|
||||
img.takenAt ?? img.createdAt;
|
||||
final String? ts = dt != null
|
||||
? DateFormat('dd.MM.yyyy, HH:mm')
|
||||
.format(dt)
|
||||
: null;
|
||||
if (dt == null) return null;
|
||||
// z.B. 23.10.2025, 14:05
|
||||
return DateFormat('dd.MM.yyyy, HH:mm')
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../main.dart';
|
||||
import '../../igel/data/igel_images_repository.dart';
|
||||
import '../domain/igel_image.dart';
|
||||
|
||||
class IgelGalleryScreen extends ConsumerStatefulWidget {
|
||||
const IgelGalleryScreen(
|
||||
|
||||
@@ -182,6 +182,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.7"
|
||||
exif:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: exif
|
||||
sha256: a7980fdb3b7ffcd0b035e5b8a5e1eef7cadfe90ea6a4e85ebb62f87b96c7a172
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -693,6 +701,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies:
|
||||
image_picker: ^1.0.7
|
||||
intl: ^0.20.2
|
||||
http_parser: ^4.0.2
|
||||
exif: ^3.3.0
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.11
|
||||
|
||||
Reference in New Issue
Block a user