philippurbschat.de/plants/api.php
Philipp Urbschat f393beb7ff
feat: inkubator-standard & modul-autodiscovery
- Dynamische Modulerkennung via module.json in ModuleService.php
- Kaskadierender Env-Loader fuer zentrale API-Keys (GEMINI_API_KEY)
- Graceful Auth fuer alle Subprojekte (dinos, plants, storymachine, test)
- Wiederverwendbares Starter-Template in _template/
- test-Modul auf philippurbschat.de Dark-Design modernisiert
2026-09-13 00:23:16 +02:00

382 lines
22 KiB
PHP

<?php
$authFile = __DIR__ . '/../auth.php';
if (file_exists($authFile)) {
$current_project = basename(__DIR__);
require_once $authFile;
}
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
require_once 'db.php';
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
if (file_exists(__DIR__ . '/.env') && class_exists('Dotenv\Dotenv')) {
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->safeLoad();
}
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['error' => 'Benutzer nicht authentifiziert.']);
exit;
}
$currentUserId = $_SESSION['user_id'];
// === HELPER FUNCTIONS ===
function callGeminiAPI(array $payload, string $apiKey): array {
$googleApiUrl = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
$fullApiUrl = $googleApiUrl . '?key=' . $apiKey;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $fullApiUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90,
]);
$response_body = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch) || $http_code >= 400) {
$error_message = curl_errno($ch) ? curl_error($ch) : 'KI-API-Fehler mit Status ' . $http_code . ' - ' . $response_body;
curl_close($ch);
throw new Exception('Fehler bei der Kommunikation mit der KI: ' . $error_message);
}
curl_close($ch);
return json_decode($response_body, true);
}
function processUploadedPhoto(array $fileInfo, int $targetWidth = 800): ?string {
if ($fileInfo['error'] !== UPLOAD_ERR_OK) return null;
$allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array(mime_content_type($fileInfo['tmp_name']), $allowedMimeTypes) || $fileInfo['size'] > (10 * 1024 * 1024)) return null;
$uploadDir = 'uploads/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
$fileName = uniqid() . '-' . pathinfo($fileInfo['name'], PATHINFO_FILENAME) . '.webp';
$targetFilePath = $uploadDir . $fileName;
$sourceImage = null;
switch (exif_imagetype($fileInfo['tmp_name'])) {
case IMAGETYPE_JPEG: $sourceImage = imagecreatefromjpeg($fileInfo['tmp_name']); break;
case IMAGETYPE_PNG: $sourceImage = imagecreatefrompng($fileInfo['tmp_name']); break;
case IMAGETYPE_WEBP: $sourceImage = imagecreatefromwebp($fileInfo['tmp_name']); break;
}
if (!$sourceImage) return null;
$resizedImage = (imagesx($sourceImage) > $targetWidth) ? imagescale($sourceImage, $targetWidth) : $sourceImage;
imagewebp($resizedImage, $targetFilePath, 85);
imagedestroy($sourceImage);
if ($resizedImage !== $sourceImage) imagedestroy($resizedImage);
return $targetFilePath;
}
function getPlantWithDueDates($pdo, $plantId, $userId) {
$stmt = $pdo->prepare("SELECT p.id, p.name, p.latin_name, p.photo, p.last_watered, p.last_fertilized, p.watering_interval_days, p.fertilizing_interval_days, p.care_tips FROM plants p WHERE p.id = ? AND p.user_id = ?");
$stmt->execute([$plantId, $userId]);
$plant = $stmt->fetch(PDO::FETCH_ASSOC);
if ($plant) {
$plant['watering_interval'] = $plant['watering_interval_days'];
$plant['fertilizing_interval'] = $plant['fertilizing_interval_days'];
$isValidDate = fn($dateStr) => !empty($dateStr) && $dateStr !== '0000-00-00';
if ((int)$plant['watering_interval'] > 0 && $isValidDate($plant['last_watered'])) {
$plant['next_watering_due'] = (new DateTime($plant['last_watered']))->add(new DateInterval('P' . $plant['watering_interval'] . 'D'))->format('Y-m-d');
} else { $plant['next_watering_due'] = null; }
if ((int)$plant['fertilizing_interval'] > 0 && $isValidDate($plant['last_fertilized'])) {
$plant['next_fertilizing_due'] = (new DateTime($plant['last_fertilized']))->add(new DateInterval('P' . $plant['fertilizing_interval'] . 'D'))->format('Y-m-d');
} else { $plant['next_fertilizing_due'] = null; }
}
return $plant;
}
// === CREDIT MANAGEMENT ===
try {
$stmt = $pdo->prepare("SELECT ai_credits_last_reset FROM users WHERE id = ?");
$stmt->execute([$currentUserId]);
$lastReset = $stmt->fetchColumn();
$now = new DateTime();
if ($lastReset === null || (new DateTime($lastReset))->format('Y-m') < $now->format('Y-m')) {
$stmt = $pdo->prepare("UPDATE users SET ai_credits = 100, ai_credits_last_reset = NOW() WHERE id = ?");
$stmt->execute([$currentUserId]);
}
} catch (Exception $e) { /* non-blocking */ }
// === API ROUTING ===
$method = $_SERVER['REQUEST_METHOD'];
$json_data = json_decode(file_get_contents('php://input'), true);
header('Content-Type: application/json');
try {
// --- Handle JSON-based POST requests FIRST ---
if ($method === 'POST' && !empty($json_data)) {
$request_type = $json_data['request_type'] ?? '';
switch ($request_type) {
case 'ai_identification':
case 'ai_enrichment':
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE users SET ai_credits = ai_credits - 1 WHERE id = ? AND ai_credits >= 1");
$stmt->execute([$currentUserId]);
$geminiKey = $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: '';
if (empty($geminiKey) && file_exists(__DIR__ . '/../home/.env')) {
foreach (file(__DIR__ . '/../home/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') !== false) {
list($k, $v) = explode('=', $line, 2);
if (trim($k) === 'GEMINI_API_KEY') {
$geminiKey = trim(trim($v), '"\'');
break;
}
}
}
}
$response_data = callGeminiAPI($json_data['payload'], $geminiKey);
$pdo->commit();
echo json_encode($response_data);
break;
case 'update_user_settings':
$settings = $json_data['settings'] ?? [];
$allowedKeys = ['theme', 'allow_photo_sharing', 'auto_fill_watering', 'auto_fill_fertilizing', 'auto_fill_care_tips'];
$sqlParts = []; $params = [];
foreach ($settings as $key => $value) {
if (in_array($key, $allowedKeys)) {
$sqlParts[] = "$key = ?";
$params[] = is_bool($value) ? (int)$value : $value;
}
}
if (empty($sqlParts)) throw new Exception('Keine gültigen Einstellungsfelder.', 400);
$params[] = $currentUserId;
$stmt = $pdo->prepare("UPDATE users SET " . implode(', ', $sqlParts) . " WHERE id = ?");
$stmt->execute($params);
echo json_encode(['success' => true]);
break;
case 'care_action':
$plantId = $json_data['id'];
$column = ($json_data['action'] === 'water') ? 'last_watered' : 'last_fertilized';
$entryType = ($json_data['action'] === 'water') ? 'watered' : 'fertilized';
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE plants SET $column = CURDATE() WHERE id = ? AND user_id = ?");
$stmt->execute([$plantId, $currentUserId]);
$stmt = $pdo->prepare("INSERT INTO plant_diary (plant_id, user_id, entry_type) VALUES (?, ?, ?)");
$stmt->execute([$plantId, $currentUserId, $entryType]);
$pdo->commit();
echo json_encode(['success' => true, 'plant' => getPlantWithDueDates($pdo, $plantId, $currentUserId)]);
break;
case 'set_profile_picture':
$stmt = $pdo->prepare("UPDATE users SET profile_image = ? WHERE id = ?");
$stmt->execute([$json_data['photo_path'], $currentUserId]);
echo json_encode(['success' => true, 'new_image' => $json_data['photo_path']]);
break;
default:
throw new Exception('Unbekannter JSON-Request-Typ.', 400);
}
exit;
}
// --- Handle GET, DELETE, and multipart/form-data POST requests ---
switch ($method) {
case 'GET':
$action = $_GET['action'] ?? 'get_plants';
switch ($action) {
case 'get_plants':
$stmt = $pdo->prepare("SELECT id FROM plants WHERE user_id = ? ORDER BY id DESC");
$stmt->execute([$currentUserId]);
$plant_ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
echo json_encode(array_map(fn($id) => getPlantWithDueDates($pdo, $id, $currentUserId), $plant_ids));
break;
case 'get_diary':
$stmt = $pdo->prepare("SELECT * FROM plant_diary WHERE plant_id = ? AND user_id = ? ORDER BY entry_date DESC");
$stmt->execute([$_GET['plant_id'], $currentUserId]);
echo json_encode($stmt->fetchAll());
break;
case 'get_user_settings':
$stmt = $pdo->prepare("SELECT username, email, profile_image, theme, allow_photo_sharing, ai_credits, auto_fill_watering, auto_fill_fertilizing, auto_fill_care_tips FROM users WHERE id = ?");
$stmt->execute([$currentUserId]);
$userSettings = $stmt->fetch();
$stmt_count = $pdo->prepare("SELECT COUNT(id) as plant_count FROM plants WHERE user_id = ?");
$stmt_count->execute([$currentUserId]);
$userSettings['plant_count'] = $stmt_count->fetchColumn();
echo json_encode($userSettings);
break;
case 'get_lexicon_entries':
$stmt = $pdo->prepare("SELECT pl.name, pl.latin_name, lp.photo_path as photo_url FROM plant_lexicon pl LEFT JOIN (SELECT plant_latin_name, photo_path, ROW_NUMBER() OVER(PARTITION BY plant_latin_name ORDER BY ai_photo_quality_score DESC, id DESC) as rn FROM lexicon_photos WHERE status = 'approved') lp ON pl.latin_name = lp.plant_latin_name AND lp.rn = 1 WHERE pl.entry_count > 0 ORDER BY pl.name ASC");
$stmt->execute();
echo json_encode($stmt->fetchAll());
break;
case 'get_lexicon_entry_details':
$latinName = $_GET['latin_name'] ?? '';
if (empty($latinName)) throw new Exception('Kein botanischer Name angegeben.', 400);
$stmt = $pdo->prepare("SELECT * FROM plant_lexicon WHERE latin_name = ?");
$stmt->execute([$latinName]);
$plantDetails = $stmt->fetch();
if (!$plantDetails) throw new Exception('Pflanze nicht im Lexikon gefunden.', 404);
$stmtPhotos = $pdo->prepare("SELECT photo_path FROM lexicon_photos WHERE plant_latin_name = ? AND status = 'approved' ORDER BY ai_photo_quality_score DESC, id DESC LIMIT 10");
$stmtPhotos->execute([$latinName]);
$plantDetails['photo_gallery'] = $stmtPhotos->fetchAll(PDO::FETCH_COLUMN);
echo json_encode($plantDetails);
break;
default:
throw new Exception('Ungültige GET-Aktion.', 400);
}
break;
case 'POST':
$action = $_POST['action'] ?? 'save_plant';
switch ($action) {
case 'add_diary_entry':
$photoPath = isset($_FILES['photo']) ? processUploadedPhoto($_FILES['photo'], 600) : null;
$stmt = $pdo->prepare("INSERT INTO plant_diary (plant_id, user_id, notes, photo, entry_type) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$_POST['plant_id'], $currentUserId, $_POST['notes'] ?? null, $photoPath, isset($_POST['is_milestone']) ? 'milestone' : 'manual']);
$newEntryId = $pdo->lastInsertId();
$stmt = $pdo->prepare("SELECT * FROM plant_diary WHERE id = ?");
$stmt->execute([$newEntryId]);
echo json_encode(['success' => true, 'entry' => $stmt->fetch()]);
break;
case 'save_plant':
$name = trim($_POST['name'] ?? '');
if (empty($name)) throw new Exception('Der Name der Pflanze darf nicht leer sein.', 400);
$plantId = $_POST['id'] ?? null;
$latinName = trim($_POST['latin_name'] ?? '') ?: null;
$photoPath = isset($_FILES['photo']) ? processUploadedPhoto($_FILES['photo']) : null;
$finalPhotoPath = $photoPath ?? $_POST['current_photo'] ?? null;
// ROBUSTE PRÜFUNG DER INTERVALLE
$wateringInterval = (isset($_POST['watering_interval']) && is_numeric($_POST['watering_interval'])) ? (int)$_POST['watering_interval'] : null;
$fertilizingInterval = (isset($_POST['fertilizing_interval']) && is_numeric($_POST['fertilizing_interval'])) ? (int)$_POST['fertilizing_interval'] : null;
$careTips = trim($_POST['care_tips'] ?? '') ?: null;
$pdo->beginTransaction();
$oldLatinName = null;
if ($plantId) { // Edit
$stmtOld = $pdo->prepare("SELECT latin_name FROM plants WHERE id = ? AND user_id = ?");
$stmtOld->execute([$plantId, $currentUserId]);
$oldLatinName = $stmtOld->fetchColumn();
$stmt = $pdo->prepare("UPDATE plants SET name = ?, latin_name = ?, photo = ?, watering_interval_days = ?, fertilizing_interval_days = ?, care_tips = ? WHERE id = ? AND user_id = ?");
$stmt->execute([$name, $latinName, $finalPhotoPath, $wateringInterval, $fertilizingInterval, $careTips, $plantId, $currentUserId]);
} else { // Add
$stmt = $pdo->prepare("INSERT INTO plants (user_id, name, latin_name, photo, last_watered, last_fertilized, watering_interval_days, fertilizing_interval_days, care_tips) VALUES (?, ?, ?, ?, CURDATE(), CURDATE(), ?, ?, ?)");
$stmt->execute([$currentUserId, $name, $latinName, $finalPhotoPath, $wateringInterval, $fertilizingInterval, $careTips]);
$plantId = $pdo->lastInsertId();
$stmt = $pdo->prepare("INSERT INTO plant_diary (plant_id, user_id, entry_type, notes) VALUES (?, ?, 'milestone', 'Pflanze hinzugefügt')");
$stmt->execute([$plantId, $currentUserId]);
}
// Handle lexicon entry count changes
if ($oldLatinName && $oldLatinName !== $latinName) {
$stmtDec = $pdo->prepare("UPDATE plant_lexicon SET entry_count = entry_count - 1 WHERE latin_name = ? AND entry_count > 0");
$stmtDec->execute([$oldLatinName]);
}
if ($latinName && (!$oldLatinName || $oldLatinName !== $latinName)) {
$stmt = $pdo->prepare("INSERT INTO plant_lexicon (latin_name, name) VALUES (?, ?) ON DUPLICATE KEY UPDATE entry_count = entry_count + 1, name = VALUES(name)");
$stmt->execute([$latinName, $name]);
}
// Process AI data for lexicon if available
$enrichmentData = json_decode($_POST['ai_enrichment_json'] ?? '{}', true);
if(!empty($enrichmentData)) {
$stmt = $pdo->prepare("UPDATE plant_lexicon SET plant_family = :plant_family, plant_genus = :plant_genus, description_full = :description_full, care_tips = :care_tips, watering_interval_days = :watering_interval_days, fertilizing_interval_days = :fertilizing_interval_days, light_requirement = :light_requirement, water_requirement = :water_requirement, humidity_requirement = :humidity_requirement, care_difficulty = :care_difficulty, growth_habit = :growth_habit, mature_size = :mature_size, toxicity = :toxicity, edibility = :edibility, temperature_preference = :temperature_preference, flowering_period = :flowering_period, substrate_recommendation = :substrate_recommendation, native_to = :native_to, tags = :tags, updated_at = NOW() WHERE latin_name = :latin_name AND description_full IS NULL");
$stmt->execute([
'plant_family' => $enrichmentData['plant_family'] ?? null,
'plant_genus' => $enrichmentData['plant_genus'] ?? null,
'description_full' => $enrichmentData['description_full'] ?? null,
'care_tips' => $enrichmentData['care_tips'] ?? null,
'watering_interval_days' => $enrichmentData['watering_interval_days'] ?? null,
'fertilizing_interval_days' => $enrichmentData['fertilizing_interval_days'] ?? null,
'light_requirement' => $enrichmentData['light_requirement'] ?? null,
'water_requirement' => $enrichmentData['water_requirement'] ?? null,
'humidity_requirement' => $enrichmentData['humidity_requirement'] ?? null,
'care_difficulty' => $enrichmentData['care_difficulty'] ?? null,
'growth_habit' => $enrichmentData['growth_habit'] ?? null,
'mature_size' => $enrichmentData['mature_size'] ?? null,
'toxicity' => $enrichmentData['toxicity'] ?? null,
'edibility' => $enrichmentData['edibility'] ?? null,
'temperature_preference' => $enrichmentData['temperature_preference'] ?? null,
'flowering_period' => $enrichmentData['flowering_period'] ?? null,
'substrate_recommendation' => $enrichmentData['substrate_recommendation'] ?? null,
'native_to' => $enrichmentData['native_to'] ?? null,
'tags' => $enrichmentData['tags'] ?? null,
'latin_name' => $latinName
]);
}
// Process photo for lexicon if allowed and available
$fullAiResponse = json_decode($_POST['ai_full_response_json'] ?? '{}', true);
$stmtAllow = $pdo->prepare("SELECT allow_photo_sharing FROM users WHERE id = ?");
$stmtAllow->execute([$currentUserId]);
if ($stmtAllow->fetchColumn() && $finalPhotoPath && $photoPath && !empty($fullAiResponse['photo_analysis'])) {
$photoData = $fullAiResponse['photo_analysis'];
$isHighQuality = $photoData['is_high_quality'] ?? false;
$stmt = $pdo->prepare("INSERT INTO lexicon_photos (user_id, plant_latin_name, photo_path, status, rejection_reason, ai_confidence, photo_type, focal_point_x, focal_point_y, dominant_color_hex, ai_photo_quality_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$currentUserId,
$latinName,
$finalPhotoPath,
($isHighQuality ? 'approved' : 'rejected'),
$photoData['rejection_reason'] ?? null,
$fullAiResponse['identification']['confidence_score'] ?? null,
$photoData['photo_type'] ?? null,
$photoData['focal_point']['x'] ?? null,
$photoData['focal_point']['y'] ?? null,
$photoData['dominant_color_hex'] ?? null,
$photoData['photo_quality_score'] ?? null
]);
}
$pdo->commit();
echo json_encode(['success' => true, 'plant' => getPlantWithDueDates($pdo, $plantId, $currentUserId)]);
break;
default:
throw new Exception('Ungültige Formular-Aktion.', 400);
}
break;
case 'DELETE':
$plantId = $_GET['id'] ?? null;
if ($plantId) {
$pdo->beginTransaction();
$stmtLatin = $pdo->prepare("SELECT latin_name FROM plants WHERE id = ? AND user_id = ?");
$stmtLatin->execute([$plantId, $currentUserId]);
$latinNameToDecrement = $stmtLatin->fetchColumn();
$stmt = $pdo->prepare("DELETE FROM plants WHERE id = ? AND user_id = ?");
$stmt->execute([$plantId, $currentUserId]);
if ($latinNameToDecrement) {
$stmtDec = $pdo->prepare("UPDATE plant_lexicon SET entry_count = entry_count - 1 WHERE latin_name = ? AND entry_count > 0");
$stmtDec->execute([$latinNameToDecrement]);
}
$pdo->commit();
echo json_encode(['success' => true]);
} else if (isset($_GET['diary_entry_id'])) {
$entryId = $_GET['diary_entry_id'];
$stmt = $pdo->prepare("DELETE FROM plant_diary WHERE id = ? AND user_id = ?");
$stmt->execute([$entryId, $currentUserId]);
echo json_encode(['success' => $stmt->rowCount() > 0]);
}
else {
throw new Exception('Keine ID zum Löschen angegeben.', 400);
}
break;
default:
throw new Exception('Methode nicht erlaubt.', 405);
}
} catch (Exception $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$code = $e->getCode();
if (!is_int($code) || $code < 400 || $code >= 600) {
$code = 500;
}
http_response_code($code);
echo json_encode(['error' => $e->getMessage()]);
}
?>