Compare commits

...

3 Commits

Author SHA1 Message Date
8daa183885 Support Image-Upload and Paste-from-Clipboard 2025-09-15 09:48:26 +02:00
216110992f prettier-run for code-formatting 2025-09-15 08:25:49 +02:00
c7ed7ef903 support older cURL 2025-09-15 08:25:25 +02:00
6 changed files with 1119 additions and 441 deletions

View File

@@ -248,3 +248,35 @@ body {
flex-basis: 38px;
}
}
/* ============ Add/Edit-Modal ============ */
/* Bild-Preview im Add/Edit-Modal begrenzen */
#ImgPreviewWrap {
max-height: 240px; /* wie hoch darfs 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;
}

View File

@@ -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,327 +29,522 @@ 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 array $RETRY_CURL = [
CURLE_OPERATION_TIMEDOUT,
CURLE_COULDNT_RESOLVE_HOST,
CURLE_COULDNT_CONNECT,
CURLE_RECV_ERROR,
CURLE_SEND_ERROR,
CURLE_GOT_NOTHING,
CURLE_HTTP2_STREAM, // HTTP/2 stream error/RESET
];
/** Ö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.
'max_redirects' => 5,
'retries' => 3,
'retry_backoff_ms' => 250, // Basis-Backoff
'whitelist_hosts' => null, // ['ikea.com','images.ikea.com'] oder null
'ip_resolve_v4' => true,
'referer' => 'auto', // 'auto' | 'none' | 'custom'
'custom_referer' => null,
'user_agents' => null, // override UA-Liste
'log_prefix' => 'imgfetch', // für error_log
private static array $UA_LIST = [
'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',
];
$cfg = array_replace($defaults, $opt);
// 1) URL validieren + Host prüfen
if (!self::isValidHttpUrl($url)) {
return self::fail(null, null, 0, 'Ungültige URL');
}
$p = parse_url($url);
$host = strtolower($p['host'] ?? '');
if (!$host) {
return self::fail(null, null, 0, 'Ungültige URL (Host)');
private static array $RETRY_HTTP = [429, 500, 502, 503, 504, 520, 521, 522, 523, 524];
private static function retryCurlCodes(): array
{
$list = [
\defined('CURLE_OPERATION_TIMEDOUT') ? \constant('CURLE_OPERATION_TIMEDOUT') : null,
\defined('CURLE_COULDNT_RESOLVE_HOST') ? \constant('CURLE_COULDNT_RESOLVE_HOST') : null,
\defined('CURLE_COULDNT_CONNECT') ? \constant('CURLE_COULDNT_CONNECT') : null,
\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,
\defined('CURLE_HTTP2') ? \constant('CURLE_HTTP2') : null,
];
return array_values(array_filter($list, static fn($v) => $v !== null));
}
// Host-Whitelist (optional)
if (is_array($cfg['whitelist_hosts']) && count($cfg['whitelist_hosts']) > 0) {
$ok = false;
foreach ($cfg['whitelist_hosts'] as $allowed) {
$allowed = strtolower($allowed);
if ($host === $allowed || str_ends_with($host, '.'.$allowed)) {
$ok = true; break;
public static function download(string $url, array $opt = []): array
{
$defaults = [
'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,
'whitelist_hosts' => null, // ['ikea.com', ...] oder null
'ip_resolve_v4' => true,
'referer' => 'auto', // 'auto' | 'none' | 'custom'
'custom_referer' => null,
'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);
// 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)
return self::fail(null, null, 0, 'Ungültige URL (Host)');
// Whitelist
if (is_array($cfg['whitelist_hosts']) && $cfg['whitelist_hosts']) {
$ok = false;
foreach ($cfg['whitelist_hosts'] as $allowed) {
$allowed = strtolower($allowed);
if ($host === $allowed || str_ends_with($host, '.' . $allowed)) {
$ok = true;
break;
}
}
if (!$ok)
return self::fail(null, null, 0, 'Host nicht erlaubt');
}
}
if (!$ok) {
return self::fail(null, null, 0, 'Host nicht erlaubt');
}
}
// DNS → keine privaten IPs
if (!self::hostResolvesPublic($host)) {
return self::fail(null, null, 0, 'Host nicht öffentlich erreichbar');
}
// DNS → keine privaten IPs
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');
}
$uaList = (is_array($cfg['user_agents']) && $cfg['user_agents']) ? $cfg['user_agents'] : self::$UA_LIST;
$originRef = self::originFromUrl($url);
// 3) Vorbereitung: Header + Referer + UAs
$uaList = is_array($cfg['user_agents']) && $cfg['user_agents'] ? $cfg['user_agents'] : self::$UA_LIST;
$originRef = self::originFromUrl($url);
$referer = match ($cfg['referer']) {
'none' => null,
'custom'=> (string)$cfg['custom_referer'],
default => $originRef, // auto
};
$defaultReferer = match ($cfg['referer']) {
'none' => null,
'custom' => (string) $cfg['custom_referer'],
default => $originRef,
};
$headers = [
'Accept: image/avif,image/webp,image/*;q=0.8,*/*;q=0.5',
'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',
];
// 4) Retries
$attempts = max(1, (int)$cfg['retries']);
$received = 0;
$lastHttp = null;
$lastCurlErr = null;
$finalUrl = null;
$mime = null;
$ok = false;
for ($i = 0; $i < $attempts; $i++) {
$ua = $uaList[$i % count($uaList)];
$fh = fopen($tmp, 'wb');
if ($fh === false) {
return self::fail($tmp, null, 0, 'Temp-Datei Fehler');
}
$ch = curl_init($url);
if ($ch === false) {
fclose($fh);
return self::fail($tmp, null, 0, 'Download Fehler (init)');
}
$received = 0;
$opts = [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => (int)$cfg['max_redirects'],
CURLOPT_CONNECTTIMEOUT => (int)$cfg['connect_timeout'],
CURLOPT_TIMEOUT => (int)$cfg['timeout'],
CURLOPT_USERAGENT => $ua,
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
];
if ($cfg['ip_resolve_v4']) {
$opts[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4;
}
if ($referer) {
$opts[CURLOPT_REFERER] = $referer;
}
curl_setopt_array($ch, $opts);
$exec = 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);
$cerr = curl_errno($ch);
$cerrStr = curl_error($ch);
curl_close($ch);
fclose($fh);
$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']) {
@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;
$headers = [
'Accept: image/avif,image/webp,image/*;q=0.8,*/*;q=0.5',
'Accept-Language: de-DE,de;q=0.9,en;q=0.8',
'Cache-Control: no-cache',
'Pragma: no-cache',
'Sec-Fetch-Dest: image',
'Sec-Fetch-Mode: no-cors',
'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"';
}
// 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::$RETRY_CURL, true) || $http === 0);
error_log("{$cfg['log_prefix']} fail http=$http curl={$cerr}:{$cerrStr} ua#{$i} retry=".($doRetry?'1':'0')." url=$url");
}
// 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);
}
// 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);
};
// 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');
}
// 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']);
$lastHttp = null;
$lastCurlErr = null;
$finalUrl = null;
$mime = null;
$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) {
@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);
@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'],
CURLOPT_CONNECTTIMEOUT => (int) $cfg['connect_timeout'],
CURLOPT_TIMEOUT => (int) $cfg['timeout'],
CURLOPT_USERAGENT => $ua,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_HEADERFUNCTION => $headerFn,
CURLOPT_WRITEFUNCTION => $writeFn,
CURLOPT_RETURNTRANSFER => true, // **wichtig**: niemals direkt ausgeben
CURLOPT_FILE => $fh,
CURLOPT_ACCEPT_ENCODING => '',
];
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');
}
// 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);
// 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);
$cerr = curl_errno($ch);
$cerrStr = curl_error($ch);
curl_close($ch);
fclose($fh);
$lastHttp = $http;
$lastCurlErr = $cerrStr;
// 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ß');
}
// 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
];
}
@unlink($tmp);
} else {
@unlink($tmp);
// 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;
$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');
}
if (!$ok) {
return self::fail(null, $lastCurlErr, $lastHttp ?? 0, 'Bild-Download fehlgeschlagen');
public static function safeFileNameFromUrl(string $url): string
{
$stripped = strtok($url, '?#');
$ext = strtolower(pathinfo((string) $stripped, PATHINFO_EXTENSION));
if (!preg_match('/^[a-z0-9]{1,5}$/i', $ext))
$ext = 'jpg';
return bin2hex(random_bytes(10)) . '.' . $ext;
}
return [
'ok' => true,
'tmp_path' => $tmp,
'mime' => $mime,
'http_code' => $lastHttp ?? 200,
'curl_err' => null,
'final_url' => $finalUrl,
'bytes' => $received,
];
}
/** Hilfsfunktionen */
public static function safeFileNameFromUrl(string $url): string
{
$stripped = strtok($url, '?#');
$ext = strtolower(pathinfo((string)$stripped, PATHINFO_EXTENSION));
if (!preg_match('/^[a-z0-9]{1,5}$/i', $ext)) $ext = 'jpg';
return bin2hex(random_bytes(10)).'.'.$ext;
}
private static function isValidHttpUrl(string $url): bool
{
if (!filter_var($url, FILTER_VALIDATE_URL)) return false;
$p = parse_url($url);
if (!$p || empty($p['scheme']) || empty($p['host'])) return false;
$s = strtolower($p['scheme']);
return $s === 'http' || $s === 'https';
}
private static function originFromUrl(string $url): string
{
$p = parse_url($url);
if (!$p || empty($p['scheme']) || empty($p['host'])) return '';
$port = '';
if (!empty($p['port'])) {
$default = ($p['scheme'] === 'https') ? 443 : 80;
if ((int)$p['port'] !== $default) $port = ':'.$p['port'];
private static function isValidHttpUrl(string $url): bool
{
if (!filter_var($url, FILTER_VALIDATE_URL))
return false;
$p = parse_url($url);
if (!$p || empty($p['scheme']) || empty($p['host']))
return false;
$s = strtolower($p['scheme']);
return $s === 'http' || $s === 'https';
}
return $p['scheme'].'://'.$p['host'].$port.'/';
}
private static function hostResolvesPublic(string $host): bool
{
$recs = @dns_get_record($host, DNS_A + DNS_AAAA);
if (!$recs || !count($recs)) return false;
foreach ($recs as $r) {
$ip = $r['type'] === 'A' ? ($r['ip'] ?? null) : ($r['ipv6'] ?? null);
if (!$ip) continue;
if (self::isPrivateIp($ip)) return false;
private static function originFromUrl(string $url): string
{
$p = parse_url($url);
if (!$p || empty($p['scheme']) || empty($p['host']))
return '';
$port = '';
if (!empty($p['port'])) {
$def = $p['scheme'] === 'https' ? 443 : 80;
if ((int) $p['port'] !== $def)
$port = ':' . $p['port'];
}
return $p['scheme'] . '://' . $p['host'] . $port . '/';
}
return true;
}
private static function isPrivateIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$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 {
return true;
private static function hostResolvesPublic(string $host): bool
{
$recs = @dns_get_record($host, DNS_A + DNS_AAAA);
if (!$recs || !count($recs))
return false;
foreach ($recs as $r) {
$ip = $r['type'] === 'A' ? ($r['ip'] ?? null) : ($r['ipv6'] ?? null);
if (!$ip)
continue;
if (self::isPrivateIp($ip))
return false;
}
return true;
}
foreach ($cidrs as $c) if (self::ipInCidr($ip, $c)) return true;
return false;
}
private static function ipInCidr(string $ip, string $cidr): bool
{
if (strpos($cidr, ':') !== false) {
[$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null);
$mask = (int)$mask;
$binIp = inet_pton($ip);
$binSubnet = inet_pton($subnet);
if ($binIp === false || $binSubnet === false) return false;
$bytes = intdiv($mask, 8);
$bits = $mask % 8;
if ($bytes && substr($binIp, 0, $bytes) !== substr($binSubnet, 0, $bytes)) return false;
if ($bits) {
$b1 = ord($binIp[$bytes]) & (0xFF << (8 - $bits));
$b2 = ord($binSubnet[$bytes]) & (0xFF << (8 - $bits));
return $b1 === $b2;
}
return true;
} else {
[$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null);
$mask = (int)$mask;
$ipL = ip2long($ip);
$subL = ip2long($subnet);
if ($ipL === false || $subL === false) return false;
$maskL = -1 << (32 - $mask);
return (($ipL & $maskL) === ($subL & $maskL));
private static function isPrivateIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$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
return true;
foreach ($cidrs as $c)
if (self::ipInCidr($ip, $c))
return true;
return false;
}
}
private static function fail(?string $tmp, ?string $cerr, int $http, string $msg): array
{
if ($tmp && is_file($tmp)) @unlink($tmp);
return [
'ok' => false,
'tmp_path' => null,
'mime' => null,
'http_code' => $http > 0 ? $http : null,
'curl_err' => $cerr,
'final_url' => null,
'bytes' => 0,
'error' => $msg,
];
}
private static function ipInCidr(string $ip, string $cidr): bool
{
if (strpos($cidr, ':') !== false) {
[$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null);
$mask = (int) $mask;
$binIp = inet_pton($ip);
$binSubnet = inet_pton($subnet);
if ($binIp === false || $binSubnet === false)
return false;
$bytes = intdiv($mask, 8);
$bits = $mask % 8;
if ($bytes && substr($binIp, 0, $bytes) !== substr($binSubnet, 0, $bytes))
return false;
if ($bits) {
$b1 = ord($binIp[$bytes]) & (0xFF << (8 - $bits));
$b2 = ord($binSubnet[$bytes]) & (0xFF << (8 - $bits));
return $b1 === $b2;
}
return true;
} else {
[$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null);
$mask = (int) $mask;
$ipL = ip2long($ip);
$subL = ip2long($subnet);
if ($ipL === false || $subL === false)
return false;
$maskL = -1 << (32 - $mask);
return (($ipL & $maskL) === ($subL & $maskL));
}
}
private static function fail(?string $tmp, ?string $cerr, int $http, string $msg): array
{
if ($tmp && is_file($tmp))
@unlink($tmp);
return [
'ok' => false,
'tmp_path' => null,
'mime' => null,
'http_code' => $http > 0 ? $http : null,
'curl_err' => $cerr,
'final_url' => null,
'bytes' => 0,
'error' => $msg,
];
}
}

View File

@@ -369,9 +369,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
<hr class="mb-3 opacity-25">
<p class="mb-1">
Made with
<img src="img/heart-icon.png" alt="Shakaru" class="icon-sm mx-1">
<img src="img/heart-icon.png" alt="Shakaru" class="icon-sm mx-1">
and
<img src="img/paw-icon.png" alt="Paw" class="icon-sm mx-1">
<img src="img/paw-icon.png" alt="Paw" class="icon-sm mx-1">
by
<strong>
<img src="img/shakaru-icon.png" alt="Shakaru" class="icon-sm mx-1">
@@ -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"
data-bs-dismiss="modal"></button>
</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">
<label class="form-label">Titel</label>
<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>
<input class="form-control" name="ItemLink" id="ItemLink" type="url" pattern="https?://.+">
<label class="form-label mt-2">Bild (URL)</label>
<input class="form-control" name="ItemImage" id="ItemImage" type="url" pattern="https?://.+">
<hr class="my-3">
<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">
<label class="form-check-label" for="RemoveImage">Aktuelles Bild entfernen</label>
</div>
</div>
<div class="modal-footer">
<input type="hidden" name="action" id="ItemAction" value="add">

213
item.php
View File

@@ -2,7 +2,6 @@
declare(strict_types=1);
require_once __DIR__ . '/include/image_fetch.php';
use WList\Net\ImageFetch;
/* ========= Session & Bootstrap ========= */
@@ -93,6 +92,116 @@ function is_valid_http_url(string $url): bool
return $s === 'http' || $s === 'https';
}
/* ===== Bild-Speicher-Helper ===== */
function ensure_imagedir(): string
{
global $imagedir;
if (!is_dir($imagedir))
@mkdir($imagedir, 0755, true);
return rtrim($imagedir, '/');
}
function save_image_from_tmp(string $tmp, ?string $sourceNameOrUrl = null): string
{
$info = @getimagesize($tmp);
if ($info === false || empty($info['mime']) || stripos($info['mime'], 'image/') !== 0) {
@unlink($tmp);
fail('Ungültige Bilddatei', 400);
}
$dir = ensure_imagedir();
// Dateiname: wenn URL vorhanden → davon die Ext, sonst aus MIME
if ($sourceNameOrUrl && is_valid_http_url($sourceNameOrUrl)) {
$name = ImageFetch::safeFileNameFromUrl($sourceNameOrUrl);
} else {
$ext = 'jpg';
$mime = strtolower($info['mime']);
if (str_contains($mime, 'png'))
$ext = 'png';
elseif (str_contains($mime, 'webp'))
$ext = 'webp';
elseif (str_contains($mime, 'gif'))
$ext = 'gif';
elseif (str_contains($mime, 'avif'))
$ext = 'avif';
elseif (str_contains($mime, 'jpeg'))
$ext = 'jpg';
$name = bin2hex(random_bytes(10)) . '.' . $ext;
}
$target = $dir . '/' . $name;
if (!@rename($tmp, $target)) {
if (!@copy($tmp, $target)) {
@unlink($tmp);
fail('Bildspeicherung fehlgeschlagen', 500);
}
@unlink($tmp);
}
@chmod($target, 0644);
return $name;
}
function save_image_from_upload(array $file): string
{
// Erwartet $_FILES['ItemImageFile']
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
fail('Datei-Upload fehlgeschlagen', 400);
}
if (!empty($file['error'])) {
fail('Datei-Upload Fehler (Code ' . (int) $file['error'] . ')', 400);
}
$size = (int) ($file['size'] ?? 0);
if ($size <= 0 || $size > 8 * 1024 * 1024) {
fail('Datei ist zu groß (max. 8 MB)', 400);
}
$tmp = $file['tmp_name'];
// doppelt absichern: in separärer Temp-Datei lesen/schreiben (optional)
$probe = @getimagesize($tmp);
if ($probe === false || empty($probe['mime']) || stripos($probe['mime'], 'image/') !== 0) {
fail('Hochgeladene Datei ist kein gültiges Bild', 400);
}
// in einen eigenen tmp kopieren, um unify mit save_image_from_tmp zu haben
$tmp2 = tempnam(sys_get_temp_dir(), 'wlimg_');
if ($tmp2 === false)
fail('Temp-Datei Fehler', 500);
if (!@copy($tmp, $tmp2)) {
@unlink($tmp2);
fail('Temp-Datei Fehler', 500);
}
$origName = (string) ($file['name'] ?? '');
// Falls Originalname eine Extension hat, nutzen wir sie indirekt über getimagesize() (oben)
return save_image_from_tmp($tmp2, $origName !== '' ? $origName : null);
}
function save_image_from_dataurl(string $dataUrl, string $suggestedName = 'clipboard.png'): string
{
// data:image/png;base64,....
if (!preg_match('#^data:(image/[\w\-\+\.]+);base64,(.+)$#i', $dataUrl, $m)) {
fail('Zwischenablage-Daten ungültig', 400);
}
$mime = strtolower($m[1]);
$b64 = $m[2];
// Größenlimit grob prüfen (Base64 ist ~33% größer)
$rawLen = (int) (strlen($b64) * 3 / 4);
if ($rawLen > 8 * 1024 * 1024) {
fail('Zwischenablage-Bild ist zu groß (max. 8 MB)', 400);
}
$bin = base64_decode($b64, true);
if ($bin === false || strlen($bin) === 0) {
fail('Zwischenablage-Daten ungültig (Decode)', 400);
}
$tmp = tempnam(sys_get_temp_dir(), 'wlimg_');
if ($tmp === false)
fail('Temp-Datei Fehler', 500);
if (file_put_contents($tmp, $bin) === false) {
@unlink($tmp);
fail('Temp-Datei Fehler', 500);
}
// suggestedName nur als Hint finale Ext über getimagesize/MIME
return save_image_from_tmp($tmp, $suggestedName);
}
/* ============= Controller ============= */
require_csrf();
@@ -104,7 +213,12 @@ $ItemDescription = trim((string) ($_POST['ItemDescription'] ?? ''));
$ItemPrice = parse_price_to_cents((string) ($_POST['ItemPrice'] ?? ''));
$ItemQty = isset($_POST['ItemQty']) ? max(1, (int) $_POST['ItemQty']) : 1;
$ItemLink = trim((string) ($_POST['ItemLink'] ?? ''));
$ItemImageUrl = trim((string) ($_POST['ItemImage'] ?? '')); // optional URL zum Pull
// NEU: drei mögliche Bild-Inputs
$ItemImageUrl = trim((string) ($_POST['ItemImageUrl'] ?? ''));
$ItemImagePaste = (string) ($_POST['ItemImagePaste'] ?? '');
$ItemImagePasteName = trim((string) ($_POST['ItemImagePasteName'] ?? 'clipboard.png'));
$ListUUID = trim((string) ($_POST['ItemListUUID'] ?? ''));
$sortbyTransfer = (string) ($_POST['sortby_transfer'] ?? 'priority');
@@ -141,54 +255,52 @@ if ($ItemTitle === '')
if ($ItemLink !== '' && !is_valid_http_url($ItemLink))
fail('Ungültiger Angebotslink', 400);
/* Optional: Bild von externer URL holen */
/* ===== Bild verarbeiten: Upload → Paste → URL ===== */
$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']);
$fetch = ImageFetch::download($ItemImageUrl, [
'max_bytes' => 8_000_000,
'timeout' => 12,
'connect_timeout' => 5,
'retries' => 4,
'retry_backoff_ms' => 300,
'whitelist_hosts' => $whitelist,
'ip_resolve_v4' => true,
'referer' => 'auto',
'log_prefix' => 'wishlist-img',
]);
} elseif ($hasPaste) {
// 2) Zwischenablage (Data-URL)
$imageLocalLink = save_image_from_dataurl($ItemImagePaste, $ItemImagePasteName);
if (!$fetch['ok']) {
error_log("wishlist image error: http=" . ($fetch['http_code'] ?? 0) . " curl=" . ($fetch['curl_err'] ?? '-') . " url=$ItemImageUrl");
$conn->close();
fail('Bild-Download fehlgeschlagen', 400);
}
$info = @getimagesize($fetch['tmp_path']);
$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']);
} elseif ($hasUrl) {
// 3) Bild-URL (Download-Pflicht, sonst Fehler)
if (!is_valid_http_url($ItemImageUrl)) {
$conn->close();
fail('Bildspeicherung fehlgeschlagen', 500);
fail('Ungültiger Bildlink', 400);
}
@unlink($fetch['tmp_path']);
$fetch = ImageFetch::download($ItemImageUrl, [
'page_url' => $ItemLink !== '' && is_valid_http_url($ItemLink) ? $ItemLink : null,
'max_bytes' => 8_000_000,
'timeout' => 12,
'connect_timeout' => 5,
'retries' => 5,
'retry_backoff_ms' => 300,
'whitelist_hosts' => $image_host_whitelist ?? null,
'referer' => 'auto', // Falls keine page_url, nimmt er Bild-Origin
'log_prefix' => 'wishlist-img',
'debug' => true,
'try_http_versions' => ['2', '1.1'],
'try_ip_resolve_combo' => ['v4', 'auto'],
'force_client_hints' => true,
]);
if (!$fetch['ok']) {
error_log("wishlist image error: http=" . ($fetch['http_code'] ?? 0) . " curl=" . ($fetch['curl_err'] ?? '-') . " url=$ItemImageUrl");
$conn->close();
fail('Bild-Download fehlgeschlagen. Bitte Bild manuell speichern/hochladen oder per Zwischenablage einfügen und erneut versuchen.', 400);
}
$imageLocalLink = save_image_from_tmp($fetch['tmp_path'], $ItemImageUrl);
}
@chmod($target, 0644);
$imageLocalLink = $filename;
}
/* ====== ADD ====== */
@@ -227,8 +339,9 @@ if ($action === 'add') {
fail('Speichern fehlgeschlagen', 500);
}
$stmt->close();
}
/* ====== EDIT ====== */ elseif ($action === 'edit') {
/* ====== EDIT ====== */
} elseif ($action === 'edit') {
if ($WhishID <= 0) {
$conn->close();
fail('Ungültige Item-ID', 400);
@@ -251,16 +364,14 @@ if ($action === 'add') {
$newImage = $oldImage;
if ($removeImage) {
if (!empty($oldImage)) {
global $imagedir;
$full = rtrim($imagedir, '/') . '/' . $oldImage;
$full = ensure_imagedir() . '/' . $oldImage;
if (is_file($full))
@unlink($full);
}
$newImage = '';
} elseif ($imageLocalLink !== null) {
if (!empty($oldImage)) {
global $imagedir;
$full = rtrim($imagedir, '/') . '/' . $oldImage;
$full = ensure_imagedir() . '/' . $oldImage;
if (is_file($full))
@unlink($full);
}
@@ -291,8 +402,8 @@ if ($action === 'add') {
fail('Update fehlgeschlagen', 500);
}
$stmt->close();
}
/* ====== Unbekannt ====== */ else {
} else {
$conn->close();
fail('Unbekannte Aktion', 400);
}

View File

@@ -2,16 +2,24 @@
(function () {
"use strict";
// Sort direkt submitten
const sortSel = document.getElementById("sortby");
// ===== Helper =====
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) {
sortSel.addEventListener("change", function () {
this.form.submit();
});
}
// Reservation Modal
const reservationModal = document.getElementById("reservationModal");
// ===== Reservation Modal =====
const reservationModal = $("#reservationModal");
if (reservationModal) {
reservationModal.addEventListener("show.bs.modal", function (ev) {
const btn = ev.relatedTarget;
@@ -19,13 +27,12 @@
const wishid = btn.getAttribute("data-wishid");
const reserved = btn.getAttribute("data-reserved");
reservationModal.querySelector("#modal-wishid").value = wishid || "";
reservationModal.querySelector("#modal-reservedstat").value =
reserved || "";
$("#modal-wishid", reservationModal).value = wishid || "";
$("#modal-reservedstat", reservationModal).value = reserved || "";
const submitBtn = reservationModal.querySelector("#reservation-submit");
const titleEl = reservationModal.querySelector("#reservationModalLabel");
const infoEl = reservationModal.querySelector("#ReservationInfoText");
const submitBtn = $("#reservation-submit", reservationModal);
const titleEl = $("#reservationModalLabel", reservationModal);
const infoEl = $("#ReservationInfoText", reservationModal);
if (reserved === "1") {
submitBtn.textContent = "Reservierung aufheben";
@@ -39,8 +46,8 @@
});
}
// Delete Modal
const deleteModal = document.getElementById("deleteModal");
// ===== Delete Modal =====
const deleteModal = $("#deleteModal");
if (deleteModal) {
deleteModal.addEventListener("show.bs.modal", function (ev) {
const btn = ev.relatedTarget;
@@ -51,7 +58,6 @@
: "";
const wishid = btn.getAttribute("data-wishid") || "";
// robust: suche entweder #DelWhishID ODER #WhishID
const idInput = deleteModal.querySelector(
'#DelWhishID, #WhishID, input[name="WhishID"]'
);
@@ -64,8 +70,8 @@
});
}
// Push Prio Modal
const prioModal = document.getElementById("pushprioModal");
// ===== Push Prio Modal =====
const prioModal = $("#pushprioModal");
if (prioModal) {
prioModal.addEventListener("show.bs.modal", function (ev) {
const btn = ev.relatedTarget;
@@ -88,28 +94,118 @@
});
}
// Add/Edit Item Modal
const itemModal = document.getElementById("itemModal");
// ===== Add/Edit Item Modal =====
const itemModal = $("#itemModal");
let _currentPreviewObjectUrl = null;
function revokePreviewUrl() {
if (_currentPreviewObjectUrl) {
try {
URL.revokeObjectURL(_currentPreviewObjectUrl);
} catch (e) {}
_currentPreviewObjectUrl = null;
}
}
if (itemModal) {
itemModal.addEventListener("show.bs.modal", function (ev) {
const btn = ev.relatedTarget;
if (!btn) return;
const mode = btn.getAttribute("data-mode") || "add";
const titleEl = itemModal.querySelector("#itemModalTitle");
const actionEl = itemModal.querySelector("#ItemAction");
const submitEl = itemModal.querySelector("#ItemSubmitBtn");
const removeWrap = itemModal.querySelector("#RemoveImageWrap");
const removeChk = itemModal.querySelector("#RemoveImage");
const titleEl = $("#itemModalTitle", itemModal);
const actionEl = $("#ItemAction", itemModal);
const submitEl = $("#ItemSubmitBtn", itemModal);
const removeWrap = $("#RemoveImageWrap", itemModal);
const removeChk = $("#RemoveImage", itemModal);
// Felder
const fTitle = itemModal.querySelector("#ItemTitle");
const fDesc = itemModal.querySelector("#ItemDescription");
const fPrice = itemModal.querySelector("#ItemPrice");
const fLink = itemModal.querySelector("#ItemLink");
const fImg = itemModal.querySelector("#ItemImage");
const fId = itemModal.querySelector("#WhishID");
const fQty = itemModal.querySelector("#ItemQty"); // <-- NEU
const fTitle = $("#ItemTitle", itemModal);
const fDesc = $("#ItemDescription", itemModal);
const fPrice = $("#ItemPrice", itemModal);
const fLink = $("#ItemLink", itemModal);
const fId = $("#WhishID", itemModal);
const fQty = $("#ItemQty", itemModal);
// 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") {
titleEl.textContent = "Wunsch bearbeiten";
@@ -123,15 +219,14 @@
const dDesc = btn.getAttribute("data-description") || "";
const dPrice = btn.getAttribute("data-price") || "";
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 (fTitle) fTitle.value = dTitle;
if (fDesc) fDesc.value = dDesc;
if (fPrice) fPrice.value = dPrice;
if (fLink) fLink.value = dLink;
if (fImg) fImg.value = "";
if (fQty) fQty.value = dQty; // <-- NEU
if (fQty) fQty.value = dQty;
} else {
titleEl.textContent = "Wunsch hinzufügen";
actionEl.value = "add";
@@ -141,11 +236,170 @@
if (fDesc) fDesc.value = "";
if (fPrice) fPrice.value = "";
if (fLink) fLink.value = "";
if (fImg) fImg.value = "";
if (fQty) fQty.value = "1"; // <-- NEU
if (fQty) fQty.value = "1";
if (removeWrap) removeWrap.style.display = "none";
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 }
);
});
}
})();

View File

@@ -4,7 +4,11 @@ declare(strict_types=1);
/* ========= Session & Bootstrap ========= */
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
session_set_cookie_params([
'lifetime'=>0,'path'=>'/','secure'=>$secure,'httponly'=>true,'samesite'=>'Lax',
'lifetime' => 0,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_name('WLISTSESSID');
session_start();
@@ -14,44 +18,62 @@ require_once __DIR__ . '/config/config.php';
/* ===== Debug Toggle (wie item.php) ===== */
if (!empty($app_debug)) {
ini_set('display_errors','1'); ini_set('display_startup_errors','1'); ini_set('log_errors','1');
if (!is_dir(__DIR__.'/logs')) { @mkdir(__DIR__.'/logs',0750,true); }
ini_set('error_log', __DIR__.'/logs/php-error.log'); error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
if (!is_dir(__DIR__ . '/logs')) {
@mkdir(__DIR__ . '/logs', 0750, true);
}
ini_set('error_log', __DIR__ . '/logs/php-error.log');
error_reporting(E_ALL);
} else {
ini_set('display_errors','0'); ini_set('display_startup_errors','0'); ini_set('log_errors','1');
if (!is_dir(__DIR__.'/logs')) { @mkdir(__DIR__.'/logs',0750,true); }
ini_set('error_log', __DIR__.'/logs/php-error.log'); error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
ini_set('log_errors', '1');
if (!is_dir(__DIR__ . '/logs')) {
@mkdir(__DIR__ . '/logs', 0750, true);
}
ini_set('error_log', __DIR__ . '/logs/php-error.log');
error_reporting(E_ALL);
}
/* ============= Helpers ============= */
function fail(string $msg, int $code=400): void {
function fail(string $msg, int $code = 400): void
{
http_response_code($code);
// kleine Flash-Message für index.php
$_SESSION['flash'] = ['msg'=>$msg,'type'=> ($code>=400?'danger':'success')];
$_SESSION['flash'] = ['msg' => $msg, 'type' => ($code >= 400 ? 'danger' : 'success')];
safe_redirect_back();
}
function db(): mysqli {
function db(): mysqli
{
global $servername, $username, $password, $db;
$conn = new mysqli($servername, $username, $password, $db);
if ($conn->connect_error) fail('Interner Fehler (DB)', 500);
if ($conn->connect_error)
fail('Interner Fehler (DB)', 500);
$conn->set_charset('utf8mb4');
return $conn;
}
function require_csrf(): void {
$t = (string)($_POST['csrf'] ?? '');
function require_csrf(): void
{
$t = (string) ($_POST['csrf'] ?? '');
if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $t)) {
fail('Ungültiges CSRF-Token', 403);
}
}
function e(string $s): string { return htmlspecialchars($s, ENT_QUOTES|ENT_SUBSTITUTE, 'UTF-8'); }
function safe_redirect_back(): void {
$ref = (string)($_SERVER['HTTP_REFERER'] ?? '');
function e(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function safe_redirect_back(): void
{
$ref = (string) ($_SERVER['HTTP_REFERER'] ?? '');
if ($ref === '' || stripos($ref, 'http') !== 0) {
$host = $_SERVER['HTTP_HOST'] ?? '';
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
header('Location: '.$scheme.'://'.$host.'/', true, 303);
header('Location: ' . $scheme . '://' . $host . '/', true, 303);
} else {
header('Location: '.$ref, true, 303);
header('Location: ' . $ref, true, 303);
}
exit;
}
@@ -63,12 +85,14 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
}
require_csrf();
$wishId = (int)($_POST['wishid'] ?? -1);
$pw = (string)($_POST['WishPassword'] ?? '');
$reservedstat = (int)($_POST['reservedstat'] ?? 0); // 0 = setzen, 1 = aufheben
$wishId = (int) ($_POST['wishid'] ?? -1);
$pw = (string) ($_POST['WishPassword'] ?? '');
$reservedstat = (int) ($_POST['reservedstat'] ?? 0); // 0 = setzen, 1 = aufheben
if ($wishId <= 0) fail('Ungültige Wunsch-ID', 400);
if ($pw === '') fail('Passwort erforderlich', 400);
if ($wishId <= 0)
fail('Ungültige Wunsch-ID', 400);
if ($pw === '')
fail('Passwort erforderlich', 400);
$conn = db();
@@ -79,18 +103,21 @@ $stmt->bind_param('i', $wishId);
$stmt->execute();
$res = $stmt->get_result();
if (!$res || !($row = $res->fetch_assoc())) {
$stmt->close(); $conn->close(); fail('Wunsch nicht gefunden', 404);
$stmt->close();
$conn->close();
fail('Wunsch nicht gefunden', 404);
}
$qty = max(1, (int)$row['qty']);
$qty = max(1, (int) $row['qty']);
$stmt->close();
/* Existierende Reservierungen zählen */
$cnt = 0;
$stmt = $conn->prepare('SELECT COUNT(*) AS c FROM wishes_reservations WHERE wish_id = ?');
$stmt->bind_param('i',$wishId);
$stmt->bind_param('i', $wishId);
$stmt->execute();
$res = $stmt->get_result();
if ($res && ($row = $res->fetch_assoc())) $cnt = (int)$row['c'];
if ($res && ($row = $res->fetch_assoc()))
$cnt = (int) $row['c'];
$stmt->close();
/* --- Operationen --- */
@@ -104,25 +131,26 @@ if ($reservedstat === 0) {
$ins = $conn->prepare('INSERT INTO wishes_reservations (wish_id, pass_hash, created_at) VALUES (?, ?, NOW())');
$ins->bind_param('is', $wishId, $hash);
if (!$ins->execute()) {
$ins->close(); $conn->close();
$ins->close();
$conn->close();
fail('Reservierung fehlgeschlagen', 500);
}
$ins->close();
$conn->close();
$_SESSION['flash'] = ['msg'=>'Reservierung eingetragen','type'=>'success'];
$_SESSION['flash'] = ['msg' => 'Reservierung eingetragen', 'type' => 'success'];
safe_redirect_back();
} else {
// aufheben: passenden Hash suchen und genau einen Eintrag löschen
$sel = $conn->prepare('SELECT id, pass_hash FROM wishes_reservations WHERE wish_id = ?');
$sel->bind_param('i',$wishId);
$sel->bind_param('i', $wishId);
$sel->execute();
$res = $sel->get_result();
$deleted = false;
while ($row = $res->fetch_assoc()) {
$rid = (int)$row['id'];
$rhash= (string)$row['pass_hash'];
$rid = (int) $row['id'];
$rhash = (string) $row['pass_hash'];
if (password_verify($pw, $rhash)) {
$del = $conn->prepare('DELETE FROM wishes_reservations WHERE id = ? LIMIT 1');
$del->bind_param('i', $rid);
@@ -135,9 +163,9 @@ if ($reservedstat === 0) {
$conn->close();
if ($deleted) {
$_SESSION['flash'] = ['msg'=>'Reservierung aufgehoben','type'=>'success'];
$_SESSION['flash'] = ['msg' => 'Reservierung aufgehoben', 'type' => 'success'];
} else {
$_SESSION['flash'] = ['msg'=>'Kein passender Reservierungseintrag gefunden (Passwort korrekt?)','type'=>'warning'];
$_SESSION['flash'] = ['msg' => 'Kein passender Reservierungseintrag gefunden (Passwort korrekt?)', 'type' => 'warning'];
}
safe_redirect_back();
}