427 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			427 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| declare(strict_types=1);
 | |
| 
 | |
| /* ========= Session & Bootstrap ========= */
 | |
| $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
 | |
| session_set_cookie_params([
 | |
|   'lifetime' => 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;
 |