diff --git a/css/tweaks.css b/css/tweaks.css index 7620123..b8a0cb5 100644 --- a/css/tweaks.css +++ b/css/tweaks.css @@ -248,3 +248,35 @@ body { 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; +} diff --git a/include/image_fetch.php b/include/image_fetch.php index 244dd56..ffd2985 100644 --- a/include/image_fetch.php +++ b/include/image_fetch.php @@ -2,28 +2,26 @@ declare(strict_types=1); /** - * Robust Image Downloader (CDN/Anti-Bot freundlich) - * - Validiert URL + DNS (keine private IPs/SSRF) - * - Host-Whitelist optional - * - Rotiert mehrere echte Browser-UAs - * - Setzt realistische Headers (Accept: AVIF/WebP etc.) - * - Referer automatisch auf Origin - * - IPv4 bevorzugt - * - Follow Redirects - * - Strict Content-Type-Check (image/*) - * - Byte-Limit-Abbruch im Stream - * - Retries mit Exponential Backoff + Jitter - * - Liefert Temp-Datei; Caller verschiebt/benennt final + * Robust Image Downloader (universal, CDN/Bot-freundlich) + * - URL/DNS-Checks (kein SSRF, keine privaten IPs) + * - optionale Host-Whitelist + * - UA-Rotation, realistische Headers + Client Hints + * - Referer: bevorzugt Einbettungs-Seite (page_url), sonst Origin + * - Cookie-Probe auf page_url (sammelt z. B. __cf_bm) + * - HTTP/2 -> 1.1 Fallback, IPv4 bevorzugt (optional) + * - Byte-Limit im Stream, Retries mit Backoff+Jitter + * - Kein Echo/Output: RETURNTRANSFER überall an * * Rückgabe-Array: * [ - * 'ok' => bool, - * 'tmp_path' => string|null, - * 'mime' => string|null, + * 'ok' => bool, + * 'tmp_path' => string|null, + * 'mime' => string|null, * 'http_code' => int|null, - * 'curl_err' => string|null, + * 'curl_err' => string|null, * 'final_url' => string|null, - * 'bytes' => int, + * 'bytes' => int, + * 'error' => string|null, * ] */ @@ -31,16 +29,13 @@ namespace WList\Net; final class ImageFetch { - /** Default User-Agents (rotieren pro Versuch) */ 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 (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 (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 function retryCurlCodes(): array @@ -52,45 +47,48 @@ final class ImageFetch \defined('CURLE_RECV_ERROR') ? \constant('CURLE_RECV_ERROR') : null, \defined('CURLE_SEND_ERROR') ? \constant('CURLE_SEND_ERROR') : 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') ? \constant('CURLE_HTTP2') : null, // manche Builds haben nur den + \defined('CURLE_HTTP2_STREAM') ? \constant('CURLE_HTTP2_STREAM') : null, + \defined('CURLE_HTTP2') ? \constant('CURLE_HTTP2') : null, ]; - // Nulls rausfiltern return array_values(array_filter($list, static fn($v) => $v !== null)); } - - /** Öffentliche API */ public static function download(string $url, array $opt = []): array { $defaults = [ - 'max_bytes' => 8_000_000, // 8 MiB - 'timeout' => 12, // Sek. - 'connect_timeout' => 5, // Sek. + 'page_url' => null, // Einbettungs-Seite (Angebotslink) – universell + 'force_client_hints' => true, // sec-ch-ua etc. + 'max_bytes' => 8_000_000, + 'timeout' => 12, + 'connect_timeout' => 5, 'max_redirects' => 5, 'retries' => 3, - 'retry_backoff_ms' => 250, // Basis-Backoff - 'whitelist_hosts' => null, // ['ikea.com','images.ikea.com'] oder null + 'retry_backoff_ms' => 250, + 'whitelist_hosts' => null, // ['ikea.com', ...] oder null 'ip_resolve_v4' => true, - 'referer' => 'auto', // 'auto' | 'none' | 'custom' + 'referer' => 'auto', // 'auto' | 'none' | 'custom' 'custom_referer' => null, - 'user_agents' => null, // override UA-Liste - 'log_prefix' => 'imgfetch', // für error_log + 'user_agents' => null, + '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); - // 1) URL validieren + Host prüfen - if (!self::isValidHttpUrl($url)) { + // URL + Host + if (!self::isValidHttpUrl($url)) return self::fail(null, null, 0, 'Ungültige URL'); - } $p = parse_url($url); $host = strtolower($p['host'] ?? ''); - if (!$host) { + if (!$host) return self::fail(null, null, 0, 'Ungültige URL (Host)'); - } - // Host-Whitelist (optional) - if (is_array($cfg['whitelist_hosts']) && count($cfg['whitelist_hosts']) > 0) { + // Whitelist + if (is_array($cfg['whitelist_hosts']) && $cfg['whitelist_hosts']) { $ok = false; foreach ($cfg['whitelist_hosts'] as $allowed) { $allowed = strtolower($allowed); @@ -99,29 +97,21 @@ final class ImageFetch break; } } - if (!$ok) { + if (!$ok) return self::fail(null, null, 0, 'Host nicht erlaubt'); - } } // DNS → keine privaten IPs - if (!self::hostResolvesPublic($host)) { + if (!self::hostResolvesPublic($host)) return self::fail(null, null, 0, 'Host nicht öffentlich erreichbar'); - } - // 2) Tmpfile anlegen - $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; + $uaList = (is_array($cfg['user_agents']) && $cfg['user_agents']) ? $cfg['user_agents'] : self::$UA_LIST; $originRef = self::originFromUrl($url); - $referer = match ($cfg['referer']) { + + $defaultReferer = match ($cfg['referer']) { 'none' => null, 'custom' => (string) $cfg['custom_referer'], - default => $originRef, // auto + default => $originRef, }; $headers = [ @@ -129,36 +119,169 @@ final class ImageFetch 'Accept-Language: de-DE,de;q=0.9,en;q=0.8', 'Cache-Control: no-cache', 'Pragma: no-cache', - // Friendly fetch hints (einige CDNs schauen da drauf) 'Sec-Fetch-Dest: image', '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']); - $received = 0; $lastHttp = null; $lastCurlErr = null; $finalUrl = 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++) { $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'); 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); if ($ch === false) { fclose($fh); - return self::fail($tmp, null, 0, 'Download Fehler (init)'); + @unlink($tmp); + return self::fail(null, null, 0, 'Download Fehler (init)'); } $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 = [ CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => (int) $cfg['max_redirects'], @@ -168,29 +291,37 @@ final class ImageFetch CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => $headers, - CURLOPT_HEADER => false, - CURLOPT_RETURNTRANSFER => false, // stream direkt ins Filehandle - CURLOPT_FILE => $fh, // fallback, falls WRITEFUNCTION nicht greift - CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$received, $cfg, $fh) { - $len = strlen($data); - $received += $len; - if ($received > (int) $cfg['max_bytes']) { - return 0; // -> CURLE_WRITE_ERROR - } - return fwrite($fh, $data); - }, - CURLOPT_ACCEPT_ENCODING => '', // gzip/br zulassen + CURLOPT_HEADERFUNCTION => $headerFn, + CURLOPT_WRITEFUNCTION => $writeFn, + CURLOPT_RETURNTRANSFER => true, // **wichtig**: niemals direkt ausgeben + CURLOPT_FILE => $fh, + CURLOPT_ACCEPT_ENCODING => '', ]; - 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'); } - 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); - $exec = curl_exec($ch); + // Wichtig: exec nie echoen lassen + @curl_exec($ch); + $http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $ctype = (string) (curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: ''); $finalUrl = (string) (curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url); @@ -202,71 +333,111 @@ final class ImageFetch $lastHttp = $http; $lastCurlErr = $cerrStr; - // Abbruch durch Größenlimit → als 413 semantisch behandeln (Payload Too Large) - if ($exec === false && $cerr === CURLE_WRITE_ERROR && $received > (int) $cfg['max_bytes']) { + // Debug + 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); error_log("{$cfg['log_prefix']} size limit hit after {$received} bytes url=$url"); return self::fail(null, null, 413, 'Bild zu groß'); } - // Erfolgspfad: 2xx + image/* - if ($exec !== false && $http >= 200 && $http < 300 && stripos($ctype, 'image/') === 0) { - $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; + // Erfolg + if ($http >= 200 && $http < 300) { + if (stripos($ctype, 'image/') === 0) { + $mime = $ctype; + } else { + $probe = @getimagesize($tmp); + if ($probe !== false) + $mime = $probe['mime'] ?? 'image/*'; + } + if ($mime) { + return [ + 'ok' => true, + 'tmp_path' => $tmp, + 'mime' => $mime, + 'http_code' => $http, + 'curl_err' => null, + 'final_url' => $finalUrl, + 'bytes' => $received, + 'error' => null + ]; } - // 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 { - // Fehler oder Non-2xx → ggf. retry @unlink($tmp); - $doRetry = ($i + 1) < $attempts && - (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"); - } + // Cookies aus Antwort sammeln + $collectCookie($respHeaders); + + // 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; - // 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'); + continue; } } - if (!$ok) { - return self::fail(null, $lastCurlErr, $lastHttp ?? 0, 'Bild-Download fehlgeschlagen'); + // 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 [ - 'ok' => true, - 'tmp_path' => $tmp, - 'mime' => $mime, - 'http_code' => $lastHttp ?? 200, - 'curl_err' => null, - 'final_url' => $finalUrl, - 'bytes' => $received, - ]; + return self::fail(null, $lastCurlErr, $lastHttp ?? 0, 'Bild-Download fehlgeschlagen'); } - /** Hilfsfunktionen */ - public static function safeFileNameFromUrl(string $url): string { $stripped = strtok($url, '?#'); @@ -294,8 +465,8 @@ final class ImageFetch return ''; $port = ''; if (!empty($p['port'])) { - $default = ($p['scheme'] === 'https') ? 443 : 80; - if ((int) $p['port'] !== $default) + $def = $p['scheme'] === 'https' ? 443 : 80; + if ((int) $p['port'] !== $def) $port = ':' . $p['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']; } elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { $cidrs = ['::1/128', 'fc00::/7', 'fe80::/10']; - } else { + } else return true; - } foreach ($cidrs as $c) if (self::ipInCidr($ip, $c)) return true; diff --git a/index.php b/index.php index 05e7741..eaed75c 100644 --- a/index.php +++ b/index.php @@ -369,9 +369,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {

Made with - Shakaru + Shakaru and - Paw + Paw by Shakaru @@ -418,7 +418,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {

-
+