425 lines
12 KiB
PHP
425 lines
12 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/include/image_fetch.php';
|
||
use WList\Net\ImageFetch;
|
||
|
||
/* ========= Session & Bootstrap ========= */
|
||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||
session_set_cookie_params([
|
||
'lifetime' => 0,
|
||
'path' => '/',
|
||
'secure' => $secure,
|
||
'httponly' => true,
|
||
'samesite' => 'Lax',
|
||
]);
|
||
session_name('WLISTSESSID');
|
||
session_start();
|
||
umask(0022); // sorgt für 0644/0755 als Default für neu erstellte Dateien/Ordner
|
||
|
||
require_once __DIR__ . '/config/config.php';
|
||
|
||
// ===== Debug Toggle =====
|
||
if (!empty($app_debug)) {
|
||
ini_set('display_errors', '1');
|
||
ini_set('display_startup_errors', '1');
|
||
ini_set('log_errors', '1');
|
||
if (!is_dir(__DIR__ . '/logs')) {
|
||
@mkdir(__DIR__ . '/logs', 0750, true);
|
||
}
|
||
ini_set('error_log', __DIR__ . '/logs/php-error.log');
|
||
error_reporting(E_ALL);
|
||
} else {
|
||
ini_set('display_errors', '0');
|
||
ini_set('display_startup_errors', '0');
|
||
ini_set('log_errors', '1');
|
||
if (!is_dir(__DIR__ . '/logs')) {
|
||
@mkdir(__DIR__ . '/logs', 0750, true);
|
||
}
|
||
ini_set('error_log', __DIR__ . '/logs/php-error.log');
|
||
error_reporting(E_ALL);
|
||
}
|
||
|
||
/* ============= Helpers ============= */
|
||
function fail(string $msg = 'Unerwarteter Fehler', int $code = 400): void
|
||
{
|
||
http_response_code($code);
|
||
exit($msg);
|
||
}
|
||
function require_csrf(): void
|
||
{
|
||
$t = (string) ($_POST['csrf'] ?? '');
|
||
if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $t))
|
||
fail('Ungültiges CSRF-Token', 403);
|
||
}
|
||
function require_logged_in(): int
|
||
{
|
||
if (!isset($_SESSION['listid']))
|
||
fail('Nicht eingeloggt', 403);
|
||
return (int) $_SESSION['listid'];
|
||
}
|
||
function db(): mysqli
|
||
{
|
||
global $servername, $username, $password, $db;
|
||
$conn = new mysqli($servername, $username, $password, $db);
|
||
if ($conn->connect_error)
|
||
fail('Interner Fehler (DB)', 500);
|
||
$conn->set_charset('utf8mb4');
|
||
return $conn;
|
||
}
|
||
function parse_price_to_cents(string $in): int
|
||
{
|
||
$clean = preg_replace('/[^\d,\.]/', '', $in ?? '');
|
||
$norm = str_replace('.', '', $clean);
|
||
$norm = str_replace(',', '.', $norm);
|
||
if ($norm === '' || !is_numeric($norm))
|
||
return 0;
|
||
$cents = (int) round((float) $norm * 100);
|
||
if ($cents < 0)
|
||
$cents = 0;
|
||
if ($cents > 100000000)
|
||
$cents = 100000000; // 1 Mio €
|
||
return $cents;
|
||
}
|
||
function is_valid_http_url(string $url): bool
|
||
{
|
||
if (!filter_var($url, FILTER_VALIDATE_URL))
|
||
return false;
|
||
$p = parse_url($url);
|
||
if (!$p || empty($p['scheme']) || empty($p['host']))
|
||
return false;
|
||
$s = strtolower($p['scheme']);
|
||
return $s === 'http' || $s === 'https';
|
||
}
|
||
|
||
/* ===== 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();
|
||
$sessionListId = require_logged_in();
|
||
|
||
$action = strtolower(trim((string) ($_POST['action'] ?? '')));
|
||
$ItemTitle = trim((string) ($_POST['ItemTitle'] ?? ''));
|
||
$ItemDescription = trim((string) ($_POST['ItemDescription'] ?? ''));
|
||
$ItemPrice = parse_price_to_cents((string) ($_POST['ItemPrice'] ?? ''));
|
||
$ItemQty = isset($_POST['ItemQty']) ? max(1, (int) $_POST['ItemQty']) : 1;
|
||
$ItemLink = trim((string) ($_POST['ItemLink'] ?? ''));
|
||
|
||
// 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');
|
||
|
||
$removeImage = isset($_POST['RemoveImage']) && ($_POST['RemoveImage'] === '1');
|
||
$WhishID = isset($_POST['WhishID']) ? (int) $_POST['WhishID'] : 0; // bei edit
|
||
|
||
if (!preg_match('/^[0-9a-fA-F-]{32,36}$/', $ListUUID))
|
||
fail('Liste nicht autorisiert', 403);
|
||
|
||
$conn = db();
|
||
|
||
/* UUID -> int ID */
|
||
$stmt = $conn->prepare('SELECT ID FROM lists WHERE uuid = ?');
|
||
$stmt->bind_param('s', $ListUUID);
|
||
$stmt->execute();
|
||
$res = $stmt->get_result();
|
||
if (!$res || !($row = $res->fetch_assoc())) {
|
||
$stmt->close();
|
||
$conn->close();
|
||
fail('Liste nicht autorisiert', 403);
|
||
}
|
||
$ListID = (int) $row['ID'];
|
||
$stmt->close();
|
||
|
||
/* Session muss zu dieser Liste gehören */
|
||
if ($ListID !== $sessionListId) {
|
||
$conn->close();
|
||
fail('Liste nicht autorisiert', 403);
|
||
}
|
||
|
||
/* Validierungen (add & edit) */
|
||
if ($ItemTitle === '')
|
||
fail('Titel fehlt', 400);
|
||
if ($ItemLink !== '' && !is_valid_http_url($ItemLink))
|
||
fail('Ungültiger Angebotslink', 400);
|
||
|
||
/* ===== Bild verarbeiten: Upload → Paste → URL ===== */
|
||
$imageLocalLink = null;
|
||
$hasUpload = isset($_FILES['ItemImageFile']) && is_array($_FILES['ItemImageFile']) && !empty($_FILES['ItemImageFile']['name']);
|
||
$hasPaste = $ItemImagePaste !== '';
|
||
$hasUrl = $ItemImageUrl !== '';
|
||
|
||
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, [
|
||
'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);
|
||
}
|
||
}
|
||
|
||
/* ====== ADD ====== */
|
||
if ($action === 'add') {
|
||
// nächste Priority ermitteln
|
||
$next = 1;
|
||
$s = $conn->prepare('SELECT COALESCE(MAX(priority),0) AS maxp FROM wishes WHERE wishlist = ?');
|
||
$s->bind_param('i', $ListID);
|
||
$s->execute();
|
||
$r = $s->get_result();
|
||
if ($r && $m = $r->fetch_assoc())
|
||
$next = ((int) $m['maxp']) + 1;
|
||
$s->close();
|
||
|
||
$stmt = $conn->prepare('
|
||
INSERT INTO wishes (title, description, link, image, price, wishlist, priority, qty)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
');
|
||
$link = $ItemLink !== '' ? $ItemLink : null;
|
||
$image = $imageLocalLink !== null ? $imageLocalLink : null;
|
||
$stmt->bind_param(
|
||
'ssssiiii',
|
||
$ItemTitle,
|
||
$ItemDescription,
|
||
$link,
|
||
$image,
|
||
$ItemPrice,
|
||
$ListID,
|
||
$next,
|
||
$ItemQty
|
||
);
|
||
|
||
if (!$stmt->execute()) {
|
||
$stmt->close();
|
||
$conn->close();
|
||
fail('Speichern fehlgeschlagen', 500);
|
||
}
|
||
$stmt->close();
|
||
|
||
/* ====== EDIT ====== */
|
||
} elseif ($action === 'edit') {
|
||
if ($WhishID <= 0) {
|
||
$conn->close();
|
||
fail('Ungültige Item-ID', 400);
|
||
}
|
||
|
||
// Besitz prüfen & altes Bild holen
|
||
$stmt = $conn->prepare('SELECT image FROM wishes WHERE ID = ? AND wishlist = ?');
|
||
$stmt->bind_param('ii', $WhishID, $ListID);
|
||
$stmt->execute();
|
||
$res = $stmt->get_result();
|
||
if (!$res || !($row = $res->fetch_assoc())) {
|
||
$stmt->close();
|
||
$conn->close();
|
||
fail('Item nicht gefunden/autorisiert', 404);
|
||
}
|
||
$oldImage = (string) $row['image'];
|
||
$stmt->close();
|
||
|
||
// Bild-Entscheidung: entfernen, ersetzen oder behalten
|
||
$newImage = $oldImage;
|
||
if ($removeImage) {
|
||
if (!empty($oldImage)) {
|
||
$full = ensure_imagedir() . '/' . $oldImage;
|
||
if (is_file($full))
|
||
@unlink($full);
|
||
}
|
||
$newImage = '';
|
||
} elseif ($imageLocalLink !== null) {
|
||
if (!empty($oldImage)) {
|
||
$full = ensure_imagedir() . '/' . $oldImage;
|
||
if (is_file($full))
|
||
@unlink($full);
|
||
}
|
||
$newImage = $imageLocalLink;
|
||
}
|
||
|
||
$link = $ItemLink !== '' ? $ItemLink : null;
|
||
$imgNullable = ($newImage !== '') ? $newImage : null;
|
||
|
||
$stmt = $conn->prepare(
|
||
'UPDATE wishes SET title=?, description=?, link=?, image=?, price=?, qty=? WHERE ID=? AND wishlist=?'
|
||
);
|
||
$stmt->bind_param(
|
||
'sssssiii',
|
||
$ItemTitle,
|
||
$ItemDescription,
|
||
$link,
|
||
$imgNullable,
|
||
$ItemPrice,
|
||
$ItemQty,
|
||
$WhishID,
|
||
$ListID
|
||
);
|
||
|
||
if (!$stmt->execute()) {
|
||
$stmt->close();
|
||
$conn->close();
|
||
fail('Update fehlgeschlagen', 500);
|
||
}
|
||
$stmt->close();
|
||
|
||
} else {
|
||
$conn->close();
|
||
fail('Unbekannte Aktion', 400);
|
||
}
|
||
|
||
$conn->close();
|
||
|
||
/* Redirect zurück (sicher, mit UUID) */
|
||
$redirect = (string) ($_SERVER['HTTP_REFERER'] ?? '');
|
||
if ($redirect === '' || stripos($redirect, 'http') !== 0) {
|
||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||
$scheme = $secure ? 'https' : 'http';
|
||
$qs = '?list=' . urlencode($ListUUID);
|
||
if ($sortbyTransfer !== '')
|
||
$qs .= '&sort=' . urlencode($sortbyTransfer);
|
||
$redirect = $scheme . '://' . $host . '/' . $qs;
|
||
}
|
||
header('Location: ' . $redirect, true, 303);
|
||
exit;
|