Files
Simple-Wishlist/item.php

425 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;