Support Image-Upload and Paste-from-Clipboard
This commit is contained in:
		@@ -248,3 +248,35 @@ body {
 | 
				
			|||||||
    flex-basis: 38px;
 | 
					    flex-basis: 38px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/* ============ Add/Edit-Modal  ============ */
 | 
				
			||||||
 | 
					/* Bild-Preview im Add/Edit-Modal begrenzen */
 | 
				
			||||||
 | 
					#ImgPreviewWrap {
 | 
				
			||||||
 | 
					  max-height: 240px; /* wie hoch darf’s werden */
 | 
				
			||||||
 | 
					  overflow: hidden; /* nix überlappen lassen */
 | 
				
			||||||
 | 
					  border: 1px solid rgba(0, 0, 0, 0.1);
 | 
				
			||||||
 | 
					  border-radius: 0.5rem;
 | 
				
			||||||
 | 
					  padding: 0.5rem;
 | 
				
			||||||
 | 
					  background: #fff;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#ImgPreview {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					  max-width: 100%;
 | 
				
			||||||
 | 
					  max-height: 220px; /* = Wrap - Padding */
 | 
				
			||||||
 | 
					  width: auto;
 | 
				
			||||||
 | 
					  height: auto;
 | 
				
			||||||
 | 
					  object-fit: contain; /* immer komplett sichtbar */
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.visually-hidden {
 | 
				
			||||||
 | 
					  position: absolute !important;
 | 
				
			||||||
 | 
					  width: 1px;
 | 
				
			||||||
 | 
					  height: 1px;
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					  margin: -1px;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					  clip: rect(0, 0, 0, 0);
 | 
				
			||||||
 | 
					  white-space: nowrap;
 | 
				
			||||||
 | 
					  border: 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,18 +2,15 @@
 | 
				
			|||||||
declare(strict_types=1);
 | 
					declare(strict_types=1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Robust Image Downloader (CDN/Anti-Bot freundlich)
 | 
					 * Robust Image Downloader (universal, CDN/Bot-freundlich)
 | 
				
			||||||
 * - Validiert URL + DNS (keine private IPs/SSRF)
 | 
					 * - URL/DNS-Checks (kein SSRF, keine privaten IPs)
 | 
				
			||||||
 * - Host-Whitelist optional
 | 
					 * - optionale Host-Whitelist
 | 
				
			||||||
 * - Rotiert mehrere echte Browser-UAs
 | 
					 * - UA-Rotation, realistische Headers + Client Hints
 | 
				
			||||||
 * - Setzt realistische Headers (Accept: AVIF/WebP etc.)
 | 
					 * - Referer: bevorzugt Einbettungs-Seite (page_url), sonst Origin
 | 
				
			||||||
 * - Referer automatisch auf Origin
 | 
					 * - Cookie-Probe auf page_url (sammelt z. B. __cf_bm)
 | 
				
			||||||
 * - IPv4 bevorzugt
 | 
					 * - HTTP/2 -> 1.1 Fallback, IPv4 bevorzugt (optional)
 | 
				
			||||||
 * - Follow Redirects
 | 
					 * - Byte-Limit im Stream, Retries mit Backoff+Jitter
 | 
				
			||||||
 * - Strict Content-Type-Check (image/*)
 | 
					 * - Kein Echo/Output: RETURNTRANSFER überall an
 | 
				
			||||||
 * - Byte-Limit-Abbruch im Stream
 | 
					 | 
				
			||||||
 * - Retries mit Exponential Backoff + Jitter
 | 
					 | 
				
			||||||
 * - Liefert Temp-Datei; Caller verschiebt/benennt final
 | 
					 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * Rückgabe-Array:
 | 
					 * Rückgabe-Array:
 | 
				
			||||||
 * [
 | 
					 * [
 | 
				
			||||||
@@ -24,6 +21,7 @@ declare(strict_types=1);
 | 
				
			|||||||
 *   'curl_err'  => string|null,
 | 
					 *   'curl_err'  => string|null,
 | 
				
			||||||
 *   'final_url' => string|null,
 | 
					 *   'final_url' => string|null,
 | 
				
			||||||
 *   'bytes'     => int,
 | 
					 *   'bytes'     => int,
 | 
				
			||||||
 | 
					 *   'error'     => string|null,
 | 
				
			||||||
 * ]
 | 
					 * ]
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -31,16 +29,13 @@ namespace WList\Net;
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
final class ImageFetch
 | 
					final class ImageFetch
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    /** Default User-Agents (rotieren pro Versuch) */
 | 
					 | 
				
			||||||
    private static array $UA_LIST = [
 | 
					    private static array $UA_LIST = [
 | 
				
			||||||
        // Aktuelle Desktop-Chromes/Firefox als Tarnkappe
 | 
					 | 
				
			||||||
        'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
 | 
					        'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
 | 
				
			||||||
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
 | 
					        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
 | 
				
			||||||
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
 | 
					        'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15',
 | 
				
			||||||
        'Mozilla/5.0 (X11; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0',
 | 
					        'Mozilla/5.0 (X11; Linux x86_64; rv:129.0) Gecko/20100101 Firefox/129.0',
 | 
				
			||||||
    ];
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /** Fehlercodes, bei denen sich ein Retry lohnt */
 | 
					 | 
				
			||||||
    private static array $RETRY_HTTP = [429, 500, 502, 503, 504, 520, 521, 522, 523, 524];
 | 
					    private static array $RETRY_HTTP = [429, 500, 502, 503, 504, 520, 521, 522, 523, 524];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private static function retryCurlCodes(): array
 | 
					    private static function retryCurlCodes(): array
 | 
				
			||||||
@@ -52,45 +47,48 @@ final class ImageFetch
 | 
				
			|||||||
            \defined('CURLE_RECV_ERROR') ? \constant('CURLE_RECV_ERROR') : null,
 | 
					            \defined('CURLE_RECV_ERROR') ? \constant('CURLE_RECV_ERROR') : null,
 | 
				
			||||||
            \defined('CURLE_SEND_ERROR') ? \constant('CURLE_SEND_ERROR') : null,
 | 
					            \defined('CURLE_SEND_ERROR') ? \constant('CURLE_SEND_ERROR') : null,
 | 
				
			||||||
            \defined('CURLE_GOT_NOTHING') ? \constant('CURLE_GOT_NOTHING') : null,
 | 
					            \defined('CURLE_GOT_NOTHING') ? \constant('CURLE_GOT_NOTHING') : null,
 | 
				
			||||||
            \defined('CURLE_HTTP2_STREAM') ? \constant('CURLE_HTTP2_STREAM') : null, // nur wenn vorhanden
 | 
					            \defined('CURLE_HTTP2_STREAM') ? \constant('CURLE_HTTP2_STREAM') : null,
 | 
				
			||||||
            \defined('CURLE_HTTP2') ? \constant('CURLE_HTTP2') : null, // manche Builds haben nur den
 | 
					            \defined('CURLE_HTTP2') ? \constant('CURLE_HTTP2') : null,
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
        // Nulls rausfiltern
 | 
					 | 
				
			||||||
        return array_values(array_filter($list, static fn($v) => $v !== null));
 | 
					        return array_values(array_filter($list, static fn($v) => $v !== null));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    /** Öffentliche API */
 | 
					 | 
				
			||||||
    public static function download(string $url, array $opt = []): array
 | 
					    public static function download(string $url, array $opt = []): array
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $defaults = [
 | 
					        $defaults = [
 | 
				
			||||||
            'max_bytes' => 8_000_000,     // 8 MiB
 | 
					            'page_url' => null,           // Einbettungs-Seite (Angebotslink) – universell
 | 
				
			||||||
            'timeout' => 12,            // Sek.
 | 
					            'force_client_hints' => true,           // sec-ch-ua etc.
 | 
				
			||||||
            'connect_timeout' => 5,             // Sek.
 | 
					            'max_bytes' => 8_000_000,
 | 
				
			||||||
 | 
					            'timeout' => 12,
 | 
				
			||||||
 | 
					            'connect_timeout' => 5,
 | 
				
			||||||
            'max_redirects' => 5,
 | 
					            'max_redirects' => 5,
 | 
				
			||||||
            'retries' => 3,
 | 
					            'retries' => 3,
 | 
				
			||||||
            'retry_backoff_ms' => 250,           // Basis-Backoff
 | 
					            'retry_backoff_ms' => 250,
 | 
				
			||||||
            'whitelist_hosts' => null,          // ['ikea.com','images.ikea.com'] oder null
 | 
					            'whitelist_hosts' => null,           // ['ikea.com', ...] oder null
 | 
				
			||||||
            'ip_resolve_v4' => true,
 | 
					            'ip_resolve_v4' => true,
 | 
				
			||||||
            'referer' => 'auto',         // 'auto' | 'none' | 'custom'
 | 
					            'referer' => 'auto',         // 'auto' | 'none' | 'custom'
 | 
				
			||||||
            'custom_referer' => null,
 | 
					            'custom_referer' => null,
 | 
				
			||||||
            'user_agents' => null,          // override UA-Liste
 | 
					            'user_agents' => null,
 | 
				
			||||||
            'log_prefix' => 'imgfetch',    // für error_log
 | 
					            'log_prefix' => 'imgfetch',
 | 
				
			||||||
 | 
					            'debug' => true,
 | 
				
			||||||
 | 
					            'debug_peek_bytes' => 512,
 | 
				
			||||||
 | 
					            'try_http_versions' => ['2', '1.1'],
 | 
				
			||||||
 | 
					            'try_ip_resolve_combo' => ['v4', 'auto'],
 | 
				
			||||||
 | 
					            // Optional: bei harten Blockern nicht failen, sondern externen URL akzeptieren
 | 
				
			||||||
 | 
					            'failopen_hosts' => [],             // z.B. ['ikea.com']
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
        $cfg = array_replace($defaults, $opt);
 | 
					        $cfg = array_replace($defaults, $opt);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 1) URL validieren + Host prüfen
 | 
					        // URL + Host
 | 
				
			||||||
        if (!self::isValidHttpUrl($url)) {
 | 
					        if (!self::isValidHttpUrl($url))
 | 
				
			||||||
            return self::fail(null, null, 0, 'Ungültige URL');
 | 
					            return self::fail(null, null, 0, 'Ungültige URL');
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        $p = parse_url($url);
 | 
					        $p = parse_url($url);
 | 
				
			||||||
        $host = strtolower($p['host'] ?? '');
 | 
					        $host = strtolower($p['host'] ?? '');
 | 
				
			||||||
        if (!$host) {
 | 
					        if (!$host)
 | 
				
			||||||
            return self::fail(null, null, 0, 'Ungültige URL (Host)');
 | 
					            return self::fail(null, null, 0, 'Ungültige URL (Host)');
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Host-Whitelist (optional)
 | 
					        // Whitelist
 | 
				
			||||||
        if (is_array($cfg['whitelist_hosts']) && count($cfg['whitelist_hosts']) > 0) {
 | 
					        if (is_array($cfg['whitelist_hosts']) && $cfg['whitelist_hosts']) {
 | 
				
			||||||
            $ok = false;
 | 
					            $ok = false;
 | 
				
			||||||
            foreach ($cfg['whitelist_hosts'] as $allowed) {
 | 
					            foreach ($cfg['whitelist_hosts'] as $allowed) {
 | 
				
			||||||
                $allowed = strtolower($allowed);
 | 
					                $allowed = strtolower($allowed);
 | 
				
			||||||
@@ -99,29 +97,21 @@ final class ImageFetch
 | 
				
			|||||||
                    break;
 | 
					                    break;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            if (!$ok) {
 | 
					            if (!$ok)
 | 
				
			||||||
                return self::fail(null, null, 0, 'Host nicht erlaubt');
 | 
					                return self::fail(null, null, 0, 'Host nicht erlaubt');
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // DNS → keine privaten IPs
 | 
					        // DNS → keine privaten IPs
 | 
				
			||||||
        if (!self::hostResolvesPublic($host)) {
 | 
					        if (!self::hostResolvesPublic($host))
 | 
				
			||||||
            return self::fail(null, null, 0, 'Host nicht öffentlich erreichbar');
 | 
					            return self::fail(null, null, 0, 'Host nicht öffentlich erreichbar');
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 2) Tmpfile anlegen
 | 
					        $uaList = (is_array($cfg['user_agents']) && $cfg['user_agents']) ? $cfg['user_agents'] : self::$UA_LIST;
 | 
				
			||||||
        $tmp = tempnam(sys_get_temp_dir(), 'wlimg_');
 | 
					 | 
				
			||||||
        if ($tmp === false) {
 | 
					 | 
				
			||||||
            return self::fail(null, null, 0, 'Temp-Datei Fehler');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // 3) Vorbereitung: Header + Referer + UAs
 | 
					 | 
				
			||||||
        $uaList = is_array($cfg['user_agents']) && $cfg['user_agents'] ? $cfg['user_agents'] : self::$UA_LIST;
 | 
					 | 
				
			||||||
        $originRef = self::originFromUrl($url);
 | 
					        $originRef = self::originFromUrl($url);
 | 
				
			||||||
        $referer = match ($cfg['referer']) {
 | 
					
 | 
				
			||||||
 | 
					        $defaultReferer = match ($cfg['referer']) {
 | 
				
			||||||
            'none' => null,
 | 
					            'none' => null,
 | 
				
			||||||
            'custom' => (string) $cfg['custom_referer'],
 | 
					            'custom' => (string) $cfg['custom_referer'],
 | 
				
			||||||
            default => $originRef, // auto
 | 
					            default => $originRef,
 | 
				
			||||||
        };
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        $headers = [
 | 
					        $headers = [
 | 
				
			||||||
@@ -129,36 +119,169 @@ final class ImageFetch
 | 
				
			|||||||
            'Accept-Language: de-DE,de;q=0.9,en;q=0.8',
 | 
					            'Accept-Language: de-DE,de;q=0.9,en;q=0.8',
 | 
				
			||||||
            'Cache-Control: no-cache',
 | 
					            'Cache-Control: no-cache',
 | 
				
			||||||
            'Pragma: no-cache',
 | 
					            'Pragma: no-cache',
 | 
				
			||||||
            // Friendly fetch hints (einige CDNs schauen da drauf)
 | 
					 | 
				
			||||||
            'Sec-Fetch-Dest: image',
 | 
					            'Sec-Fetch-Dest: image',
 | 
				
			||||||
            'Sec-Fetch-Mode: no-cors',
 | 
					            'Sec-Fetch-Mode: no-cors',
 | 
				
			||||||
            'Sec-Fetch-Site: cross-site',
 | 
					            'Sec-Fetch-Site: cross-site', // wird dynamisch ggf. same-origin
 | 
				
			||||||
        ];
 | 
					        ];
 | 
				
			||||||
 | 
					        if (!empty($cfg['force_client_hints'])) {
 | 
				
			||||||
 | 
					            $headers[] = 'sec-ch-ua: "Chromium";v="128", "Not=A?Brand";v="99"';
 | 
				
			||||||
 | 
					            $headers[] = 'sec-ch-ua-mobile: ?0';
 | 
				
			||||||
 | 
					            $headers[] = 'sec-ch-ua-platform: "Linux"';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // 4) Retries
 | 
					        // Cookie-Tools
 | 
				
			||||||
 | 
					        $cookieJar = [];
 | 
				
			||||||
 | 
					        $collectCookie = static function (array $respHeaders) use (&$cookieJar): void {
 | 
				
			||||||
 | 
					            if (!isset($respHeaders['set-cookie']))
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            foreach ($respHeaders['set-cookie'] as $line) {
 | 
				
			||||||
 | 
					                $parts = explode(';', $line);
 | 
				
			||||||
 | 
					                if (!$parts)
 | 
				
			||||||
 | 
					                    continue;
 | 
				
			||||||
 | 
					                $nv = trim($parts[0]);
 | 
				
			||||||
 | 
					                $eq = strpos($nv, '=');
 | 
				
			||||||
 | 
					                if ($eq === false)
 | 
				
			||||||
 | 
					                    continue;
 | 
				
			||||||
 | 
					                $name = substr($nv, 0, $eq);
 | 
				
			||||||
 | 
					                $value = substr($nv, $eq + 1);
 | 
				
			||||||
 | 
					                if ($name !== '' && $value !== '')
 | 
				
			||||||
 | 
					                    $cookieJar[$name] = $value;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        $cookieHeader = static function (array $jar): ?string {
 | 
				
			||||||
 | 
					            if (!$jar)
 | 
				
			||||||
 | 
					                return null;
 | 
				
			||||||
 | 
					            $buf = [];
 | 
				
			||||||
 | 
					            foreach ($jar as $k => $v)
 | 
				
			||||||
 | 
					                $buf[] = "$k=$v";
 | 
				
			||||||
 | 
					            return implode('; ', $buf);
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // page_url (Einbettungs-Seite)
 | 
				
			||||||
 | 
					        $embeddingUrl = is_string($cfg['page_url']) ? $cfg['page_url'] : null;
 | 
				
			||||||
 | 
					        $embeddingHost = null;
 | 
				
			||||||
 | 
					        if ($embeddingUrl && self::isValidHttpUrl($embeddingUrl)) {
 | 
				
			||||||
 | 
					            $ep = parse_url($embeddingUrl);
 | 
				
			||||||
 | 
					            $embeddingHost = strtolower($ep['host'] ?? '');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Sec-Fetch-Site dynamisch
 | 
				
			||||||
 | 
					        $fetchSite = 'cross-site';
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					            $embeddingHost && ($embeddingHost === $host
 | 
				
			||||||
 | 
					                || str_ends_with($host, '.' . $embeddingHost)
 | 
				
			||||||
 | 
					                || str_ends_with($embeddingHost, '.' . $host))
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					            $fetchSite = 'same-origin';
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        foreach ($headers as $i => $h) {
 | 
				
			||||||
 | 
					            if (stripos($h, 'Sec-Fetch-Site:') === 0)
 | 
				
			||||||
 | 
					                $headers[$i] = 'Sec-Fetch-Site: ' . $fetchSite;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Cookie-Probe auf page_url
 | 
				
			||||||
 | 
					        if ($embeddingUrl) {
 | 
				
			||||||
 | 
					            $probeHeaders = [];
 | 
				
			||||||
 | 
					            $chp = @curl_init($embeddingUrl);
 | 
				
			||||||
 | 
					            if ($chp) {
 | 
				
			||||||
 | 
					                $uaProbe = (string) ($uaList[0] ?? 'Mozilla/5.0');
 | 
				
			||||||
 | 
					                $probeOpts = [
 | 
				
			||||||
 | 
					                    CURLOPT_NOBODY => false, // GET (HEAD liefert oft kein Set-Cookie)
 | 
				
			||||||
 | 
					                    CURLOPT_FOLLOWLOCATION => true,
 | 
				
			||||||
 | 
					                    CURLOPT_MAXREDIRS => 3,
 | 
				
			||||||
 | 
					                    CURLOPT_CONNECTTIMEOUT => (int) $cfg['connect_timeout'],
 | 
				
			||||||
 | 
					                    CURLOPT_TIMEOUT => (int) $cfg['timeout'],
 | 
				
			||||||
 | 
					                    CURLOPT_USERAGENT => $uaProbe,
 | 
				
			||||||
 | 
					                    CURLOPT_SSL_VERIFYPEER => true,
 | 
				
			||||||
 | 
					                    CURLOPT_SSL_VERIFYHOST => 2,
 | 
				
			||||||
 | 
					                    CURLOPT_HTTPHEADER => $headers,
 | 
				
			||||||
 | 
					                    CURLOPT_HEADERFUNCTION => function ($ch, $hdr) use (&$probeHeaders) {
 | 
				
			||||||
 | 
					                        $line = trim($hdr);
 | 
				
			||||||
 | 
					                        if ($line !== '' && stripos($line, 'http/') !== 0) {
 | 
				
			||||||
 | 
					                            $pos = strpos($line, ':');
 | 
				
			||||||
 | 
					                            if ($pos !== false) {
 | 
				
			||||||
 | 
					                                $k = strtolower(trim(substr($line, 0, $pos)));
 | 
				
			||||||
 | 
					                                $v = trim(substr($line, $pos + 1));
 | 
				
			||||||
 | 
					                                $probeHeaders[$k][] = $v;
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                        }
 | 
				
			||||||
 | 
					                        return strlen($hdr);
 | 
				
			||||||
 | 
					                    },
 | 
				
			||||||
 | 
					                    CURLOPT_WRITEFUNCTION => function ($ch, $d) {
 | 
				
			||||||
 | 
					                        return strlen($d); }, // nix ausgeben
 | 
				
			||||||
 | 
					                    CURLOPT_RETURNTRANSFER => true,  // **wichtig**: niemals in Output schreiben
 | 
				
			||||||
 | 
					                    CURLOPT_ACCEPT_ENCODING => '',
 | 
				
			||||||
 | 
					                ];
 | 
				
			||||||
 | 
					                $probeOpts[CURLOPT_REFERER] = $embeddingUrl;
 | 
				
			||||||
 | 
					                @curl_setopt_array($chp, $probeOpts);
 | 
				
			||||||
 | 
					                @curl_exec($chp); // Body wird verworfen, aber nicht ausgegeben
 | 
				
			||||||
 | 
					                @curl_close($chp);
 | 
				
			||||||
 | 
					                $collectCookie($probeHeaders);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Versuchsstrategie
 | 
				
			||||||
        $attempts = max(1, (int) $cfg['retries']);
 | 
					        $attempts = max(1, (int) $cfg['retries']);
 | 
				
			||||||
        $received = 0;
 | 
					 | 
				
			||||||
        $lastHttp = null;
 | 
					        $lastHttp = null;
 | 
				
			||||||
        $lastCurlErr = null;
 | 
					        $lastCurlErr = null;
 | 
				
			||||||
        $finalUrl = null;
 | 
					        $finalUrl = null;
 | 
				
			||||||
        $mime = null;
 | 
					        $mime = null;
 | 
				
			||||||
        $ok = false;
 | 
					        $received = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        $httpVersSeq = (array) $cfg['try_http_versions'];
 | 
				
			||||||
 | 
					        $ipSeq = (array) $cfg['try_ip_resolve_combo'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for ($i = 0; $i < $attempts; $i++) {
 | 
					        for ($i = 0; $i < $attempts; $i++) {
 | 
				
			||||||
            $ua = $uaList[$i % count($uaList)];
 | 
					            $ua = $uaList[$i % count($uaList)];
 | 
				
			||||||
 | 
					            $httpVers = $httpVersSeq[$i % count($httpVersSeq)];
 | 
				
			||||||
 | 
					            $ipPref = $ipSeq[$i % count($ipSeq)];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            $tmp = tempnam(sys_get_temp_dir(), 'wlimg_');
 | 
				
			||||||
 | 
					            if ($tmp === false)
 | 
				
			||||||
 | 
					                return self::fail(null, null, 0, 'Temp-Datei Fehler');
 | 
				
			||||||
            $fh = fopen($tmp, 'wb');
 | 
					            $fh = fopen($tmp, 'wb');
 | 
				
			||||||
            if ($fh === false) {
 | 
					            if ($fh === false) {
 | 
				
			||||||
                return self::fail($tmp, null, 0, 'Temp-Datei Fehler');
 | 
					                @unlink($tmp);
 | 
				
			||||||
 | 
					                return self::fail(null, null, 0, 'Temp-Datei Fehler');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            $respHeaders = [];
 | 
				
			||||||
 | 
					            $headerFn = function ($ch, $hdr) use (&$respHeaders) {
 | 
				
			||||||
 | 
					                $line = trim($hdr);
 | 
				
			||||||
 | 
					                if ($line === '' || strpos($line, 'HTTP/') === 0) {
 | 
				
			||||||
 | 
					                } else {
 | 
				
			||||||
 | 
					                    $pos = strpos($line, ':');
 | 
				
			||||||
 | 
					                    if ($pos !== false) {
 | 
				
			||||||
 | 
					                        $k = strtolower(trim(substr($line, 0, $pos)));
 | 
				
			||||||
 | 
					                        $v = trim(substr($line, $pos + 1));
 | 
				
			||||||
 | 
					                        $respHeaders[$k][] = $v;
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                return strlen($hdr);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            $ch = curl_init($url);
 | 
					            $ch = curl_init($url);
 | 
				
			||||||
            if ($ch === false) {
 | 
					            if ($ch === false) {
 | 
				
			||||||
                fclose($fh);
 | 
					                fclose($fh);
 | 
				
			||||||
                return self::fail($tmp, null, 0, 'Download Fehler (init)');
 | 
					                @unlink($tmp);
 | 
				
			||||||
 | 
					                return self::fail(null, null, 0, 'Download Fehler (init)');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            $received = 0;
 | 
					            $received = 0;
 | 
				
			||||||
 | 
					            $peekBuf = '';
 | 
				
			||||||
 | 
					            $peekLimit = max(0, (int) $cfg['debug_peek_bytes']);
 | 
				
			||||||
 | 
					            $writeFn = function ($ch, $data) use (&$received, $cfg, $fh, &$peekBuf, $peekLimit) {
 | 
				
			||||||
 | 
					                $len = strlen($data);
 | 
				
			||||||
 | 
					                if ($peekLimit > 0 && strlen($peekBuf) < $peekLimit) {
 | 
				
			||||||
 | 
					                    $need = $peekLimit - strlen($peekBuf);
 | 
				
			||||||
 | 
					                    $peekBuf .= substr($data, 0, max(0, min($need, $len)));
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                $received += $len;
 | 
				
			||||||
 | 
					                if ($received > (int) $cfg['max_bytes'])
 | 
				
			||||||
 | 
					                    return 0;
 | 
				
			||||||
 | 
					                return fwrite($fh, $data);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            $opts = [
 | 
					            $opts = [
 | 
				
			||||||
                CURLOPT_FOLLOWLOCATION => true,
 | 
					                CURLOPT_FOLLOWLOCATION => true,
 | 
				
			||||||
                CURLOPT_MAXREDIRS => (int) $cfg['max_redirects'],
 | 
					                CURLOPT_MAXREDIRS => (int) $cfg['max_redirects'],
 | 
				
			||||||
@@ -168,29 +291,37 @@ final class ImageFetch
 | 
				
			|||||||
                CURLOPT_SSL_VERIFYPEER => true,
 | 
					                CURLOPT_SSL_VERIFYPEER => true,
 | 
				
			||||||
                CURLOPT_SSL_VERIFYHOST => 2,
 | 
					                CURLOPT_SSL_VERIFYHOST => 2,
 | 
				
			||||||
                CURLOPT_HTTPHEADER => $headers,
 | 
					                CURLOPT_HTTPHEADER => $headers,
 | 
				
			||||||
                CURLOPT_HEADER => false,
 | 
					                CURLOPT_HEADERFUNCTION => $headerFn,
 | 
				
			||||||
                CURLOPT_RETURNTRANSFER => false,      // stream direkt ins Filehandle
 | 
					                CURLOPT_WRITEFUNCTION => $writeFn,
 | 
				
			||||||
                CURLOPT_FILE => $fh,        // fallback, falls WRITEFUNCTION nicht greift
 | 
					                CURLOPT_RETURNTRANSFER => true, // **wichtig**: niemals direkt ausgeben
 | 
				
			||||||
                CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$received, $cfg, $fh) {
 | 
					                CURLOPT_FILE => $fh,
 | 
				
			||||||
                    $len = strlen($data);
 | 
					                CURLOPT_ACCEPT_ENCODING => '',
 | 
				
			||||||
                    $received += $len;
 | 
					 | 
				
			||||||
                    if ($received > (int) $cfg['max_bytes']) {
 | 
					 | 
				
			||||||
                        return 0; // -> CURLE_WRITE_ERROR
 | 
					 | 
				
			||||||
                    }
 | 
					 | 
				
			||||||
                    return fwrite($fh, $data);
 | 
					 | 
				
			||||||
                },
 | 
					 | 
				
			||||||
                CURLOPT_ACCEPT_ENCODING => '',         // gzip/br zulassen
 | 
					 | 
				
			||||||
            ];
 | 
					            ];
 | 
				
			||||||
            if (!empty($cfg['ip_resolve_v4']) && \defined('CURLOPT_IPRESOLVE') && \defined('CURL_IPRESOLVE_V4')) {
 | 
					            if ($httpVers === '2' && \defined('CURL_HTTP_VERSION_2_0')) {
 | 
				
			||||||
 | 
					                $opts[CURLOPT_HTTP_VERSION] = \constant('CURL_HTTP_VERSION_2_0');
 | 
				
			||||||
 | 
					            } elseif ($httpVers === '1.1' && \defined('CURL_HTTP_VERSION_1_1')) {
 | 
				
			||||||
 | 
					                $opts[CURLOPT_HTTP_VERSION] = \constant('CURL_HTTP_VERSION_1_1');
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            if ($ipPref === 'v4' && \defined('CURLOPT_IPRESOLVE') && \defined('CURL_IPRESOLVE_V4')) {
 | 
				
			||||||
                $opts[CURLOPT_IPRESOLVE] = \constant('CURL_IPRESOLVE_V4');
 | 
					                $opts[CURLOPT_IPRESOLVE] = \constant('CURL_IPRESOLVE_V4');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
            if ($referer) {
 | 
					
 | 
				
			||||||
                $opts[CURLOPT_REFERER] = $referer;
 | 
					            // Referer
 | 
				
			||||||
            }
 | 
					            if ($embeddingUrl)
 | 
				
			||||||
 | 
					                $opts[CURLOPT_REFERER] = $embeddingUrl;
 | 
				
			||||||
 | 
					            elseif ($defaultReferer)
 | 
				
			||||||
 | 
					                $opts[CURLOPT_REFERER] = $defaultReferer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Cookies
 | 
				
			||||||
 | 
					            $cookieStr = $cookieHeader($cookieJar);
 | 
				
			||||||
 | 
					            if ($cookieStr)
 | 
				
			||||||
 | 
					                $opts[CURLOPT_COOKIE] = $cookieStr;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            curl_setopt_array($ch, $opts);
 | 
					            curl_setopt_array($ch, $opts);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            $exec = curl_exec($ch);
 | 
					            // Wichtig: exec nie echoen lassen
 | 
				
			||||||
 | 
					            @curl_exec($ch);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
 | 
					            $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
 | 
				
			||||||
            $ctype = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: '');
 | 
					            $ctype = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: '');
 | 
				
			||||||
            $finalUrl = (string) (curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url);
 | 
					            $finalUrl = (string) (curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url);
 | 
				
			||||||
@@ -202,70 +333,110 @@ final class ImageFetch
 | 
				
			|||||||
            $lastHttp = $http;
 | 
					            $lastHttp = $http;
 | 
				
			||||||
            $lastCurlErr = $cerrStr;
 | 
					            $lastCurlErr = $cerrStr;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Abbruch durch Größenlimit → als 413 semantisch behandeln (Payload Too Large)
 | 
					            // Debug
 | 
				
			||||||
            if ($exec === false && $cerr === CURLE_WRITE_ERROR && $received > (int) $cfg['max_bytes']) {
 | 
					            if ($cfg['debug']) {
 | 
				
			||||||
 | 
					                $hdrLog = '';
 | 
				
			||||||
 | 
					                foreach ($respHeaders as $k => $vals)
 | 
				
			||||||
 | 
					                    foreach ($vals as $v)
 | 
				
			||||||
 | 
					                        $hdrLog .= "$k: $v | ";
 | 
				
			||||||
 | 
					                $hdrLog = rtrim($hdrLog, ' |');
 | 
				
			||||||
 | 
					                $peekHex = bin2hex($peekBuf);
 | 
				
			||||||
 | 
					                $peekTxt = preg_replace('/[^\x20-\x7E]/', '.', $peekBuf);
 | 
				
			||||||
 | 
					                error_log(sprintf(
 | 
				
			||||||
 | 
					                    "%s try#%d http=%d cerr=%d:%s ua='%s' ref='%s' vers=%s ip=%s eff='%s' ctype='%s' bytes=%d hdrs={%s} peek_hex=%s peek_txt=%s url=%s",
 | 
				
			||||||
 | 
					                    $cfg['log_prefix'],
 | 
				
			||||||
 | 
					                    $i + 1,
 | 
				
			||||||
 | 
					                    $http,
 | 
				
			||||||
 | 
					                    $cerr,
 | 
				
			||||||
 | 
					                    $cerrStr ?: '-',
 | 
				
			||||||
 | 
					                    $ua,
 | 
				
			||||||
 | 
					                    ($embeddingUrl ?: ($defaultReferer ?: '-')),
 | 
				
			||||||
 | 
					                    $httpVers,
 | 
				
			||||||
 | 
					                    $ipPref,
 | 
				
			||||||
 | 
					                    $finalUrl,
 | 
				
			||||||
 | 
					                    $ctype,
 | 
				
			||||||
 | 
					                    $received,
 | 
				
			||||||
 | 
					                    $hdrLog,
 | 
				
			||||||
 | 
					                    substr($peekHex, 0, 160),
 | 
				
			||||||
 | 
					                    substr($peekTxt, 0, 160),
 | 
				
			||||||
 | 
					                    $url
 | 
				
			||||||
 | 
					                ));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Größenlimit
 | 
				
			||||||
 | 
					            if ($cerr === (\defined('CURLE_WRITE_ERROR') ? \constant('CURLE_WRITE_ERROR') : 23) && $received > (int) $cfg['max_bytes']) {
 | 
				
			||||||
                @unlink($tmp);
 | 
					                @unlink($tmp);
 | 
				
			||||||
                error_log("{$cfg['log_prefix']} size limit hit after {$received} bytes url=$url");
 | 
					                error_log("{$cfg['log_prefix']} size limit hit after {$received} bytes url=$url");
 | 
				
			||||||
                return self::fail(null, null, 413, 'Bild zu groß');
 | 
					                return self::fail(null, null, 413, 'Bild zu groß');
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Erfolgspfad: 2xx + image/*
 | 
					            // Erfolg
 | 
				
			||||||
            if ($exec !== false && $http >= 200 && $http < 300 && stripos($ctype, 'image/') === 0) {
 | 
					            if ($http >= 200 && $http < 300) {
 | 
				
			||||||
 | 
					                if (stripos($ctype, 'image/') === 0) {
 | 
				
			||||||
                    $mime = $ctype;
 | 
					                    $mime = $ctype;
 | 
				
			||||||
                $ok = true;
 | 
					 | 
				
			||||||
                break;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // Nicht-Image trotz 2xx → Blockseite/HTML/CSS etc.
 | 
					 | 
				
			||||||
            if ($exec !== false && $http >= 200 && $http < 300 && stripos($ctype, 'image/') !== 0) {
 | 
					 | 
				
			||||||
                // Manche Server liefern leeren/fehlenden Content-Type. Letzter Rettungsanker: magic sniff via getimagesize
 | 
					 | 
				
			||||||
                $probe = @getimagesize($tmp);
 | 
					 | 
				
			||||||
                if ($probe !== false) {
 | 
					 | 
				
			||||||
                    $mime = $probe['mime'] ?? 'image/*';
 | 
					 | 
				
			||||||
                    $ok = true;
 | 
					 | 
				
			||||||
                    break;
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
                // sonst retry
 | 
					 | 
				
			||||||
                @unlink($tmp);
 | 
					 | 
				
			||||||
                $doRetry = ($i + 1) < $attempts;
 | 
					 | 
				
			||||||
                error_log("{$cfg['log_prefix']} bad ctype http=$http ctype={$ctype} retry=" . ($doRetry ? '1' : '0') . " url=$url");
 | 
					 | 
				
			||||||
                } else {
 | 
					                } else {
 | 
				
			||||||
                // Fehler oder Non-2xx → ggf. retry
 | 
					                    $probe = @getimagesize($tmp);
 | 
				
			||||||
                @unlink($tmp);
 | 
					                    if ($probe !== false)
 | 
				
			||||||
                $doRetry = ($i + 1) < $attempts &&
 | 
					                        $mime = $probe['mime'] ?? 'image/*';
 | 
				
			||||||
                    (in_array($http, self::$RETRY_HTTP, true) || in_array($cerr, self::retryCurlCodes(), true) || $http === 0);
 | 
					 | 
				
			||||||
                error_log("{$cfg['log_prefix']} fail http=$http curl={$cerr}:{$cerrStr} ua#{$i} retry=" . ($doRetry ? '1' : '0') . " url=$url");
 | 
					 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					                if ($mime) {
 | 
				
			||||||
            // Backoff + Jitter vorm nächsten Versuch
 | 
					 | 
				
			||||||
            if (($i + 1) < $attempts) {
 | 
					 | 
				
			||||||
                $sleepMs = (int) ($cfg['retry_backoff_ms'] * (2 ** $i) + random_int(0, 150));
 | 
					 | 
				
			||||||
                usleep($sleepMs * 1000);
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            // neues Tmp für nächsten Versuch
 | 
					 | 
				
			||||||
            $tmp = tempnam(sys_get_temp_dir(), 'wlimg_');
 | 
					 | 
				
			||||||
            if ($tmp === false) {
 | 
					 | 
				
			||||||
                return self::fail(null, null, 0, 'Temp-Datei Fehler');
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (!$ok) {
 | 
					 | 
				
			||||||
            return self::fail(null, $lastCurlErr, $lastHttp ?? 0, 'Bild-Download fehlgeschlagen');
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    return [
 | 
					                    return [
 | 
				
			||||||
                        'ok' => true,
 | 
					                        'ok' => true,
 | 
				
			||||||
                        'tmp_path' => $tmp,
 | 
					                        'tmp_path' => $tmp,
 | 
				
			||||||
                        'mime' => $mime,
 | 
					                        'mime' => $mime,
 | 
				
			||||||
            'http_code' => $lastHttp ?? 200,
 | 
					                        'http_code' => $http,
 | 
				
			||||||
                        'curl_err' => null,
 | 
					                        'curl_err' => null,
 | 
				
			||||||
                        'final_url' => $finalUrl,
 | 
					                        'final_url' => $finalUrl,
 | 
				
			||||||
                        'bytes' => $received,
 | 
					                        'bytes' => $received,
 | 
				
			||||||
 | 
					                        'error' => null
 | 
				
			||||||
                    ];
 | 
					                    ];
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
 | 
					                @unlink($tmp);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                @unlink($tmp);
 | 
				
			||||||
 | 
					                // Cookies aus Antwort sammeln
 | 
				
			||||||
 | 
					                $collectCookie($respHeaders);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /** Hilfsfunktionen */
 | 
					                // Cloudflare-Heuristik
 | 
				
			||||||
 | 
					                $cf403 = ($http === 403) && (
 | 
				
			||||||
 | 
					                    (isset($respHeaders['server']) && stripos(implode(',', $respHeaders['server']), 'cloudflare') !== false)
 | 
				
			||||||
 | 
					                    || isset($respHeaders['set-cookie'])
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                $doRetry = ($i + 1) < $attempts && (
 | 
				
			||||||
 | 
					                    in_array($http, self::$RETRY_HTTP, true) ||
 | 
				
			||||||
 | 
					                    in_array($cerr, self::retryCurlCodes(), true) ||
 | 
				
			||||||
 | 
					                    $http === 0 || $cf403
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if (!$doRetry)
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                $sleepMs = (int) ($cfg['retry_backoff_ms'] * (2 ** $i) + random_int(0, 150));
 | 
				
			||||||
 | 
					                usleep($sleepMs * 1000);
 | 
				
			||||||
 | 
					                continue;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Fail-open? (z. B. Ikea hart geblockt)
 | 
				
			||||||
 | 
					        foreach ((array) $cfg['failopen_hosts'] as $fo) {
 | 
				
			||||||
 | 
					            $fo = strtolower($fo);
 | 
				
			||||||
 | 
					            if ($host === $fo || str_ends_with($host, '.' . $fo)) {
 | 
				
			||||||
 | 
					                return [
 | 
				
			||||||
 | 
					                    'ok' => false,
 | 
				
			||||||
 | 
					                    'tmp_path' => null,
 | 
				
			||||||
 | 
					                    'mime' => null,
 | 
				
			||||||
 | 
					                    'http_code' => $lastHttp ?? 403,
 | 
				
			||||||
 | 
					                    'curl_err' => $lastCurlErr,
 | 
				
			||||||
 | 
					                    'final_url' => $url,
 | 
				
			||||||
 | 
					                    'bytes' => 0,
 | 
				
			||||||
 | 
					                    'error' => 'failopen',
 | 
				
			||||||
 | 
					                ];
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return self::fail(null, $lastCurlErr, $lastHttp ?? 0, 'Bild-Download fehlgeschlagen');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static function safeFileNameFromUrl(string $url): string
 | 
					    public static function safeFileNameFromUrl(string $url): string
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -294,8 +465,8 @@ final class ImageFetch
 | 
				
			|||||||
            return '';
 | 
					            return '';
 | 
				
			||||||
        $port = '';
 | 
					        $port = '';
 | 
				
			||||||
        if (!empty($p['port'])) {
 | 
					        if (!empty($p['port'])) {
 | 
				
			||||||
            $default = ($p['scheme'] === 'https') ? 443 : 80;
 | 
					            $def = $p['scheme'] === 'https' ? 443 : 80;
 | 
				
			||||||
            if ((int) $p['port'] !== $default)
 | 
					            if ((int) $p['port'] !== $def)
 | 
				
			||||||
                $port = ':' . $p['port'];
 | 
					                $port = ':' . $p['port'];
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return $p['scheme'] . '://' . $p['host'] . $port . '/';
 | 
					        return $p['scheme'] . '://' . $p['host'] . $port . '/';
 | 
				
			||||||
@@ -322,9 +493,8 @@ final class ImageFetch
 | 
				
			|||||||
            $cidrs = ['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'];
 | 
					            $cidrs = ['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'];
 | 
				
			||||||
        } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
 | 
					        } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
 | 
				
			||||||
            $cidrs = ['::1/128', 'fc00::/7', 'fe80::/10'];
 | 
					            $cidrs = ['::1/128', 'fc00::/7', 'fe80::/10'];
 | 
				
			||||||
        } else {
 | 
					        } else
 | 
				
			||||||
            return true;
 | 
					            return true;
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        foreach ($cidrs as $c)
 | 
					        foreach ($cidrs as $c)
 | 
				
			||||||
            if (self::ipInCidr($ip, $c))
 | 
					            if (self::ipInCidr($ip, $c))
 | 
				
			||||||
                return true;
 | 
					                return true;
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										68
									
								
								index.php
									
									
									
									
									
								
							
							
						
						
									
										68
									
								
								index.php
									
									
									
									
									
								
							@@ -418,7 +418,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
 | 
				
			|||||||
          <h5 class="modal-title" id="itemModalTitle">Wunsch hinzufügen</h5><button type="button" class="btn-close"
 | 
					          <h5 class="modal-title" id="itemModalTitle">Wunsch hinzufügen</h5><button type="button" class="btn-close"
 | 
				
			||||||
            data-bs-dismiss="modal"></button>
 | 
					            data-bs-dismiss="modal"></button>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <form action="item.php" method="POST" id="itemForm">
 | 
					        <form action="item.php" method="POST" id="itemForm" enctype="multipart/form-data">
 | 
				
			||||||
          <div class="modal-body">
 | 
					          <div class="modal-body">
 | 
				
			||||||
            <label class="form-label">Titel</label>
 | 
					            <label class="form-label">Titel</label>
 | 
				
			||||||
            <input class="form-control" name="ItemTitle" id="ItemTitle" required>
 | 
					            <input class="form-control" name="ItemTitle" id="ItemTitle" required>
 | 
				
			||||||
@@ -438,13 +438,73 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
 | 
				
			|||||||
            <label class="form-label mt-2">Link zum Angebot</label>
 | 
					            <label class="form-label mt-2">Link zum Angebot</label>
 | 
				
			||||||
            <input class="form-control" name="ItemLink" id="ItemLink" type="url" pattern="https?://.+">
 | 
					            <input class="form-control" name="ItemLink" id="ItemLink" type="url" pattern="https?://.+">
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <label class="form-label mt-2">Bild (URL)</label>
 | 
					            <hr class="my-3">
 | 
				
			||||||
            <input class="form-control" name="ItemImage" id="ItemImage" type="url" pattern="https?://.+">
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div class="form-check mt-2" id="RemoveImageWrap" style="display:none;">
 | 
					            <label class="form-label">Bild hinzufügen</label>
 | 
				
			||||||
 | 
					            <ul class="nav nav-tabs" id="imgTab" role="tablist">
 | 
				
			||||||
 | 
					              <li class="nav-item" role="presentation">
 | 
				
			||||||
 | 
					                <button class="nav-link active" id="tab-url" data-bs-toggle="tab" data-bs-target="#pane-url"
 | 
				
			||||||
 | 
					                  type="button" role="tab">Bild-URL</button>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					              <li class="nav-item" role="presentation">
 | 
				
			||||||
 | 
					                <button class="nav-link" id="tab-file" data-bs-toggle="tab" data-bs-target="#pane-file" type="button"
 | 
				
			||||||
 | 
					                  role="tab">Datei-Upload</button>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					              <li class="nav-item" role="presentation">
 | 
				
			||||||
 | 
					                <button class="nav-link" id="tab-paste" data-bs-toggle="tab" data-bs-target="#pane-paste" type="button"
 | 
				
			||||||
 | 
					                  role="tab">Aus Zwischenablage</button>
 | 
				
			||||||
 | 
					              </li>
 | 
				
			||||||
 | 
					            </ul>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="tab-content border border-top-0 rounded-bottom p-3" id="imgTabContent">
 | 
				
			||||||
 | 
					              <!-- URL -->
 | 
				
			||||||
 | 
					              <div class="tab-pane fade show active" id="pane-url" role="tabpanel" aria-labelledby="tab-url">
 | 
				
			||||||
 | 
					                <div class="mb-2">
 | 
				
			||||||
 | 
					                  <input class="form-control" name="ItemImageUrl" id="ItemImageUrl" type="url"
 | 
				
			||||||
 | 
					                    placeholder="https://…/bild.jpg" pattern="https?://.+">
 | 
				
			||||||
 | 
					                  <div class="form-text">Falls der Download scheitert, bitte Datei hochladen oder per Zwischenablage
 | 
				
			||||||
 | 
					                    einfügen.</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- FILE -->
 | 
				
			||||||
 | 
					              <div class="tab-pane fade" id="pane-file" role="tabpanel" aria-labelledby="tab-file">
 | 
				
			||||||
 | 
					                <div class="mb-2">
 | 
				
			||||||
 | 
					                  <input class="form-control" name="ItemImageFile" id="ItemImageFile" type="file" accept="image/*">
 | 
				
			||||||
 | 
					                  <div class="form-text">Max. 8 MB. Unterstützt gängige Formate (jpg, png, webp, avif…).</div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              <!-- PASTE -->
 | 
				
			||||||
 | 
					              <div class="tab-pane fade" id="pane-paste" role="tabpanel" aria-labelledby="tab-paste">
 | 
				
			||||||
 | 
					                <div class="mb-2 d-flex align-items-center gap-2">
 | 
				
			||||||
 | 
					                  <button type="button" class="btn btn-outline-secondary btn-sm" id="PasteActivator">
 | 
				
			||||||
 | 
					                    Aus Zwischenablage einfügen (Strg/Cmd+V)
 | 
				
			||||||
 | 
					                  </button>
 | 
				
			||||||
 | 
					                  <small class="text-muted">Tipp: Rechtsklick → „Bild kopieren“, dann hier klicken und einfügen.</small>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Unsichtbarer Paste-Catcher -->
 | 
				
			||||||
 | 
					                <div id="PasteZone" class="visually-hidden" contenteditable="true"></div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <!-- Hidden: Base64 + Name -->
 | 
				
			||||||
 | 
					                <input type="hidden" name="ItemImagePaste" id="ItemImagePaste">
 | 
				
			||||||
 | 
					                <input type="hidden" name="ItemImagePasteName" id="ItemImagePasteName" value="clipboard.png">
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <!-- Live Preview (für File/Paste/URL wenn möglich) -->
 | 
				
			||||||
 | 
					            <div class="mt-3" id="ImgPreviewWrap" style="display:none;">
 | 
				
			||||||
 | 
					              <label class="form-label">Vorschau</label>
 | 
				
			||||||
 | 
					              <img id="ImgPreview" src="" alt="Bildvorschau" class="img-fluid border rounded w-100"
 | 
				
			||||||
 | 
					                style="max-height:220px; object-fit:contain;">
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            <div class="form-check mt-3" id="RemoveImageWrap" style="display:none;">
 | 
				
			||||||
              <input class="form-check-input" type="checkbox" value="1" id="RemoveImage" name="RemoveImage">
 | 
					              <input class="form-check-input" type="checkbox" value="1" id="RemoveImage" name="RemoveImage">
 | 
				
			||||||
              <label class="form-check-label" for="RemoveImage">Aktuelles Bild entfernen</label>
 | 
					              <label class="form-check-label" for="RemoveImage">Aktuelles Bild entfernen</label>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div class="modal-footer">
 | 
					          <div class="modal-footer">
 | 
				
			||||||
            <input type="hidden" name="action" id="ItemAction" value="add">
 | 
					            <input type="hidden" name="action" id="ItemAction" value="add">
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										193
									
								
								item.php
									
									
									
									
									
								
							
							
						
						
									
										193
									
								
								item.php
									
									
									
									
									
								
							@@ -2,7 +2,6 @@
 | 
				
			|||||||
declare(strict_types=1);
 | 
					declare(strict_types=1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
require_once __DIR__ . '/include/image_fetch.php';
 | 
					require_once __DIR__ . '/include/image_fetch.php';
 | 
				
			||||||
 | 
					 | 
				
			||||||
use WList\Net\ImageFetch;
 | 
					use WList\Net\ImageFetch;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* ========= Session & Bootstrap ========= */
 | 
					/* ========= Session & Bootstrap ========= */
 | 
				
			||||||
@@ -93,6 +92,116 @@ function is_valid_http_url(string $url): bool
 | 
				
			|||||||
  return $s === 'http' || $s === 'https';
 | 
					  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 ============= */
 | 
					/* ============= Controller ============= */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
require_csrf();
 | 
					require_csrf();
 | 
				
			||||||
@@ -104,7 +213,12 @@ $ItemDescription = trim((string) ($_POST['ItemDescription'] ?? ''));
 | 
				
			|||||||
$ItemPrice = parse_price_to_cents((string) ($_POST['ItemPrice'] ?? ''));
 | 
					$ItemPrice = parse_price_to_cents((string) ($_POST['ItemPrice'] ?? ''));
 | 
				
			||||||
$ItemQty = isset($_POST['ItemQty']) ? max(1, (int) $_POST['ItemQty']) : 1;
 | 
					$ItemQty = isset($_POST['ItemQty']) ? max(1, (int) $_POST['ItemQty']) : 1;
 | 
				
			||||||
$ItemLink = trim((string) ($_POST['ItemLink'] ?? ''));
 | 
					$ItemLink = trim((string) ($_POST['ItemLink'] ?? ''));
 | 
				
			||||||
$ItemImageUrl = trim((string) ($_POST['ItemImage'] ?? ''));   // optional URL zum Pull
 | 
					
 | 
				
			||||||
 | 
					// 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'] ?? ''));
 | 
					$ListUUID = trim((string) ($_POST['ItemListUUID'] ?? ''));
 | 
				
			||||||
$sortbyTransfer = (string) ($_POST['sortby_transfer'] ?? 'priority');
 | 
					$sortbyTransfer = (string) ($_POST['sortby_transfer'] ?? 'priority');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -141,54 +255,52 @@ if ($ItemTitle === '')
 | 
				
			|||||||
if ($ItemLink !== '' && !is_valid_http_url($ItemLink))
 | 
					if ($ItemLink !== '' && !is_valid_http_url($ItemLink))
 | 
				
			||||||
  fail('Ungültiger Angebotslink', 400);
 | 
					  fail('Ungültiger Angebotslink', 400);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* Optional: Bild von externer URL holen */
 | 
					/* ===== Bild verarbeiten: Upload → Paste → URL ===== */
 | 
				
			||||||
$imageLocalLink = null;
 | 
					$imageLocalLink = null;
 | 
				
			||||||
if (!$removeImage && $ItemImageUrl !== '') {
 | 
					$hasUpload = isset($_FILES['ItemImageFile']) && is_array($_FILES['ItemImageFile']) && !empty($_FILES['ItemImageFile']['name']);
 | 
				
			||||||
 | 
					$hasPaste = $ItemImagePaste !== '';
 | 
				
			||||||
 | 
					$hasUrl = $ItemImageUrl !== '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  $whitelist = $image_host_whitelist ?? null;
 | 
					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, [
 | 
					    $fetch = ImageFetch::download($ItemImageUrl, [
 | 
				
			||||||
 | 
					      'page_url' => $ItemLink !== '' && is_valid_http_url($ItemLink) ? $ItemLink : null,
 | 
				
			||||||
      'max_bytes' => 8_000_000,
 | 
					      'max_bytes' => 8_000_000,
 | 
				
			||||||
      'timeout' => 12,
 | 
					      'timeout' => 12,
 | 
				
			||||||
      'connect_timeout' => 5,
 | 
					      'connect_timeout' => 5,
 | 
				
			||||||
    'retries' => 4,
 | 
					      'retries' => 5,
 | 
				
			||||||
      'retry_backoff_ms' => 300,
 | 
					      'retry_backoff_ms' => 300,
 | 
				
			||||||
    'whitelist_hosts' => $whitelist,
 | 
					      'whitelist_hosts' => $image_host_whitelist ?? null,
 | 
				
			||||||
    'ip_resolve_v4' => true,
 | 
					      'referer' => 'auto',        // Falls keine page_url, nimmt er Bild-Origin
 | 
				
			||||||
    'referer' => 'auto',
 | 
					 | 
				
			||||||
      'log_prefix' => 'wishlist-img',
 | 
					      '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']) {
 | 
					    if (!$fetch['ok']) {
 | 
				
			||||||
      error_log("wishlist image error: http=" . ($fetch['http_code'] ?? 0) . " curl=" . ($fetch['curl_err'] ?? '-') . " url=$ItemImageUrl");
 | 
					      error_log("wishlist image error: http=" . ($fetch['http_code'] ?? 0) . " curl=" . ($fetch['curl_err'] ?? '-') . " url=$ItemImageUrl");
 | 
				
			||||||
      $conn->close();
 | 
					      $conn->close();
 | 
				
			||||||
    fail('Bild-Download fehlgeschlagen', 400);
 | 
					      fail('Bild-Download fehlgeschlagen. Bitte Bild manuell speichern/hochladen oder per Zwischenablage einfügen und erneut versuchen.', 400);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  $info = @getimagesize($fetch['tmp_path']);
 | 
					    $imageLocalLink = save_image_from_tmp($fetch['tmp_path'], $ItemImageUrl);
 | 
				
			||||||
  $mime = $info['mime'] ?? $fetch['mime'] ?? 'image/*';
 | 
					 | 
				
			||||||
  if (stripos($mime, 'image/') !== 0) {
 | 
					 | 
				
			||||||
    @unlink($fetch['tmp_path']);
 | 
					 | 
				
			||||||
    $conn->close();
 | 
					 | 
				
			||||||
    fail('Link ist kein gültiges Bild', 400);
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  global $imagedir;
 | 
					 | 
				
			||||||
  if (!is_dir($imagedir))
 | 
					 | 
				
			||||||
    @mkdir($imagedir, 0755, true);
 | 
					 | 
				
			||||||
  $filename = ImageFetch::safeFileNameFromUrl($ItemImageUrl);
 | 
					 | 
				
			||||||
  $target = rtrim($imagedir, '/') . '/' . $filename;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (!@rename($fetch['tmp_path'], $target)) {
 | 
					 | 
				
			||||||
    if (!@copy($fetch['tmp_path'], $target)) {
 | 
					 | 
				
			||||||
      @unlink($fetch['tmp_path']);
 | 
					 | 
				
			||||||
      $conn->close();
 | 
					 | 
				
			||||||
      fail('Bildspeicherung fehlgeschlagen', 500);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    @unlink($fetch['tmp_path']);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  @chmod($target, 0644);
 | 
					 | 
				
			||||||
  $imageLocalLink = $filename;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* ====== ADD ====== */
 | 
					/* ====== ADD ====== */
 | 
				
			||||||
@@ -227,8 +339,9 @@ if ($action === 'add') {
 | 
				
			|||||||
    fail('Speichern fehlgeschlagen', 500);
 | 
					    fail('Speichern fehlgeschlagen', 500);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  $stmt->close();
 | 
					  $stmt->close();
 | 
				
			||||||
}
 | 
					
 | 
				
			||||||
/* ====== EDIT ====== */ elseif ($action === 'edit') {
 | 
					  /* ====== EDIT ====== */
 | 
				
			||||||
 | 
					} elseif ($action === 'edit') {
 | 
				
			||||||
  if ($WhishID <= 0) {
 | 
					  if ($WhishID <= 0) {
 | 
				
			||||||
    $conn->close();
 | 
					    $conn->close();
 | 
				
			||||||
    fail('Ungültige Item-ID', 400);
 | 
					    fail('Ungültige Item-ID', 400);
 | 
				
			||||||
@@ -251,16 +364,14 @@ if ($action === 'add') {
 | 
				
			|||||||
  $newImage = $oldImage;
 | 
					  $newImage = $oldImage;
 | 
				
			||||||
  if ($removeImage) {
 | 
					  if ($removeImage) {
 | 
				
			||||||
    if (!empty($oldImage)) {
 | 
					    if (!empty($oldImage)) {
 | 
				
			||||||
      global $imagedir;
 | 
					      $full = ensure_imagedir() . '/' . $oldImage;
 | 
				
			||||||
      $full = rtrim($imagedir, '/') . '/' . $oldImage;
 | 
					 | 
				
			||||||
      if (is_file($full))
 | 
					      if (is_file($full))
 | 
				
			||||||
        @unlink($full);
 | 
					        @unlink($full);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    $newImage = '';
 | 
					    $newImage = '';
 | 
				
			||||||
  } elseif ($imageLocalLink !== null) {
 | 
					  } elseif ($imageLocalLink !== null) {
 | 
				
			||||||
    if (!empty($oldImage)) {
 | 
					    if (!empty($oldImage)) {
 | 
				
			||||||
      global $imagedir;
 | 
					      $full = ensure_imagedir() . '/' . $oldImage;
 | 
				
			||||||
      $full = rtrim($imagedir, '/') . '/' . $oldImage;
 | 
					 | 
				
			||||||
      if (is_file($full))
 | 
					      if (is_file($full))
 | 
				
			||||||
        @unlink($full);
 | 
					        @unlink($full);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@@ -291,8 +402,8 @@ if ($action === 'add') {
 | 
				
			|||||||
    fail('Update fehlgeschlagen', 500);
 | 
					    fail('Update fehlgeschlagen', 500);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  $stmt->close();
 | 
					  $stmt->close();
 | 
				
			||||||
}
 | 
					
 | 
				
			||||||
/* ====== Unbekannt ====== */ else {
 | 
					} else {
 | 
				
			||||||
  $conn->close();
 | 
					  $conn->close();
 | 
				
			||||||
  fail('Unbekannte Aktion', 400);
 | 
					  fail('Unbekannte Aktion', 400);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										322
									
								
								js/wishlist.js
									
									
									
									
									
								
							
							
						
						
									
										322
									
								
								js/wishlist.js
									
									
									
									
									
								
							@@ -2,16 +2,24 @@
 | 
				
			|||||||
(function () {
 | 
					(function () {
 | 
				
			||||||
  "use strict";
 | 
					  "use strict";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Sort direkt submitten
 | 
					  // ===== Helper =====
 | 
				
			||||||
  const sortSel = document.getElementById("sortby");
 | 
					  function $(sel, root) {
 | 
				
			||||||
 | 
					    return (root || document).querySelector(sel);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  function $all(sel, root) {
 | 
				
			||||||
 | 
					    return Array.from((root || document).querySelectorAll(sel));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // ===== Sort direkt submitten =====
 | 
				
			||||||
 | 
					  const sortSel = $("#sortby");
 | 
				
			||||||
  if (sortSel && sortSel.form) {
 | 
					  if (sortSel && sortSel.form) {
 | 
				
			||||||
    sortSel.addEventListener("change", function () {
 | 
					    sortSel.addEventListener("change", function () {
 | 
				
			||||||
      this.form.submit();
 | 
					      this.form.submit();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Reservation Modal
 | 
					  // ===== Reservation Modal =====
 | 
				
			||||||
  const reservationModal = document.getElementById("reservationModal");
 | 
					  const reservationModal = $("#reservationModal");
 | 
				
			||||||
  if (reservationModal) {
 | 
					  if (reservationModal) {
 | 
				
			||||||
    reservationModal.addEventListener("show.bs.modal", function (ev) {
 | 
					    reservationModal.addEventListener("show.bs.modal", function (ev) {
 | 
				
			||||||
      const btn = ev.relatedTarget;
 | 
					      const btn = ev.relatedTarget;
 | 
				
			||||||
@@ -19,13 +27,12 @@
 | 
				
			|||||||
      const wishid = btn.getAttribute("data-wishid");
 | 
					      const wishid = btn.getAttribute("data-wishid");
 | 
				
			||||||
      const reserved = btn.getAttribute("data-reserved");
 | 
					      const reserved = btn.getAttribute("data-reserved");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      reservationModal.querySelector("#modal-wishid").value = wishid || "";
 | 
					      $("#modal-wishid", reservationModal).value = wishid || "";
 | 
				
			||||||
      reservationModal.querySelector("#modal-reservedstat").value =
 | 
					      $("#modal-reservedstat", reservationModal).value = reserved || "";
 | 
				
			||||||
        reserved || "";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const submitBtn = reservationModal.querySelector("#reservation-submit");
 | 
					      const submitBtn = $("#reservation-submit", reservationModal);
 | 
				
			||||||
      const titleEl = reservationModal.querySelector("#reservationModalLabel");
 | 
					      const titleEl = $("#reservationModalLabel", reservationModal);
 | 
				
			||||||
      const infoEl = reservationModal.querySelector("#ReservationInfoText");
 | 
					      const infoEl = $("#ReservationInfoText", reservationModal);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (reserved === "1") {
 | 
					      if (reserved === "1") {
 | 
				
			||||||
        submitBtn.textContent = "Reservierung aufheben";
 | 
					        submitBtn.textContent = "Reservierung aufheben";
 | 
				
			||||||
@@ -39,8 +46,8 @@
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Delete Modal
 | 
					  // ===== Delete Modal =====
 | 
				
			||||||
  const deleteModal = document.getElementById("deleteModal");
 | 
					  const deleteModal = $("#deleteModal");
 | 
				
			||||||
  if (deleteModal) {
 | 
					  if (deleteModal) {
 | 
				
			||||||
    deleteModal.addEventListener("show.bs.modal", function (ev) {
 | 
					    deleteModal.addEventListener("show.bs.modal", function (ev) {
 | 
				
			||||||
      const btn = ev.relatedTarget;
 | 
					      const btn = ev.relatedTarget;
 | 
				
			||||||
@@ -51,7 +58,6 @@
 | 
				
			|||||||
        : "";
 | 
					        : "";
 | 
				
			||||||
      const wishid = btn.getAttribute("data-wishid") || "";
 | 
					      const wishid = btn.getAttribute("data-wishid") || "";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // robust: suche entweder #DelWhishID ODER #WhishID
 | 
					 | 
				
			||||||
      const idInput = deleteModal.querySelector(
 | 
					      const idInput = deleteModal.querySelector(
 | 
				
			||||||
        '#DelWhishID, #WhishID, input[name="WhishID"]'
 | 
					        '#DelWhishID, #WhishID, input[name="WhishID"]'
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
@@ -64,8 +70,8 @@
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Push Prio Modal
 | 
					  // ===== Push Prio Modal =====
 | 
				
			||||||
  const prioModal = document.getElementById("pushprioModal");
 | 
					  const prioModal = $("#pushprioModal");
 | 
				
			||||||
  if (prioModal) {
 | 
					  if (prioModal) {
 | 
				
			||||||
    prioModal.addEventListener("show.bs.modal", function (ev) {
 | 
					    prioModal.addEventListener("show.bs.modal", function (ev) {
 | 
				
			||||||
      const btn = ev.relatedTarget;
 | 
					      const btn = ev.relatedTarget;
 | 
				
			||||||
@@ -88,28 +94,118 @@
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Add/Edit Item Modal
 | 
					  // ===== Add/Edit Item Modal =====
 | 
				
			||||||
  const itemModal = document.getElementById("itemModal");
 | 
					  const itemModal = $("#itemModal");
 | 
				
			||||||
 | 
					  let _currentPreviewObjectUrl = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function revokePreviewUrl() {
 | 
				
			||||||
 | 
					    if (_currentPreviewObjectUrl) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        URL.revokeObjectURL(_currentPreviewObjectUrl);
 | 
				
			||||||
 | 
					      } catch (e) {}
 | 
				
			||||||
 | 
					      _currentPreviewObjectUrl = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (itemModal) {
 | 
					  if (itemModal) {
 | 
				
			||||||
    itemModal.addEventListener("show.bs.modal", function (ev) {
 | 
					    itemModal.addEventListener("show.bs.modal", function (ev) {
 | 
				
			||||||
      const btn = ev.relatedTarget;
 | 
					      const btn = ev.relatedTarget;
 | 
				
			||||||
      if (!btn) return;
 | 
					      if (!btn) return;
 | 
				
			||||||
      const mode = btn.getAttribute("data-mode") || "add";
 | 
					      const mode = btn.getAttribute("data-mode") || "add";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const titleEl = itemModal.querySelector("#itemModalTitle");
 | 
					      const titleEl = $("#itemModalTitle", itemModal);
 | 
				
			||||||
      const actionEl = itemModal.querySelector("#ItemAction");
 | 
					      const actionEl = $("#ItemAction", itemModal);
 | 
				
			||||||
      const submitEl = itemModal.querySelector("#ItemSubmitBtn");
 | 
					      const submitEl = $("#ItemSubmitBtn", itemModal);
 | 
				
			||||||
      const removeWrap = itemModal.querySelector("#RemoveImageWrap");
 | 
					      const removeWrap = $("#RemoveImageWrap", itemModal);
 | 
				
			||||||
      const removeChk = itemModal.querySelector("#RemoveImage");
 | 
					      const removeChk = $("#RemoveImage", itemModal);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Felder
 | 
					      // Felder
 | 
				
			||||||
      const fTitle = itemModal.querySelector("#ItemTitle");
 | 
					      const fTitle = $("#ItemTitle", itemModal);
 | 
				
			||||||
      const fDesc = itemModal.querySelector("#ItemDescription");
 | 
					      const fDesc = $("#ItemDescription", itemModal);
 | 
				
			||||||
      const fPrice = itemModal.querySelector("#ItemPrice");
 | 
					      const fPrice = $("#ItemPrice", itemModal);
 | 
				
			||||||
      const fLink = itemModal.querySelector("#ItemLink");
 | 
					      const fLink = $("#ItemLink", itemModal);
 | 
				
			||||||
      const fImg = itemModal.querySelector("#ItemImage");
 | 
					      const fId = $("#WhishID", itemModal);
 | 
				
			||||||
      const fId = itemModal.querySelector("#WhishID");
 | 
					      const fQty = $("#ItemQty", itemModal);
 | 
				
			||||||
      const fQty = itemModal.querySelector("#ItemQty"); // <-- NEU
 | 
					
 | 
				
			||||||
 | 
					      // Bild-Felder
 | 
				
			||||||
 | 
					      const fImgUrl = $("#ItemImageUrl", itemModal);
 | 
				
			||||||
 | 
					      const fImgFile = $("#ItemImageFile", itemModal);
 | 
				
			||||||
 | 
					      const pasteZone = $("#PasteZone", itemModal);
 | 
				
			||||||
 | 
					      const pasteVal = $("#ItemImagePaste", itemModal);
 | 
				
			||||||
 | 
					      const pasteName = $("#ItemImagePasteName", itemModal);
 | 
				
			||||||
 | 
					      const prevWrap = $("#ImgPreviewWrap", itemModal);
 | 
				
			||||||
 | 
					      const prevImg = $("#ImgPreview", itemModal);
 | 
				
			||||||
 | 
					      const pasteActivator = $("#PasteActivator", itemModal);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Preview-Styling erzwingen (falls keine CSS-Regel vorhanden)
 | 
				
			||||||
 | 
					      if (prevWrap) {
 | 
				
			||||||
 | 
					        prevWrap.style.maxHeight = "240px";
 | 
				
			||||||
 | 
					        prevWrap.style.overflow = "hidden";
 | 
				
			||||||
 | 
					        prevWrap.style.border = "1px solid rgba(0,0,0,.1)";
 | 
				
			||||||
 | 
					        prevWrap.style.borderRadius = ".5rem";
 | 
				
			||||||
 | 
					        prevWrap.style.padding = ".5rem";
 | 
				
			||||||
 | 
					        prevWrap.style.background = "#fff";
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (prevImg) {
 | 
				
			||||||
 | 
					        prevImg.style.maxWidth = "100%";
 | 
				
			||||||
 | 
					        prevImg.style.maxHeight = "220px";
 | 
				
			||||||
 | 
					        prevImg.style.objectFit = "contain";
 | 
				
			||||||
 | 
					        prevImg.classList.add("img-fluid", "border", "rounded", "w-100");
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      function showPreview(src) {
 | 
				
			||||||
 | 
					        revokePreviewUrl();
 | 
				
			||||||
 | 
					        if (!prevWrap || !prevImg) return;
 | 
				
			||||||
 | 
					        if (!src) {
 | 
				
			||||||
 | 
					          prevWrap.style.display = "none";
 | 
				
			||||||
 | 
					          prevImg.removeAttribute("src");
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        prevImg.src = src;
 | 
				
			||||||
 | 
					        prevWrap.style.display = "block";
 | 
				
			||||||
 | 
					        if (src.startsWith("blob:")) _currentPreviewObjectUrl = src;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      function clearUrl() {
 | 
				
			||||||
 | 
					        if (fImgUrl) fImgUrl.value = "";
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      function clearFile() {
 | 
				
			||||||
 | 
					        if (fImgFile) fImgFile.value = "";
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      function clearPaste() {
 | 
				
			||||||
 | 
					        if (pasteVal) pasteVal.value = "";
 | 
				
			||||||
 | 
					        if (pasteName) pasteName.value = "clipboard.png";
 | 
				
			||||||
 | 
					        if (pasteZone) {
 | 
				
			||||||
 | 
					          pasteZone.innerHTML = "";
 | 
				
			||||||
 | 
					          pasteZone.setAttribute("data-ph-active", "1");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      function clearPreviewAndInputs() {
 | 
				
			||||||
 | 
					        showPreview("");
 | 
				
			||||||
 | 
					        clearUrl();
 | 
				
			||||||
 | 
					        clearFile();
 | 
				
			||||||
 | 
					        clearPaste();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      function focusPasteCatcher() {
 | 
				
			||||||
 | 
					        if (!pasteZone) return;
 | 
				
			||||||
 | 
					        // kurz sichtbar machen, fokus holen, dann wieder verstecken
 | 
				
			||||||
 | 
					        pasteZone.classList.remove("visually-hidden");
 | 
				
			||||||
 | 
					        pasteZone.focus();
 | 
				
			||||||
 | 
					        // cursor ans ende
 | 
				
			||||||
 | 
					        const sel = window.getSelection();
 | 
				
			||||||
 | 
					        if (sel && sel.rangeCount === 0) {
 | 
				
			||||||
 | 
					          const r = document.createRange();
 | 
				
			||||||
 | 
					          r.selectNodeContents(pasteZone);
 | 
				
			||||||
 | 
					          r.collapse(false);
 | 
				
			||||||
 | 
					          sel.removeAllRanges();
 | 
				
			||||||
 | 
					          sel.addRange(r);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        // wieder verstecken – Fokus bleibt (Browser lässt Paste trotzdem zu)
 | 
				
			||||||
 | 
					        setTimeout(() => pasteZone.classList.add("visually-hidden"), 10);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // —— Modal immer CLEAN beim Öffnen ——
 | 
				
			||||||
 | 
					      clearPreviewAndInputs();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (mode === "edit") {
 | 
					      if (mode === "edit") {
 | 
				
			||||||
        titleEl.textContent = "Wunsch bearbeiten";
 | 
					        titleEl.textContent = "Wunsch bearbeiten";
 | 
				
			||||||
@@ -123,15 +219,14 @@
 | 
				
			|||||||
        const dDesc = btn.getAttribute("data-description") || "";
 | 
					        const dDesc = btn.getAttribute("data-description") || "";
 | 
				
			||||||
        const dPrice = btn.getAttribute("data-price") || "";
 | 
					        const dPrice = btn.getAttribute("data-price") || "";
 | 
				
			||||||
        const dLink = btn.getAttribute("data-link") || "";
 | 
					        const dLink = btn.getAttribute("data-link") || "";
 | 
				
			||||||
        const dQty = btn.getAttribute("data-qty") || "1"; // <-- NEU
 | 
					        const dQty = btn.getAttribute("data-qty") || "1";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if (fId) fId.value = wishid;
 | 
					        if (fId) fId.value = wishid;
 | 
				
			||||||
        if (fTitle) fTitle.value = dTitle;
 | 
					        if (fTitle) fTitle.value = dTitle;
 | 
				
			||||||
        if (fDesc) fDesc.value = dDesc;
 | 
					        if (fDesc) fDesc.value = dDesc;
 | 
				
			||||||
        if (fPrice) fPrice.value = dPrice;
 | 
					        if (fPrice) fPrice.value = dPrice;
 | 
				
			||||||
        if (fLink) fLink.value = dLink;
 | 
					        if (fLink) fLink.value = dLink;
 | 
				
			||||||
        if (fImg) fImg.value = "";
 | 
					        if (fQty) fQty.value = dQty;
 | 
				
			||||||
        if (fQty) fQty.value = dQty; // <-- NEU
 | 
					 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        titleEl.textContent = "Wunsch hinzufügen";
 | 
					        titleEl.textContent = "Wunsch hinzufügen";
 | 
				
			||||||
        actionEl.value = "add";
 | 
					        actionEl.value = "add";
 | 
				
			||||||
@@ -141,11 +236,170 @@
 | 
				
			|||||||
        if (fDesc) fDesc.value = "";
 | 
					        if (fDesc) fDesc.value = "";
 | 
				
			||||||
        if (fPrice) fPrice.value = "";
 | 
					        if (fPrice) fPrice.value = "";
 | 
				
			||||||
        if (fLink) fLink.value = "";
 | 
					        if (fLink) fLink.value = "";
 | 
				
			||||||
        if (fImg) fImg.value = "";
 | 
					        if (fQty) fQty.value = "1";
 | 
				
			||||||
        if (fQty) fQty.value = "1"; // <-- NEU
 | 
					 | 
				
			||||||
        if (removeWrap) removeWrap.style.display = "none";
 | 
					        if (removeWrap) removeWrap.style.display = "none";
 | 
				
			||||||
        if (removeChk) removeChk.checked = false;
 | 
					        if (removeChk) removeChk.checked = false;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // --- Bild-URL
 | 
				
			||||||
 | 
					      if (fImgUrl) {
 | 
				
			||||||
 | 
					        fImgUrl.addEventListener(
 | 
				
			||||||
 | 
					          "input",
 | 
				
			||||||
 | 
					          () => {
 | 
				
			||||||
 | 
					            const val = fImgUrl.value.trim();
 | 
				
			||||||
 | 
					            if (val !== "") {
 | 
				
			||||||
 | 
					              clearFile();
 | 
				
			||||||
 | 
					              clearPaste();
 | 
				
			||||||
 | 
					              showPreview(val);
 | 
				
			||||||
 | 
					            } else showPreview("");
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          { once: true }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // --- Datei-Upload
 | 
				
			||||||
 | 
					      if (fImgFile) {
 | 
				
			||||||
 | 
					        fImgFile.addEventListener(
 | 
				
			||||||
 | 
					          "change",
 | 
				
			||||||
 | 
					          () => {
 | 
				
			||||||
 | 
					            if (fImgFile.files && fImgFile.files[0]) {
 | 
				
			||||||
 | 
					              const f = fImgFile.files[0];
 | 
				
			||||||
 | 
					              if (f.size > 8 * 1024 * 1024) {
 | 
				
			||||||
 | 
					                alert("Datei ist größer als 8 MB.");
 | 
				
			||||||
 | 
					                fImgFile.value = "";
 | 
				
			||||||
 | 
					                showPreview("");
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					              clearUrl();
 | 
				
			||||||
 | 
					              clearPaste();
 | 
				
			||||||
 | 
					              const blobUrl = URL.createObjectURL(f);
 | 
				
			||||||
 | 
					              showPreview(blobUrl);
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					              showPreview("");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          { once: true }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // --- Paste aus Zwischenablage
 | 
				
			||||||
 | 
					      if (pasteZone) {
 | 
				
			||||||
 | 
					        pasteZone.setAttribute(
 | 
				
			||||||
 | 
					          "data-placeholder",
 | 
				
			||||||
 | 
					          pasteZone.getAttribute("data-placeholder") ||
 | 
				
			||||||
 | 
					            "Hier klicken und dann Strg+V / Cmd+V"
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        pasteZone.setAttribute("data-ph-active", "1");
 | 
				
			||||||
 | 
					        pasteZone.addEventListener(
 | 
				
			||||||
 | 
					          "focus",
 | 
				
			||||||
 | 
					          () => {
 | 
				
			||||||
 | 
					            if (pasteZone.getAttribute("data-ph-active") === "1") {
 | 
				
			||||||
 | 
					              pasteZone.innerHTML = "";
 | 
				
			||||||
 | 
					              pasteZone.removeAttribute("data-ph-active");
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          { once: true }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        pasteZone.addEventListener("blur", () => {
 | 
				
			||||||
 | 
					          if (pasteZone.innerText.trim() === "") {
 | 
				
			||||||
 | 
					            pasteZone.innerHTML = "";
 | 
				
			||||||
 | 
					            pasteZone.setAttribute("data-ph-active", "1");
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        // verhindert, dass Text-Schnipsel sichtbar werden
 | 
				
			||||||
 | 
					        pasteZone.addEventListener("input", () => {
 | 
				
			||||||
 | 
					          pasteZone.textContent = "";
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // keine Drops zulassen
 | 
				
			||||||
 | 
					        ["dragover", "drop"].forEach((evName) => {
 | 
				
			||||||
 | 
					          pasteZone.addEventListener(evName, (e) => {
 | 
				
			||||||
 | 
					            e.preventDefault();
 | 
				
			||||||
 | 
					            e.stopPropagation();
 | 
				
			||||||
 | 
					          });
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        pasteZone.addEventListener(
 | 
				
			||||||
 | 
					          "paste",
 | 
				
			||||||
 | 
					          (ev) => {
 | 
				
			||||||
 | 
					            const items =
 | 
				
			||||||
 | 
					              ev.clipboardData && ev.clipboardData.items
 | 
				
			||||||
 | 
					                ? ev.clipboardData.items
 | 
				
			||||||
 | 
					                : [];
 | 
				
			||||||
 | 
					            let imgItem = null;
 | 
				
			||||||
 | 
					            for (let i = 0; i < items.length; i++) {
 | 
				
			||||||
 | 
					              const t = items[i].type || "";
 | 
				
			||||||
 | 
					              if (t.startsWith("image/")) {
 | 
				
			||||||
 | 
					                imgItem = items[i];
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // nur Bilder erlaubt
 | 
				
			||||||
 | 
					            if (!imgItem) {
 | 
				
			||||||
 | 
					              ev.preventDefault();
 | 
				
			||||||
 | 
					              // optional Hinweis
 | 
				
			||||||
 | 
					              alert("Bitte nur Bilder einfügen (z. B. über „Bild kopieren“).");
 | 
				
			||||||
 | 
					              return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            ev.preventDefault();
 | 
				
			||||||
 | 
					            const blob = imgItem.getAsFile();
 | 
				
			||||||
 | 
					            if (!blob) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (blob.size > 8 * 1024 * 1024) {
 | 
				
			||||||
 | 
					              alert("Paste-Bild ist größer als 8 MB.");
 | 
				
			||||||
 | 
					              return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const ext =
 | 
				
			||||||
 | 
					              blob.type && blob.type.split("/")[1]
 | 
				
			||||||
 | 
					                ? blob.type.split("/")[1]
 | 
				
			||||||
 | 
					                : "png";
 | 
				
			||||||
 | 
					            if (pasteName) pasteName.value = "clipboard." + ext;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            const reader = new FileReader();
 | 
				
			||||||
 | 
					            reader.onload = function () {
 | 
				
			||||||
 | 
					              const dataUrl = reader.result; // data:image/*;base64,...
 | 
				
			||||||
 | 
					              if (pasteVal) pasteVal.value = dataUrl;
 | 
				
			||||||
 | 
					              clearUrl();
 | 
				
			||||||
 | 
					              clearFile();
 | 
				
			||||||
 | 
					              showPreview(dataUrl);
 | 
				
			||||||
 | 
					            };
 | 
				
			||||||
 | 
					            reader.readAsDataURL(blob);
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          { once: true }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (pasteActivator) {
 | 
				
			||||||
 | 
					        pasteActivator.addEventListener(
 | 
				
			||||||
 | 
					          "click",
 | 
				
			||||||
 | 
					          () => {
 | 
				
			||||||
 | 
					            // andere Inputs leeren, Preview resetten
 | 
				
			||||||
 | 
					            clearUrl();
 | 
				
			||||||
 | 
					            clearFile();
 | 
				
			||||||
 | 
					            // Fokus in den versteckten Paste-Catcher
 | 
				
			||||||
 | 
					            focusPasteCatcher();
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          { once: true }
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Beim Schließen: wirklich ALLES wieder clean + Blob-URL freigeben
 | 
				
			||||||
 | 
					      itemModal.addEventListener(
 | 
				
			||||||
 | 
					        "hidden.bs.modal",
 | 
				
			||||||
 | 
					        () => {
 | 
				
			||||||
 | 
					          revokePreviewUrl();
 | 
				
			||||||
 | 
					          if (prevWrap) prevWrap.style.display = "none";
 | 
				
			||||||
 | 
					          if (prevImg) prevImg.removeAttribute("src");
 | 
				
			||||||
 | 
					          clearUrl();
 | 
				
			||||||
 | 
					          clearFile();
 | 
				
			||||||
 | 
					          clearPaste();
 | 
				
			||||||
 | 
					          if (removeChk) removeChk.checked = false;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        { once: true }
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
})();
 | 
					})();
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user