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'; } function ip_in_cidr(string $ip, string $cidr): bool { if (strpos($cidr, ':') !== false) { [$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null); $mask = (int) $mask; $binIp = inet_pton($ip); $binSubnet = inet_pton($subnet); if ($binIp === false || $binSubnet === false) return false; $bytes = intdiv($mask, 8); $bits = $mask % 8; if ($bytes && substr($binIp, 0, $bytes) !== substr($binSubnet, 0, $bytes)) return false; if ($bits) { $b1 = ord($binIp[$bytes]) & (0xFF << (8 - $bits)); $b2 = ord($binSubnet[$bytes]) & (0xFF << (8 - $bits)); return $b1 === $b2; } return true; } else { [$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null); $mask = (int) $mask; $ipL = ip2long($ip); $subL = ip2long($subnet); if ($ipL === false || $subL === false) return false; $maskL = -1 << (32 - $mask); return (($ipL & $maskL) === ($subL & $maskL)); } } function is_private_ip(string $ip): bool { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { foreach (['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.0/8', '169.254.0.0/16'] as $c) if (ip_in_cidr($ip, $c)) return true; } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { foreach (['::1/128', 'fc00::/7', 'fe80::/10'] as $c) if (ip_in_cidr($ip, $c)) return true; } return false; } function validate_remote_host_not_private(string $url): void { $p = parse_url($url); if (!$p || empty($p['host'])) fail('Ungültige URL', 400); $host = $p['host']; global $image_host_whitelist; if (isset($image_host_whitelist) && is_array($image_host_whitelist) && count($image_host_whitelist) > 0) { $ok = false; foreach ($image_host_whitelist as $allowed) { if (strcasecmp($host, $allowed) === 0) { $ok = true; break; } if (preg_match('/\.' . preg_quote($allowed, '/') . '$/i', $host)) { $ok = true; break; } } if (!$ok) fail('Host nicht erlaubt', 400); } $recs = dns_get_record($host, DNS_A + DNS_AAAA); if (!$recs || !count($recs)) fail('Host nicht auflösbar', 400); foreach ($recs as $r) { $ip = $r['type'] === 'A' ? ($r['ip'] ?? null) : ($r['ipv6'] ?? null); if (!$ip) continue; if (is_private_ip($ip)) fail('Zieladresse unzulässig', 400); } } function download_remote_image_limited(string $url, int $maxBytes = 5_000_000, int $timeout = 8): string { $tmp = tempnam(sys_get_temp_dir(), 'wlimg_'); if ($tmp === false) fail('Temp-Datei Fehler', 500); $fh = fopen($tmp, 'wb'); if ($fh === false) { @unlink($tmp); fail('Temp-Datei Fehler', 500); } $ch = curl_init($url); if ($ch === false) { fclose($fh); @unlink($tmp); fail('Download Fehler', 500); } $received = 0; curl_setopt_array($ch, [ CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => $timeout, CURLOPT_USERAGENT => 'wishlist/1.0', CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$received, $maxBytes, $fh) { $len = strlen($data); $received += $len; if ($received > $maxBytes) return 0; return fwrite($fh, $data); } ]); $ok = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); fclose($fh); if (!$ok || $code < 200 || $code >= 300) { @unlink($tmp); fail('Bild-Download fehlgeschlagen', 400); } return $tmp; } function safe_image_filename_from_url(string $url): string { $stripped = strtok($url, '?#'); $ext = strtolower(pathinfo((string) $stripped, PATHINFO_EXTENSION)); if (!preg_match('/^[a-z0-9]{1,5}$/i', $ext)) $ext = 'jpg'; return bin2hex(random_bytes(10)) . '.' . $ext; } /* ============= 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'] ?? '')); $ItemImageUrl = trim((string) ($_POST['ItemImage'] ?? '')); // optional URL zum Pull $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); /* Optional: Bild von externer URL holen */ $imageLocalLink = null; if (!$removeImage && $ItemImageUrl !== '') { if (!is_valid_http_url($ItemImageUrl)) { $conn->close(); fail('Ungültiger Bildlink', 400); } validate_remote_host_not_private($ItemImageUrl); $tmp = download_remote_image_limited($ItemImageUrl, 5_000_000, 8); $info = @getimagesize($tmp); if ($info === false || empty($info['mime']) || stripos($info['mime'], 'image/') !== 0) { @unlink($tmp); $conn->close(); fail('Link ist kein gültiges Bild', 400); } global $imagedir; if (!is_dir($imagedir)) { @mkdir($imagedir, 0755, true); } $filename = safe_image_filename_from_url($ItemImageUrl); $target = rtrim($imagedir, '/') . '/' . $filename; if (!@rename($tmp, $target)) { // Fallback falls rename scheitert if (!@copy($tmp, $target)) { @unlink($tmp); $conn->close(); fail('Bildspeicherung fehlgeschlagen', 500); } @unlink($tmp); } // HIER: Permissions fixen @chmod($target, 0644); $imageLocalLink = $filename; } /* ====== 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)) { global $imagedir; $full = rtrim($imagedir, '/') . '/' . $oldImage; if (is_file($full)) @unlink($full); } $newImage = ''; } elseif ($imageLocalLink !== null) { if (!empty($oldImage)) { global $imagedir; $full = rtrim($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(); } /* ====== Unbekannt ====== */ 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;