0, 'path' => '/', 'secure' => $secure, 'httponly' => true, 'samesite' => 'Lax', ]); session_name('WLISTSESSID'); session_start(); umask(0022); // sorgt für 0644/0755 als Default für neu erstellte Dateien/Ordner require_once __DIR__ . '/config/config.php'; // ===== Debug Toggle ===== if (!empty($app_debug)) { ini_set('display_errors', '1'); ini_set('display_startup_errors', '1'); ini_set('log_errors', '1'); if (!is_dir(__DIR__ . '/logs')) { @mkdir(__DIR__ . '/logs', 0750, true); } ini_set('error_log', __DIR__ . '/logs/php-error.log'); error_reporting(E_ALL); } else { ini_set('display_errors', '0'); ini_set('display_startup_errors', '0'); ini_set('log_errors', '1'); if (!is_dir(__DIR__ . '/logs')) { @mkdir(__DIR__ . '/logs', 0750, true); } ini_set('error_log', __DIR__ . '/logs/php-error.log'); error_reporting(E_ALL); } /* ============= Helpers ============= */ function fail(string $msg = 'Unerwarteter Fehler', int $code = 400): void { http_response_code($code); exit($msg); } function require_csrf(): void { $t = (string) ($_POST['csrf'] ?? ''); if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $t)) fail('Ungültiges CSRF-Token', 403); } function require_logged_in(): int { if (!isset($_SESSION['listid'])) fail('Nicht eingeloggt', 403); return (int) $_SESSION['listid']; } function db(): mysqli { global $servername, $username, $password, $db; $conn = new mysqli($servername, $username, $password, $db); if ($conn->connect_error) fail('Interner Fehler (DB)', 500); $conn->set_charset('utf8mb4'); return $conn; } function parse_price_to_cents(string $in): int { $clean = preg_replace('/[^\d,\.]/', '', $in ?? ''); $norm = str_replace('.', '', $clean); $norm = str_replace(',', '.', $norm); if ($norm === '' || !is_numeric($norm)) return 0; $cents = (int) round((float) $norm * 100); if ($cents < 0) $cents = 0; if ($cents > 100000000) $cents = 100000000; // 1 Mio € return $cents; } function is_valid_http_url(string $url): bool { if (!filter_var($url, FILTER_VALIDATE_URL)) return false; $p = parse_url($url); if (!$p || empty($p['scheme']) || empty($p['host'])) return false; $s = strtolower($p['scheme']); return $s === 'http' || $s === 'https'; } /* ===== Bild-Speicher-Helper ===== */ function ensure_imagedir(): string { global $imagedir; if (!is_dir($imagedir)) @mkdir($imagedir, 0755, true); return rtrim($imagedir, '/'); } function save_image_from_tmp(string $tmp, ?string $sourceNameOrUrl = null): string { $info = @getimagesize($tmp); if ($info === false || empty($info['mime']) || stripos($info['mime'], 'image/') !== 0) { @unlink($tmp); fail('Ungültige Bilddatei', 400); } $dir = ensure_imagedir(); // Dateiname: wenn URL vorhanden → davon die Ext, sonst aus MIME if ($sourceNameOrUrl && is_valid_http_url($sourceNameOrUrl)) { $name = ImageFetch::safeFileNameFromUrl($sourceNameOrUrl); } else { $ext = 'jpg'; $mime = strtolower($info['mime']); if (str_contains($mime, 'png')) $ext = 'png'; elseif (str_contains($mime, 'webp')) $ext = 'webp'; elseif (str_contains($mime, 'gif')) $ext = 'gif'; elseif (str_contains($mime, 'avif')) $ext = 'avif'; elseif (str_contains($mime, 'jpeg')) $ext = 'jpg'; $name = bin2hex(random_bytes(10)) . '.' . $ext; } $target = $dir . '/' . $name; if (!@rename($tmp, $target)) { if (!@copy($tmp, $target)) { @unlink($tmp); fail('Bildspeicherung fehlgeschlagen', 500); } @unlink($tmp); } @chmod($target, 0644); return $name; } function save_image_from_upload(array $file): string { // Erwartet $_FILES['ItemImageFile'] if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) { fail('Datei-Upload fehlgeschlagen', 400); } if (!empty($file['error'])) { fail('Datei-Upload Fehler (Code ' . (int) $file['error'] . ')', 400); } $size = (int) ($file['size'] ?? 0); if ($size <= 0 || $size > 8 * 1024 * 1024) { fail('Datei ist zu groß (max. 8 MB)', 400); } $tmp = $file['tmp_name']; // doppelt absichern: in separärer Temp-Datei lesen/schreiben (optional) $probe = @getimagesize($tmp); if ($probe === false || empty($probe['mime']) || stripos($probe['mime'], 'image/') !== 0) { fail('Hochgeladene Datei ist kein gültiges Bild', 400); } // in einen eigenen tmp kopieren, um unify mit save_image_from_tmp zu haben $tmp2 = tempnam(sys_get_temp_dir(), 'wlimg_'); if ($tmp2 === false) fail('Temp-Datei Fehler', 500); if (!@copy($tmp, $tmp2)) { @unlink($tmp2); fail('Temp-Datei Fehler', 500); } $origName = (string) ($file['name'] ?? ''); // Falls Originalname eine Extension hat, nutzen wir sie indirekt über getimagesize() (oben) return save_image_from_tmp($tmp2, $origName !== '' ? $origName : null); } function save_image_from_dataurl(string $dataUrl, string $suggestedName = 'clipboard.png'): string { // data:image/png;base64,.... if (!preg_match('#^data:(image/[\w\-\+\.]+);base64,(.+)$#i', $dataUrl, $m)) { fail('Zwischenablage-Daten ungültig', 400); } $mime = strtolower($m[1]); $b64 = $m[2]; // Größenlimit grob prüfen (Base64 ist ~33% größer) $rawLen = (int) (strlen($b64) * 3 / 4); if ($rawLen > 8 * 1024 * 1024) { fail('Zwischenablage-Bild ist zu groß (max. 8 MB)', 400); } $bin = base64_decode($b64, true); if ($bin === false || strlen($bin) === 0) { fail('Zwischenablage-Daten ungültig (Decode)', 400); } $tmp = tempnam(sys_get_temp_dir(), 'wlimg_'); if ($tmp === false) fail('Temp-Datei Fehler', 500); if (file_put_contents($tmp, $bin) === false) { @unlink($tmp); fail('Temp-Datei Fehler', 500); } // suggestedName nur als Hint – finale Ext über getimagesize/MIME return save_image_from_tmp($tmp, $suggestedName); } /* ============= Controller ============= */ require_csrf(); $sessionListId = require_logged_in(); $action = strtolower(trim((string) ($_POST['action'] ?? ''))); $ItemTitle = trim((string) ($_POST['ItemTitle'] ?? '')); $ItemDescription = trim((string) ($_POST['ItemDescription'] ?? '')); $ItemPrice = parse_price_to_cents((string) ($_POST['ItemPrice'] ?? '')); $ItemQty = isset($_POST['ItemQty']) ? max(1, (int) $_POST['ItemQty']) : 1; $ItemLink = trim((string) ($_POST['ItemLink'] ?? '')); // NEU: drei mögliche Bild-Inputs $ItemImageUrl = trim((string) ($_POST['ItemImageUrl'] ?? '')); $ItemImagePaste = (string) ($_POST['ItemImagePaste'] ?? ''); $ItemImagePasteName = trim((string) ($_POST['ItemImagePasteName'] ?? 'clipboard.png')); $ListUUID = trim((string) ($_POST['ItemListUUID'] ?? '')); $sortbyTransfer = (string) ($_POST['sortby_transfer'] ?? 'priority'); $removeImage = isset($_POST['RemoveImage']) && ($_POST['RemoveImage'] === '1'); $WhishID = isset($_POST['WhishID']) ? (int) $_POST['WhishID'] : 0; // bei edit if (!preg_match('/^[0-9a-fA-F-]{32,36}$/', $ListUUID)) fail('Liste nicht autorisiert', 403); $conn = db(); /* UUID -> int ID */ $stmt = $conn->prepare('SELECT ID FROM lists WHERE uuid = ?'); $stmt->bind_param('s', $ListUUID); $stmt->execute(); $res = $stmt->get_result(); if (!$res || !($row = $res->fetch_assoc())) { $stmt->close(); $conn->close(); fail('Liste nicht autorisiert', 403); } $ListID = (int) $row['ID']; $stmt->close(); /* Session muss zu dieser Liste gehören */ if ($ListID !== $sessionListId) { $conn->close(); fail('Liste nicht autorisiert', 403); } /* Validierungen (add & edit) */ if ($ItemTitle === '') fail('Titel fehlt', 400); if ($ItemLink !== '' && !is_valid_http_url($ItemLink)) fail('Ungültiger Angebotslink', 400); /* ===== Bild verarbeiten: Upload → Paste → URL ===== */ $imageLocalLink = null; $hasUpload = isset($_FILES['ItemImageFile']) && is_array($_FILES['ItemImageFile']) && !empty($_FILES['ItemImageFile']['name']); $hasPaste = $ItemImagePaste !== ''; $hasUrl = $ItemImageUrl !== ''; if (!$removeImage) { if ($hasUpload) { // 1) Datei-Upload $imageLocalLink = save_image_from_upload($_FILES['ItemImageFile']); } elseif ($hasPaste) { // 2) Zwischenablage (Data-URL) $imageLocalLink = save_image_from_dataurl($ItemImagePaste, $ItemImagePasteName); } elseif ($hasUrl) { // 3) Bild-URL (Download-Pflicht, sonst Fehler) if (!is_valid_http_url($ItemImageUrl)) { $conn->close(); fail('Ungültiger Bildlink', 400); } $fetch = ImageFetch::download($ItemImageUrl, [ 'page_url' => $ItemLink !== '' && is_valid_http_url($ItemLink) ? $ItemLink : null, 'max_bytes' => 8_000_000, 'timeout' => 12, 'connect_timeout' => 5, 'retries' => 5, 'retry_backoff_ms' => 300, 'whitelist_hosts' => $image_host_whitelist ?? null, 'referer' => 'auto', // Falls keine page_url, nimmt er Bild-Origin 'log_prefix' => 'wishlist-img', 'debug' => true, 'try_http_versions' => ['2', '1.1'], 'try_ip_resolve_combo' => ['v4', 'auto'], 'force_client_hints' => true, ]); if (!$fetch['ok']) { error_log("wishlist image error: http=" . ($fetch['http_code'] ?? 0) . " curl=" . ($fetch['curl_err'] ?? '-') . " url=$ItemImageUrl"); $conn->close(); fail('Bild-Download fehlgeschlagen. Bitte Bild manuell speichern/hochladen oder per Zwischenablage einfügen und erneut versuchen.', 400); } $imageLocalLink = save_image_from_tmp($fetch['tmp_path'], $ItemImageUrl); } } /* ====== ADD ====== */ if ($action === 'add') { // nächste Priority ermitteln $next = 1; $s = $conn->prepare('SELECT COALESCE(MAX(priority),0) AS maxp FROM wishes WHERE wishlist = ?'); $s->bind_param('i', $ListID); $s->execute(); $r = $s->get_result(); if ($r && $m = $r->fetch_assoc()) $next = ((int) $m['maxp']) + 1; $s->close(); $stmt = $conn->prepare(' INSERT INTO wishes (title, description, link, image, price, wishlist, priority, qty) VALUES (?, ?, ?, ?, ?, ?, ?, ?) '); $link = $ItemLink !== '' ? $ItemLink : null; $image = $imageLocalLink !== null ? $imageLocalLink : null; $stmt->bind_param( 'ssssiiii', $ItemTitle, $ItemDescription, $link, $image, $ItemPrice, $ListID, $next, $ItemQty ); if (!$stmt->execute()) { $stmt->close(); $conn->close(); fail('Speichern fehlgeschlagen', 500); } $stmt->close(); /* ====== EDIT ====== */ } elseif ($action === 'edit') { if ($WhishID <= 0) { $conn->close(); fail('Ungültige Item-ID', 400); } // Besitz prüfen & altes Bild holen $stmt = $conn->prepare('SELECT image FROM wishes WHERE ID = ? AND wishlist = ?'); $stmt->bind_param('ii', $WhishID, $ListID); $stmt->execute(); $res = $stmt->get_result(); if (!$res || !($row = $res->fetch_assoc())) { $stmt->close(); $conn->close(); fail('Item nicht gefunden/autorisiert', 404); } $oldImage = (string) $row['image']; $stmt->close(); // Bild-Entscheidung: entfernen, ersetzen oder behalten $newImage = $oldImage; if ($removeImage) { if (!empty($oldImage)) { $full = ensure_imagedir() . '/' . $oldImage; if (is_file($full)) @unlink($full); } $newImage = ''; } elseif ($imageLocalLink !== null) { if (!empty($oldImage)) { $full = ensure_imagedir() . '/' . $oldImage; if (is_file($full)) @unlink($full); } $newImage = $imageLocalLink; } $link = $ItemLink !== '' ? $ItemLink : null; $imgNullable = ($newImage !== '') ? $newImage : null; $stmt = $conn->prepare( 'UPDATE wishes SET title=?, description=?, link=?, image=?, price=?, qty=? WHERE ID=? AND wishlist=?' ); $stmt->bind_param( 'sssssiii', $ItemTitle, $ItemDescription, $link, $imgNullable, $ItemPrice, $ItemQty, $WhishID, $ListID ); if (!$stmt->execute()) { $stmt->close(); $conn->close(); fail('Update fehlgeschlagen', 500); } $stmt->close(); } else { $conn->close(); fail('Unbekannte Aktion', 400); } $conn->close(); /* Redirect zurück (sicher, mit UUID) */ $redirect = (string) ($_SERVER['HTTP_REFERER'] ?? ''); if ($redirect === '' || stripos($redirect, 'http') !== 0) { $host = $_SERVER['HTTP_HOST'] ?? ''; $scheme = $secure ? 'https' : 'http'; $qs = '?list=' . urlencode($ListUUID); if ($sortbyTransfer !== '') $qs .= '&sort=' . urlencode($sortbyTransfer); $redirect = $scheme . '://' . $host . '/' . $qs; } header('Location: ' . $redirect, true, 303); exit;