Initial public commit (clean history)
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# Kopieren zu .env und anpassen
|
||||
SFTP_HOST=change.me
|
||||
SFTP_USER=change_me
|
||||
SSH_KEY=/home/you/.ssh/id_ed25519
|
||||
TARGET_DIR=/public_html/wishlist.tld
|
||||
SOURCE_DIR=./
|
||||
DRY_RUN=1
|
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Wishlist secrets & local artifacts
|
||||
config/config.php
|
||||
data/
|
||||
*.log
|
||||
*.tmp
|
||||
.cache/
|
||||
.env
|
||||
*.pem
|
||||
*.key
|
||||
id_*
|
||||
.ssh/
|
||||
data/
|
||||
node_modules/
|
45
config/config.sample.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* Sample-Konfiguration für Simple Wishlist
|
||||
* Kopiere diese Datei nach config.php und trage echte Werte ein.
|
||||
*
|
||||
* Sicherheitshinweise:
|
||||
* - Lege die echte config.php NICHT ins Git-Repo (siehe .gitignore).
|
||||
* - Setze Dateirechte restriktiv (z. B. 640) und Eigentümer auf den Webserver-User.
|
||||
* - $image_host_whitelist: Wenn gesetzt, dürfen Bilder NUR von diesen Hosts gepullt werden.
|
||||
*/
|
||||
|
||||
$servername = 'localhost';
|
||||
$username = 'wishlist';
|
||||
$password = 'yourcooldbpasshere';
|
||||
$db = 'wishlist';
|
||||
|
||||
/**
|
||||
* Pfad zum Bilder-Verzeichnis relativ zum Webroot oder absolut.
|
||||
* Standard (relativ): 'data/images'
|
||||
* Beispiel (absolut): '/var/www/wishlist/data/images'
|
||||
*/
|
||||
$imagedir = 'data/images';
|
||||
|
||||
/**
|
||||
* Pfad zum Bilder-Verzeichnis für Platzhalter-Bilder zum Webroot oder absolut.
|
||||
* Standard (relativ): 'img/placeholders'
|
||||
* Beispiel (absolut): '/var/www/wishlist/img/placeholders'
|
||||
*/
|
||||
$placeholders_imagedir = 'img/placeholders';
|
||||
|
||||
/**
|
||||
* (Optional) Whitelist für Bild-Downloads (SSRF-Schutz).
|
||||
* - Leere Liste => kein Host explizit erlaubt, aber add_item.php blockt private/Loopback/Link-Local IPs.
|
||||
* - Wenn du hier Einträge setzt, dürfen NUR diese Hosts (und deren Subdomains) genutzt werden.
|
||||
* Beispiel: ['i.imgur.com', 'images.example.com']
|
||||
*/
|
||||
$image_host_whitelist = [];
|
||||
|
||||
/**
|
||||
* (Optional) Produktions-Flags – falls du sie in Templates brauchst.
|
||||
* Die App selbst setzt error_reporting in index/add_item; hier nur als Doku.
|
||||
*/
|
||||
// $app_env = 'production'; // 'development' | 'production'
|
||||
|
||||
?>
|
7
css/bootstrap.min.css
vendored
Normal file
1
css/bootstrap.min.css.map
Normal file
7
css/bootstrap.rtl.min.css
vendored
Normal file
1
css/bootstrap.rtl.min.css.map
Normal file
8441
css/custom.css
Normal file
9
css/fontawesome.min.css
vendored
Normal file
250
css/tweaks.css
Normal file
@@ -0,0 +1,250 @@
|
||||
/* ============ Base / Vars ============ */
|
||||
@font-face {
|
||||
font-family: "Comfortaa";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url(../webfonts/comfortaa.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
||||
U+FEFF, U+FFFD;
|
||||
}
|
||||
:root {
|
||||
--wl-card-radius: 0.75rem;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue",
|
||||
Arial, "Comfortaa", sans-serif;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
.object-fit-contain {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ============ Cards ============ */
|
||||
.card {
|
||||
border-radius: var(--wl-card-radius);
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.card .ratio {
|
||||
--bs-aspect-ratio: 56.25%; /* 16:9 Bildfläche */
|
||||
border-top-left-radius: var(--wl-card-radius);
|
||||
border-top-right-radius: var(--wl-card-radius);
|
||||
background: #fff;
|
||||
}
|
||||
.card-img-top {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 9rem;
|
||||
}
|
||||
|
||||
/* Titelzeile */
|
||||
.card-title-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.card-title-row .card-title,
|
||||
.card-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.qty-pill {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
background: var(--bs-gray-200);
|
||||
color: var(--bs-gray-800);
|
||||
}
|
||||
|
||||
/* Beschreibung (2 Zeilen Ellipsis) */
|
||||
.card-text {
|
||||
color: #333;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 2.6em;
|
||||
}
|
||||
|
||||
/* Status rechts */
|
||||
.status-right .fs-6 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.card .text-end small,
|
||||
.card .text-end .badge {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Header Controls */
|
||||
.navbar-brand svg {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.navbar .form-control#sortby {
|
||||
min-width: 10.5rem;
|
||||
}
|
||||
|
||||
/* Demo-/Bootstrap-Beispielkram killen */
|
||||
.bd-placeholder-img,
|
||||
.bd-placeholder-img-lg,
|
||||
.b-example-divider,
|
||||
.b-example-vr,
|
||||
.nav-scroller,
|
||||
.nav-scroller .nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============ Admin-Icons (schwebend) ============ */
|
||||
/* schwebende Admin-Icons sichtbar machen */
|
||||
.wl-card {
|
||||
position: relative;
|
||||
} /* hast du schon */
|
||||
.wl-card .admin-actions {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(2px);
|
||||
border-radius: 999px;
|
||||
padding: 0.125rem;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
/* beim Hovern (oder Tastaturfokus) einblenden */
|
||||
.wl-card:hover .admin-actions {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* --- Admin-Icon oben rechts bleibt kompakt --- */
|
||||
.wl-card .btn-icon {
|
||||
box-sizing: border-box;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ============ Untere Aktionsleiste ============ */
|
||||
.btn-group-pills {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Idle: klein und fix 40px */
|
||||
.btn-group-pills > .btn.btn-pill {
|
||||
flex: 0 0 40px !important;
|
||||
min-width: 40px; /* falls Bootstrap-Mindestbreiten reinfunken */
|
||||
}
|
||||
|
||||
/* Hover/Fokus/Aktiv: darf wachsen */
|
||||
.btn-group-pills > .btn.btn-pill:hover,
|
||||
.btn-group-pills > .btn.btn-pill:focus,
|
||||
.btn-group-pills > .btn.btn-pill:active {
|
||||
flex: 0 1 16rem !important; /* überschreibt die Idle-Regel */
|
||||
width: auto !important; /* falls irgendwo width:40px gewinnt */
|
||||
padding: 0.5rem 0.8rem; /* „aufklappen“ */
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Text mit aufklappen */
|
||||
.btn-pill:hover span,
|
||||
.btn-pill:focus span,
|
||||
.btn-pill:active span {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
/* kompakter Grundzustand: NUR Icon, super schmal */
|
||||
.btn-pill {
|
||||
box-sizing: border-box; /* Breite inkl. Padding & Border */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-basis: 40px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-pill i {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
.btn-pill span {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
margin-left: 0;
|
||||
transition: width 0.18s ease, opacity 0.18s ease, margin-left 0.18s ease;
|
||||
}
|
||||
|
||||
/* Hover: Button wächst, Text klappt auf */
|
||||
.btn-pill:hover {
|
||||
width: auto;
|
||||
flex: 0 1 16rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
/* Keine Ausrichtung hier setzen – die Utility-Klasse bleibt wirksam */
|
||||
}
|
||||
.btn-pill:hover span {
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
/* Fokus sichtbar (zugänglich) */
|
||||
.btn-pill:focus-visible {
|
||||
outline: 2px solid rgba(13, 110, 253, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ================= Footer ================== */
|
||||
|
||||
.icon-sm {
|
||||
width: 24px; /* oder 24px, wenn du es etwas größer magst */
|
||||
height: auto;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ============ Responsive Tweaks ============ */
|
||||
@media (max-width: 575.98px) {
|
||||
.card-body {
|
||||
min-height: unset;
|
||||
}
|
||||
.btn-group-pills {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.btn-pill {
|
||||
width: 38px;
|
||||
flex-basis: 38px;
|
||||
}
|
||||
}
|
153
deploy.sh
Executable file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# =========================
|
||||
# Wishlist Deploy (SFTP)
|
||||
# - Git-safe Defaults
|
||||
# - .env Support
|
||||
# =========================
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --- Optional: .env im Skriptordner laden (wenn vorhanden) ---
|
||||
if [[ -f "${SCRIPT_DIR}/.env" ]]; then
|
||||
# Nur schlichte KEY=VALUE Zeilen ohne Export/Spaces
|
||||
# shellcheck disable=SC2046
|
||||
set -a
|
||||
source "${SCRIPT_DIR}/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# --- Pfade/Quellen ---
|
||||
SOURCE_DIR="${SOURCE_DIR:-"$SCRIPT_DIR/"}"
|
||||
TARGET_DIR="${TARGET_DIR:-"/public_html/CHANGE_ME_path"}"
|
||||
|
||||
# --- Verbindungsdaten: absichtlich ungültige Defaults (müssen überschrieben werden) ---
|
||||
SFTP_HOST="${SFTP_HOST:-CHANGE_ME_HOST}"
|
||||
SFTP_USER="${SFTP_USER:-CHANGE_ME_USER}"
|
||||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/CHANGE_ME_key}"
|
||||
|
||||
# Flags
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
DEBUG="${DEBUG:-0}"
|
||||
|
||||
# Glob-Excludes (nur Globs, lftp-kompatibel)
|
||||
EXCLUDE_ARGS=(
|
||||
--exclude-glob ".git"
|
||||
--exclude-glob ".git/*"
|
||||
--exclude-glob "*/.git"
|
||||
--exclude-glob "*/.git/*"
|
||||
--exclude-glob "**/.git"
|
||||
--exclude-glob "**/.git/*"
|
||||
--exclude-glob ".git*"
|
||||
--exclude-glob "*/.git*"
|
||||
--exclude-glob "**/.git*"
|
||||
--exclude-glob ".gitattributes"
|
||||
--exclude-glob ".gitignore"
|
||||
--exclude-glob ".github*"
|
||||
|
||||
--exclude-glob ".env*"
|
||||
--exclude-glob "deploy*.sh"
|
||||
--exclude-glob "README*"
|
||||
--exclude-glob "*.md"
|
||||
--exclude-glob "*.sql"
|
||||
|
||||
--exclude-glob "node_modules"
|
||||
--exclude-glob "node_modules/**"
|
||||
--exclude-glob "vendor/*/.git*"
|
||||
|
||||
--exclude-glob "config/config.php"
|
||||
|
||||
--exclude-glob "data"
|
||||
--exclude-glob "data/*"
|
||||
--exclude-glob "data/**"
|
||||
)
|
||||
|
||||
need() { command -v "$1" >/dev/null 2>&1 || { echo -e "${RED}Error: '$1' ist nicht installiert.${NC}"; exit 1; }; }
|
||||
|
||||
build_connect_program() {
|
||||
printf "ssh -i %q -o IdentitiesOnly=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -o NumberOfPasswordPrompts=0 -o BatchMode=yes -o ConnectTimeout=15 -o LogLevel=ERROR" "$SSH_KEY"
|
||||
}
|
||||
|
||||
abort_defaults_present() {
|
||||
local bad=0
|
||||
if [[ "$SFTP_HOST" == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: SFTP_HOST=${SFTP_HOST}${NC}"; bad=1; fi
|
||||
if [[ "$SFTP_USER" == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: SFTP_USER=${SFTP_USER}${NC}"; bad=1; fi
|
||||
if [[ "$SSH_KEY" == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: SSH_KEY=${SSH_KEY}${NC}"; bad=1; fi
|
||||
if [[ "$TARGET_DIR" == *CHANGE_ME* ]]; then echo -e "${RED}Unsafe default: TARGET_DIR=${TARGET_DIR}${NC}"; bad=1; fi
|
||||
|
||||
if (( bad == 1 )); then
|
||||
cat <<EOF >&2
|
||||
${YELLOW}Hinweis:${NC} Setze die Variablen per Umgebung oder .env:
|
||||
SFTP_HOST=example.org
|
||||
SFTP_USER=example
|
||||
SSH_KEY=/home/user/.ssh/id_ed25519
|
||||
TARGET_DIR=/public_html/wishlist.hiabuto.de
|
||||
Abbruch, weil noch CHANGE_ME-Defaults aktiv sind.
|
||||
EOF
|
||||
exit 42
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${GREEN}Starting wishlist deployment...${NC}"
|
||||
echo -e "${GREEN}SFTP Upload -> ${SFTP_USER}@${SFTP_HOST}${NC}"
|
||||
echo -e "${GREEN}Target Dir -> ${TARGET_DIR}${NC}"
|
||||
(( DRY_RUN == 1 )) && echo -e "${YELLOW}Mode -> DRY-RUN${NC}"
|
||||
|
||||
need lftp
|
||||
abort_defaults_present
|
||||
|
||||
[ -d "$SOURCE_DIR" ] || { echo -e "${RED}Error: SOURCE_DIR existiert nicht: ${SOURCE_DIR}${NC}"; exit 1; }
|
||||
[ -r "$SSH_KEY" ] || { echo -e "${RED}Error: SSH-Key nicht gefunden/lesbar: ${SSH_KEY}${NC}"; exit 1; }
|
||||
|
||||
case "$TARGET_DIR" in
|
||||
/public_html/*) : ;;
|
||||
*) echo -e "${RED}TARGET_DIR muss unter /public_html/ liegen (aktuell: ${TARGET_DIR})${NC}"; exit 1;;
|
||||
esac
|
||||
|
||||
echo -e "${YELLOW}>> Prüfe SFTP-Verbindung (key-only)...${NC}"
|
||||
if ! lftp </dev/null -e "
|
||||
set cmd:interactive false;
|
||||
set cmd:fail-exit yes;
|
||||
set net:max-retries 1;
|
||||
set net:timeout 15;
|
||||
set sftp:auto-confirm yes;
|
||||
set sftp:connect-program '$(build_connect_program)';
|
||||
open -u ${SFTP_USER}, sftp://${SFTP_HOST};
|
||||
cls -1 '${TARGET_DIR}';
|
||||
bye
|
||||
" >/dev/null 2>&1; then
|
||||
echo -e "${RED}SFTP-Test fehlgeschlagen.${NC}"
|
||||
exit 255
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}>> Upload per SFTP (mirror -R)...${NC}"
|
||||
MIRROR_OPTS=( -R --delete --verbose --parallel=4 )
|
||||
(( DRY_RUN == 1 )) && MIRROR_OPTS+=( --dry-run )
|
||||
|
||||
(( DEBUG == 1 )) && {
|
||||
echo "mirror opts: ${MIRROR_OPTS[*]}"
|
||||
echo "exclude : ${EXCLUDE_ARGS[*]}"
|
||||
}
|
||||
|
||||
lftp -e "
|
||||
set cmd:interactive false;
|
||||
set cmd:fail-exit yes;
|
||||
set net:max-retries 2;
|
||||
set net:timeout 20;
|
||||
set sftp:auto-confirm yes;
|
||||
set sftp:connect-program '$(build_connect_program)';
|
||||
open -u ${SFTP_USER}, sftp://${SFTP_HOST};
|
||||
mirror ${MIRROR_OPTS[*]} ${EXCLUDE_ARGS[*]} '${SOURCE_DIR%/}/' '${TARGET_DIR%/}/';
|
||||
bye
|
||||
"
|
||||
|
||||
if (( DRY_RUN == 1 )); then
|
||||
echo -e "${GREEN}DRY-RUN erfolgreich (keine Dateien verändert).${NC}"
|
||||
else
|
||||
echo -e "${GREEN}SFTP-Upload erfolgreich.${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Deployment completed.${NC}"
|
BIN
img/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
img/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 5.6 KiB |
BIN
img/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
9
img/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="img/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
BIN
img/favicon-16x16.png
Normal file
After Width: | Height: | Size: 511 B |
BIN
img/favicon-32x32.png
Normal file
After Width: | Height: | Size: 607 B |
BIN
img/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
3
img/gift.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-gift" viewBox="0 0 16 16">
|
||||
<path d="M3 2.5a2.5 2.5 0 0 1 5 0 2.5 2.5 0 0 1 5 0v.006c0 .07 0 .27-.038.494H15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 14.5V7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.038A2.968 2.968 0 0 1 3 2.506V2.5zm1.068.5H7v-.5a1.5 1.5 0 1 0-3 0c0 .085.002.274.045.43a.522.522 0 0 0 .023.07zM9 3h2.932a.56.56 0 0 0 .023-.07c.043-.156.045-.345.045-.43a1.5 1.5 0 0 0-3 0V3zM1 4v2h6V4H1zm8 0v2h6V4H9zm5 3H9v8h4.5a.5.5 0 0 0 .5-.5V7zm-7 8V7H2v7.5a.5.5 0 0 0 .5.5H7z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 613 B |
BIN
img/heart-icon.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
img/mstile-144x144.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
img/mstile-150x150.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
img/mstile-310x150.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
img/mstile-310x310.png
Normal file
After Width: | Height: | Size: 4.1 KiB |
BIN
img/mstile-70x70.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
img/paw-icon.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
img/placeholders/no-image-katie-1.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
img/placeholders/no-image-katie-2.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
39
img/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M2225 6985 c-102 -20 -110 -22 -178 -46 -378 -133 -657 -466 -720
|
||||
-861 -14 -83 -15 -304 -3 -360 6 -27 5 -28 -36 -29 -24 0 -234 -1 -467 -1
|
||||
-460 0 -499 -4 -594 -55 -76 -42 -145 -117 -186 -203 l-36 -75 -2 -520 c-2
|
||||
-574 -1 -581 60 -684 64 -107 180 -185 306 -206 l66 -10 1 -1655 c1 -910 4
|
||||
-1678 7 -1706 34 -272 244 -501 514 -559 103 -22 4983 -22 5088 0 236 49 430
|
||||
235 495 475 l23 85 0 1678 c0 1194 4 1680 11 1682 6 1 40 8 75 15 161 30 294
|
||||
155 336 315 23 89 22 999 -1 1092 -39 156 -151 270 -308 313 -53 15 -120 17
|
||||
-529 18 -258 0 -470 1 -472 3 -2 2 1 36 5 74 8 69 5 244 -5 317 -9 65 -55 208
|
||||
-90 285 -98 213 -276 399 -485 507 -81 42 -186 83 -239 92 -13 3 -53 11 -89
|
||||
18 -107 23 -326 14 -447 -18 -113 -30 -292 -113 -375 -176 -125 -93 -235 -219
|
||||
-313 -357 -62 -110 -123 -294 -126 -380 -2 -46 -17 -21 -29 46 -32 190 -139
|
||||
406 -270 546 -159 172 -347 278 -597 336 -75 17 -308 20 -390 4z m347 -445
|
||||
c244 -56 454 -294 484 -546 10 -87 10 -295 0 -301 -15 -9 -1267 -6 -1276 3
|
||||
-17 17 -23 70 -24 199 0 97 5 145 18 190 76 253 306 448 557 471 35 4 65 7 66
|
||||
8 5 4 124 -12 175 -24z m2235 -16 c102 -33 167 -75 253 -163 79 -80 134 -170
|
||||
165 -271 31 -99 26 -362 -7 -395 -9 -9 -1267 -10 -1275 -1 -9 8 -7 233 2 304
|
||||
20 162 120 331 257 435 87 67 245 122 368 129 65 4 154 -10 237 -38z m-1297
|
||||
-508 c0 -3 -4 -8 -10 -11 -5 -3 -10 -1 -10 4 0 6 5 11 10 11 6 0 10 -2 10 -4z
|
||||
m-449 -773 c0 -5 1 -200 2 -436 1 -278 -1 -427 -8 -428 -5 -1 -596 -2 -1312
|
||||
-3 l-1303 -1 -1 25 c-1 31 -1 844 0 848 2 6 2621 1 2622 -5z m3500 -5 c1 -16
|
||||
1 -839 0 -853 -1 -12 -2605 -15 -2617 -3 -5 6 -10 775 -5 856 1 9 270 12 1311
|
||||
12 1041 0 1310 -3 1311 -12z m-3500 -1337 c4 -179 1 -3450 -4 -3457 -3 -5
|
||||
-430 -9 -1004 -8 -982 1 -999 1 -1044 21 -53 24 -82 52 -112 108 l-21 40 -1
|
||||
1666 0 1666 1092 0 1093 0 1 -36z m3064 -1631 l-1 -1665 -21 -40 c-30 -56 -59
|
||||
-84 -112 -108 -45 -20 -62 -20 -1039 -21 -547 -1 -999 2 -1004 6 -8 4 -11 537
|
||||
-10 1740 0 953 1 1738 1 1744 1 8 311 11 1094 10 l1092 -1 0 -1665z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
BIN
img/shakaru-icon.png
Normal file
After Width: | Height: | Size: 27 KiB |
19
img/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
116
include/delete_unused.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* delete_unused.php
|
||||
*
|
||||
* Löscht Bilddateien aus $imagedir, die in der DB nicht referenziert sind.
|
||||
* Sicherheit:
|
||||
* - CLI-only (kein Webzugriff)
|
||||
* - Pfad-Härtung via realpath()
|
||||
* - Dry-Run optional (default)
|
||||
*
|
||||
* Aufruf:
|
||||
* php delete_unused.php # Dry-Run (zeigt nur an)
|
||||
* php delete_unused.php --apply # wirklich löschen
|
||||
*/
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(403);
|
||||
exit('Forbidden');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
function out(string $msg): void
|
||||
{
|
||||
fwrite(STDOUT, $msg . PHP_EOL);
|
||||
}
|
||||
function err(string $msg): void
|
||||
{
|
||||
fwrite(STDERR, "ERROR: " . $msg . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Flags
|
||||
$apply = in_array('--apply', $argv, true);
|
||||
|
||||
// DB verbinden
|
||||
$conn = @new mysqli($GLOBALS['servername'], $GLOBALS['username'], $GLOBALS['password'], $GLOBALS['db']);
|
||||
if ($conn->connect_error)
|
||||
err('DB-Verbindung fehlgeschlagen');
|
||||
|
||||
// Bildverzeichnis prüfen/härten
|
||||
$baseDir = realpath(__DIR__ . '/..');
|
||||
if ($baseDir === false)
|
||||
err('BaseDir nicht gefunden');
|
||||
|
||||
$imgDirCfg = rtrim((string) $GLOBALS['imagedir'], '/');
|
||||
$imgDir = realpath(__DIR__ . '/../' . $imgDirCfg);
|
||||
if ($imgDir === false)
|
||||
err('imagedir nicht gefunden: ' . $imgDirCfg);
|
||||
|
||||
// Verhindere, dass außerhalb des Projekts gelöscht wird
|
||||
if (strpos($imgDir, $baseDir) !== 0)
|
||||
err('imagedir liegt außerhalb des Projekts: ' . $imgDir);
|
||||
|
||||
// Aus DB: genutzte Dateien (NULL/"" filtern)
|
||||
$used = [];
|
||||
$sql = 'SELECT image FROM wishes WHERE image IS NOT NULL AND image <> ""';
|
||||
$res = $conn->query($sql);
|
||||
if ($res === false)
|
||||
err('Query fehlgeschlagen');
|
||||
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$used[] = (string) $row['image'];
|
||||
}
|
||||
$res->free();
|
||||
$conn->close();
|
||||
|
||||
// In ein Set packen
|
||||
$usedSet = array_flip($used);
|
||||
|
||||
$deleted = 0;
|
||||
$kept = 0;
|
||||
$skipped = 0;
|
||||
|
||||
$it = new DirectoryIterator($imgDir);
|
||||
foreach ($it as $fileinfo) {
|
||||
if ($fileinfo->isDot())
|
||||
continue;
|
||||
|
||||
$path = $fileinfo->getPathname();
|
||||
if ($fileinfo->isDir()) {
|
||||
$skipped++;
|
||||
out("[skip-dir] " . $path);
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $fileinfo->getFilename();
|
||||
|
||||
if (isset($usedSet[$name])) {
|
||||
$kept++;
|
||||
out("[keep] " . $name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nicht referenziert -> löschen (oder dry-run)
|
||||
if ($apply) {
|
||||
if (@unlink($path)) {
|
||||
$deleted++;
|
||||
out("[delete] " . $name);
|
||||
} else {
|
||||
err('Konnte Datei nicht löschen: ' . $path);
|
||||
}
|
||||
} else {
|
||||
out("[dry-run] " . $name . " (würde gelöscht)");
|
||||
}
|
||||
}
|
||||
|
||||
out("");
|
||||
out("Summary:");
|
||||
out(" kept: {$kept}");
|
||||
out(" skipped: {$skipped}");
|
||||
out(" deleted: {$deleted}" . ($apply ? "" : " (dry-run)"));
|
||||
|
||||
exit(0);
|
342
include/listgenerator.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// Konfiguration einbinden (stellt $servername, $username, $password, $db, $imagedir bereit)
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
/**
|
||||
* HTML-Escape Helper (fallback, falls global e() fehlt)
|
||||
*/
|
||||
if (!function_exists('e')) {
|
||||
function e(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
/** Attribut-Helper (für data-*, value, title, …) */
|
||||
if (!function_exists('attr')) {
|
||||
function attr(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
/** Pfad zum Platzhalterbild ermitteln */
|
||||
/** Pfad zum Platzhalterbild ermitteln */
|
||||
if (!function_exists('lg_default_image')) {
|
||||
function lg_default_image(): string
|
||||
{
|
||||
$dir = $_SERVER['DOCUMENT_ROOT'] . '/' . ($GLOBALS['placeholders_imagedir'] ?? 'img/placeholders');
|
||||
|
||||
// Alle PNG/JPG Dateien aus dem Ordner holen
|
||||
$files = glob($dir . '/*.{png,jpg,jpeg,gif,webp}', GLOB_BRACE);
|
||||
|
||||
if ($files && count($files) > 0) {
|
||||
// Einen zufälligen auswählen
|
||||
$randomFile = $files[array_rand($files)];
|
||||
|
||||
// In relative URL umwandeln
|
||||
$relativePath = str_replace($_SERVER['DOCUMENT_ROOT'], '', $randomFile);
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
// Fallback: statisches no-image
|
||||
return '/img/no-image-katie-1.png';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* DB-Connector (keine Fehlerdetails nach außen)
|
||||
*/
|
||||
function lg_db(): mysqli
|
||||
{
|
||||
global $servername, $username, $password, $db;
|
||||
$conn = new mysqli($servername, $username, $password, $db);
|
||||
if ($conn->connect_error) {
|
||||
http_response_code(500);
|
||||
exit('Interner Fehler (DB)');
|
||||
}
|
||||
$conn->set_charset('utf8mb4');
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne Karten-Kachel rendern
|
||||
*/
|
||||
function generateListItem(
|
||||
int $ListItemID,
|
||||
?string $ItemImage,
|
||||
string $ItemTitle,
|
||||
?string $ItemLink,
|
||||
int $ItemPriceCents,
|
||||
?string $ItemComment,
|
||||
int $ItemReserved,
|
||||
?string $ItemDate,
|
||||
int $ItemQty,
|
||||
): void {
|
||||
global $loggedin, $imagedir;
|
||||
|
||||
// Preis formatieren (de_DE)
|
||||
$priceText = '';
|
||||
if ($ItemPriceCents > 0) {
|
||||
if (class_exists('NumberFormatter')) {
|
||||
$fmt = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
|
||||
$priceText = $fmt->formatCurrency($ItemPriceCents / 100, 'EUR');
|
||||
} else {
|
||||
$priceText = number_format($ItemPriceCents / 100, 2, ',', '.') . ' €';
|
||||
}
|
||||
}
|
||||
|
||||
// Kommentar (2 Zeilen max., CSS macht den Clamp)
|
||||
$commentPlain = trim((string) $ItemComment);
|
||||
$commentHtml = ($commentPlain === '') ? ' ' : e($commentPlain);
|
||||
|
||||
// Bild (lokal oder Placeholder)
|
||||
$srcPath = '';
|
||||
if (!empty($ItemImage)) {
|
||||
$local = rtrim($imagedir, '/') . '/' . $ItemImage;
|
||||
$srcPath = @is_file($local) ? $local : lg_default_image();
|
||||
} else {
|
||||
$srcPath = lg_default_image();
|
||||
}
|
||||
$imageTag = '<div class="ratio ratio-16x9"><img src="' . e($srcPath) . '" class="card-img-top object-fit-contain" alt="Bild zum Wunsch"></div>';
|
||||
|
||||
// Titelzeile: Qty-Pill (nur wenn >1) + einzeiliger Titel
|
||||
$qtyPill = ($ItemQty > 1) ? '<span class="qty-pill me-2">×' . (int) $ItemQty . '</span>' : '';
|
||||
$titleHtml = '<div class="d-flex align-items-center card-title-row">'
|
||||
. $qtyPill
|
||||
. '<h5 class="card-title flex-grow-1 mb-0">' . e($ItemTitle) . '</h5>'
|
||||
. '</div>';
|
||||
|
||||
// Status-Badge links
|
||||
$statusLeft = '';
|
||||
if ($ItemReserved >= $ItemQty) {
|
||||
$statusLeft = '<span class="badge bg-danger">alle reserviert</span>';
|
||||
} elseif ($ItemReserved > 0) {
|
||||
$statusLeft = '<span class="badge bg-warning text-dark">'
|
||||
. (int) $ItemReserved . ' / ' . (int) $ItemQty . ' reserviert</span>';
|
||||
}
|
||||
|
||||
// Datum (lokal lesbar)
|
||||
$dateHtml = '';
|
||||
if (!empty($ItemDate)) {
|
||||
$ts = strtotime($ItemDate);
|
||||
$dateHtml = $ts ? date('d.m.Y', $ts) : e($ItemDate);
|
||||
}
|
||||
|
||||
// User-Buttons (Reserve / Cancel / Vendor)
|
||||
// --- unten: Aktionsleiste (Icons, wachsen bei Hover) ---
|
||||
// (Reserve nur wenn möglich; Cancel immer; Vendor nur wenn Link)
|
||||
$userBtns = [];
|
||||
|
||||
if ($ItemReserved < $ItemQty) {
|
||||
$userBtns[] = sprintf(
|
||||
'<button class="btn btn-primary btn-pill justify-content-end"
|
||||
aria-label="Reservieren"
|
||||
data-bs-toggle="modal" data-bs-target="#reservationModal"
|
||||
data-wishid="%d" data-reserved="0">
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
<span>Reservieren</span>
|
||||
</button>',
|
||||
$ListItemID
|
||||
);
|
||||
}
|
||||
|
||||
if ($ItemReserved > 0) {
|
||||
$userBtns[] = sprintf(
|
||||
'<button class="btn btn-outline-primary btn-pill justify-content-center"
|
||||
aria-label="Reservierung aufheben"
|
||||
data-bs-toggle="modal" data-bs-target="#reservationModal"
|
||||
data-wishid="%d" data-reserved="1">
|
||||
<i class="fa-solid fa-unlock"></i>
|
||||
<span>Reservierung aufheben</span>
|
||||
</button>',
|
||||
$ListItemID
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($ItemLink)) {
|
||||
$safeLink = e($ItemLink);
|
||||
$userBtns[] =
|
||||
'<a class="btn btn-outline-dark btn-pill justify-content-start"
|
||||
aria-label="Zum Anbieter"
|
||||
href="' . $safeLink . '" target="_blank" rel="noopener">
|
||||
<i class="fa-solid fa-up-right-from-square">
|
||||
</i><span>Zum Anbieter</span>
|
||||
</a>';
|
||||
}
|
||||
|
||||
$userBtnRow = implode("\n", $userBtns);
|
||||
|
||||
// ... in deinem Echo-Heredoc:
|
||||
// <div class="btn-group btn-group-pills mt-3" role="group" aria-label="Aktionen">
|
||||
// {$userBtnRow}
|
||||
// </div>
|
||||
|
||||
|
||||
// Admin-Icons (nur eingeloggt)
|
||||
$adminFloating = '';
|
||||
if (!empty($loggedin)) {
|
||||
$adminFloating = '
|
||||
<div class="admin-actions btn-group btn-group-sm" role="group" aria-label="Admin-Action">
|
||||
<button class="btn btn-outline-secondary btn-icon" title="nach oben"
|
||||
data-bs-toggle="modal" data-bs-target="#pushprioModal" data-wishid="' . (int) $ListItemID . '">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-icon" title="edit"
|
||||
data-bs-toggle="modal" data-bs-target="#itemModal"
|
||||
data-mode="edit"
|
||||
data-wishid="' . (int) $ListItemID . '"
|
||||
data-title="' . attr($ItemTitle) . '"
|
||||
data-description="' . attr($commentPlain) . '"
|
||||
data-price="' . attr(($ItemPriceCents > 0) ? number_format($ItemPriceCents / 100, 2, ',', '.') : '') . '"
|
||||
data-qty="' . (int) $ItemQty . '"
|
||||
data-link="' . attr((string) $ItemLink) . '">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-icon" title="löschen"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal" data-wishid="' . (int) $ListItemID . '">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>';
|
||||
}
|
||||
|
||||
// Preis-Badge rechts
|
||||
$priceHtml = $priceText !== '' ? '<span class="badge text-bg-light fs-6 fw-semibold">' . e($priceText) . '</span>' : '';
|
||||
|
||||
echo <<<HTML
|
||||
<div class="col">
|
||||
<div class="card wl-card shadow-sm h-100 position-relative">
|
||||
{$adminFloating}
|
||||
{$imageTag}
|
||||
<div class="card-body d-flex flex-column">
|
||||
{$titleHtml}
|
||||
<p class="card-text">{$commentHtml}</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<div>{$statusLeft}</div>
|
||||
<div class="status-right text-end">
|
||||
<div>{$priceHtml}</div>
|
||||
<div class="text-muted small">{$dateHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-pills mt-3" role="group" aria-label="Aktionen">
|
||||
{$userBtnRow}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Haupt-Builder für die Listenansicht
|
||||
* $ListID: aktive Liste (int, intern)
|
||||
* $sortby: einer aus der Whitelist unten
|
||||
*/
|
||||
function wishlistMainBuilder(int $ListID, string $sortby = 'priority'): void
|
||||
{
|
||||
global $loggedin;
|
||||
|
||||
$conn = lg_db();
|
||||
|
||||
// 1) Listen-Metadaten holen
|
||||
$stmt = $conn->prepare('SELECT title, description FROM lists WHERE ID = ?');
|
||||
$stmt->bind_param('i', $ListID);
|
||||
$stmt->execute();
|
||||
$res = $stmt->get_result();
|
||||
|
||||
$listTitle = 'Unbekannte Liste';
|
||||
$listDesc = '';
|
||||
if ($res && $row = $res->fetch_assoc()) {
|
||||
$listTitle = e((string) $row['title']);
|
||||
$listDesc = e((string) $row['description']);
|
||||
}
|
||||
$stmt->close();
|
||||
|
||||
// 2) ORDER BY Whitelist
|
||||
$orderWhitelist = [
|
||||
'priority' => 'priority DESC',
|
||||
'price_asc' => 'price ASC',
|
||||
'price_desc' => 'price DESC',
|
||||
'date_desc' => 'date DESC',
|
||||
'date_asc' => 'date ASC',
|
||||
'random' => 'RAND()',
|
||||
];
|
||||
$orderSql = $orderWhitelist[$sortby] ?? $orderWhitelist['priority'];
|
||||
|
||||
// 3) Wünsche laden
|
||||
$sql = "
|
||||
SELECT
|
||||
w.ID,
|
||||
w.image,
|
||||
w.title,
|
||||
w.link,
|
||||
w.price,
|
||||
w.description,
|
||||
w.date,
|
||||
w.qty,
|
||||
COALESCE(rc.reserved_count, 0) AS reserved_count
|
||||
FROM wishes w
|
||||
LEFT JOIN v_wish_reserved_counts rc
|
||||
ON rc.wish_id = w.ID
|
||||
WHERE w.wishlist = ?
|
||||
ORDER BY {$orderSql}";
|
||||
|
||||
$stmt = $conn->prepare($sql);
|
||||
$stmt->bind_param('i', $ListID);
|
||||
$stmt->execute();
|
||||
$items = $stmt->get_result();
|
||||
|
||||
// 4) Header-Text (vorher berechnen, kein Ternary im Heredoc)
|
||||
$loginMsg = $loggedin
|
||||
? 'Eingeloggt: Du kannst Einträge bearbeiten, löschen und priorisieren.'
|
||||
: 'Tipp: Du kannst Einträge reservieren. Nur mit deinem Reservierungs-Passwort lässt sich die Reservierung wieder lösen.';
|
||||
|
||||
echo <<<HTML
|
||||
<section class="py-5 text-center container">
|
||||
<div class="row py-lg-4">
|
||||
<div class="col-lg-8 col-md-10 mx-auto">
|
||||
<h1 class="fw-light">{$listTitle}</h1>
|
||||
<p class="lead text-muted">{$listDesc}</p>
|
||||
<p class="text-muted">{$loginMsg}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="album py-3 bg-light">
|
||||
<div class="container">
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
|
||||
HTML;
|
||||
|
||||
if ($items !== false && $items->num_rows > 0) {
|
||||
while ($row = $items->fetch_assoc()) {
|
||||
generateListItem(
|
||||
(int) $row['ID'],
|
||||
$row['image'] !== null ? (string) $row['image'] : null,
|
||||
(string) $row['title'],
|
||||
$row['link'] !== null ? (string) $row['link'] : null,
|
||||
(int) $row['price'],
|
||||
$row['description'] !== null ? (string) $row['description'] : null,
|
||||
(int) $row['reserved_count'],
|
||||
$row['date'] !== null ? (string) $row['date'] : null,
|
||||
isset($row['qty']) ? (int) $row['qty'] : 1
|
||||
);
|
||||
}
|
||||
|
||||
} else {
|
||||
echo '<div class="col"><div class="alert alert-info w-100">Keine Einträge vorhanden.</div></div>';
|
||||
}
|
||||
|
||||
echo <<<HTML
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
|
||||
$stmt->close();
|
||||
$conn->close();
|
||||
}
|
583
index.php
Normal file
@@ -0,0 +1,583 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// ===== Session Setup (einheitlich in allen Entry Points) =====
|
||||
$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();
|
||||
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
require_once __DIR__ . '/include/listgenerator.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);
|
||||
}
|
||||
|
||||
$message = null;
|
||||
|
||||
if (!empty($_SESSION['flash']) && is_array($_SESSION['flash'])) {
|
||||
$message = $_SESSION['flash']; // ['msg'=>..., 'type'=> success|warning|danger]
|
||||
unset($_SESSION['flash']);
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
function e(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
function db(): mysqli
|
||||
{
|
||||
global $servername, $username, $password, $db;
|
||||
$m = new mysqli($servername, $username, $password, $db);
|
||||
if ($m->connect_error) {
|
||||
http_response_code(500);
|
||||
exit('Interner Fehler (DB)');
|
||||
}
|
||||
$m->set_charset('utf8mb4');
|
||||
return $m;
|
||||
}
|
||||
function ensure_csrf_token(): void
|
||||
{
|
||||
if (empty($_SESSION['csrf'])) {
|
||||
$_SESSION['csrf'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
}
|
||||
function require_csrf(): void
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$ok = isset($_POST['csrf']) && hash_equals($_SESSION['csrf'] ?? '', (string) $_POST['csrf']);
|
||||
if (!$ok) {
|
||||
http_response_code(403);
|
||||
exit('Ungültiges CSRF-Token');
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure_csrf_token();
|
||||
// ===== URL-Param: Liste per UUID (oder Alt-ID -> Redirect) =====
|
||||
$ListID = -1;
|
||||
$ListUUID = '';
|
||||
$showCreateEmptyState = false;
|
||||
|
||||
if (!isset($_GET['list'])) {
|
||||
// Kein Parameter => nur Empty-State ohne Message
|
||||
$showCreateEmptyState = true;
|
||||
|
||||
} else {
|
||||
$raw = trim((string) $_GET['list']);
|
||||
|
||||
if ($raw === '') {
|
||||
// Leerer Parameter => nur Empty-State ohne Message
|
||||
$showCreateEmptyState = true;
|
||||
|
||||
} elseif (preg_match('/^[0-9a-fA-F-]{32,36}$/', $raw)) {
|
||||
// UUID übergeben
|
||||
$c = db();
|
||||
$s = $c->prepare('SELECT ID, uuid FROM lists WHERE uuid=?');
|
||||
$s->bind_param('s', $raw);
|
||||
$s->execute();
|
||||
$r = $s->get_result();
|
||||
if ($r && ($row = $r->fetch_assoc())) {
|
||||
$ListID = (int) $row['ID'];
|
||||
$ListUUID = (string) $row['uuid'];
|
||||
} else {
|
||||
// UUID-Format ok, aber nicht vorhanden => Message + Empty-State
|
||||
$message = ['msg' => 'Diese Liste gibt es nicht. Lege eine neue an oder prüfe den Link.', 'type' => 'warning'];
|
||||
$showCreateEmptyState = true;
|
||||
}
|
||||
$s->close();
|
||||
$c->close();
|
||||
|
||||
} elseif (preg_match('/^\d+$/', $raw)) {
|
||||
// Numerische ID übergeben -> auf UUID umleiten, wenn vorhanden
|
||||
$id = (int) $raw;
|
||||
$c = db();
|
||||
$s = $c->prepare('SELECT uuid FROM lists WHERE ID=?');
|
||||
$s->bind_param('i', $id);
|
||||
$s->execute();
|
||||
$r = $s->get_result();
|
||||
if ($r && ($row = $r->fetch_assoc())) {
|
||||
$uuid = (string) $row['uuid'];
|
||||
$scheme = $secure ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
header('Location: ' . $scheme . '://' . $host . '/?list=' . urlencode($uuid), true, 301);
|
||||
exit;
|
||||
} else {
|
||||
// ID-Format ok, aber nicht vorhanden => Message + Empty-State
|
||||
$message = ['msg' => 'Diese Liste gibt es nicht. Lege eine neue an oder prüfe den Link.', 'type' => 'warning'];
|
||||
$showCreateEmptyState = true;
|
||||
}
|
||||
$s->close();
|
||||
$c->close();
|
||||
|
||||
} else {
|
||||
// Weder gültige UUID noch Zahl => Message + Empty-State
|
||||
$message = ['msg' => 'Diese Liste gibt es nicht. Lege eine neue an oder prüfe den Link.', 'type' => 'warning'];
|
||||
$showCreateEmptyState = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Sortierung (Whitelist) =====
|
||||
$sortby = 'priority';
|
||||
if (isset($_POST['sortby']))
|
||||
$sortby = (string) $_POST['sortby'];
|
||||
elseif (isset($_POST['sortby_transfer']))
|
||||
$sortby = (string) $_POST['sortby_transfer'];
|
||||
$allowedOrder = ['priority' => 'priority DESC', 'price_asc' => 'price ASC', 'price_desc' => 'price DESC', 'date_desc' => 'date DESC', 'date_asc' => 'date ASC', 'random' => 'RAND()'];
|
||||
if (!array_key_exists($sortby, $allowedOrder))
|
||||
$sortby = 'priority';
|
||||
|
||||
// ===== Login-Status =====
|
||||
$loggedin = (isset($_SESSION['listid']) && $ListID === (int) $_SESSION['listid']);
|
||||
$GLOBALS['loggedin'] = $loggedin; // für listgenerator.php
|
||||
|
||||
// ===== POST-Actions (mit CSRF) =====
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
require_csrf();
|
||||
|
||||
// LOGIN
|
||||
if (isset($_POST['login'])) {
|
||||
$ListPassword = (string) ($_POST['ListPassword'] ?? '');
|
||||
$ListIdFromForm = (int) ($_POST['ListID'] ?? -1);
|
||||
$c = db();
|
||||
$s = $c->prepare('SELECT edit_pw, uuid FROM lists WHERE ID=?');
|
||||
$s->bind_param('i', $ListIdFromForm);
|
||||
$s->execute();
|
||||
$r = $s->get_result();
|
||||
if ($r && ($row = $r->fetch_assoc())) {
|
||||
if (password_verify($ListPassword, (string) $row['edit_pw'])) {
|
||||
$_SESSION['listid'] = $ListIdFromForm;
|
||||
$loggedin = true;
|
||||
$ListUUID = (string) $row['uuid'];
|
||||
$message = ['msg' => 'Login erfolgreich', 'type' => 'success'];
|
||||
} else
|
||||
$message = ['msg' => 'Falsches Passwort', 'type' => 'warning'];
|
||||
} else
|
||||
$message = ['msg' => 'Liste nicht gefunden', 'type' => 'warning'];
|
||||
$s->close();
|
||||
$c->close();
|
||||
}
|
||||
|
||||
// LISTE ANLEGEN
|
||||
if (isset($_POST['listadd'])) {
|
||||
$listName = (string) ($_POST['listName'] ?? '');
|
||||
$listPasswordRaw = (string) ($_POST['listPassword'] ?? '');
|
||||
$listDescription = (string) ($_POST['listDescription'] ?? '');
|
||||
$listPassword = password_hash($listPasswordRaw, PASSWORD_DEFAULT);
|
||||
|
||||
$c = db();
|
||||
$s = $c->prepare('INSERT INTO lists (uuid, title, description, edit_pw) VALUES (UUID(), ?, ?, ?)');
|
||||
$s->bind_param('sss', $listName, $listDescription, $listPassword);
|
||||
if ($s->execute()) {
|
||||
$last_id = $c->insert_id;
|
||||
$g = $c->prepare('SELECT uuid FROM lists WHERE ID=?');
|
||||
$g->bind_param('i', $last_id);
|
||||
$g->execute();
|
||||
$gr = $g->get_result();
|
||||
$uuid = ($gr && ($row = $gr->fetch_assoc())) ? (string) $row['uuid'] : '';
|
||||
$g->close();
|
||||
$_SESSION['listid'] = $last_id;
|
||||
$loggedin = true;
|
||||
$scheme = $secure ? 'https' : 'http';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
header('Location: ' . $scheme . '://' . $host . '/?list=' . urlencode($uuid));
|
||||
exit;
|
||||
} else {
|
||||
$message = ['msg' => 'Unerwarteter Fehler beim Anlegen', 'type' => 'danger'];
|
||||
}
|
||||
$s->close();
|
||||
$c->close();
|
||||
}
|
||||
|
||||
// LOGOUT
|
||||
if (isset($_POST['logout'])) {
|
||||
session_destroy();
|
||||
$loggedin = false;
|
||||
$message = ['msg' => 'Logout erfolgreich', 'type' => 'success'];
|
||||
}
|
||||
|
||||
// PRIORITÄT PUSHEN
|
||||
if (isset($_POST['pushprio'])) {
|
||||
$wishId = (int) ($_POST['WhishID'] ?? -1);
|
||||
$c = db();
|
||||
$s = $c->prepare('SELECT COALESCE(MAX(priority),0) AS maxprio FROM wishes WHERE wishlist=?');
|
||||
$s->bind_param('i', $ListID);
|
||||
$s->execute();
|
||||
$r = $s->get_result();
|
||||
$next = 1;
|
||||
if ($r && ($row = $r->fetch_assoc()))
|
||||
$next = ((int) $row['maxprio']) + 1;
|
||||
$s->close();
|
||||
$u = $c->prepare('UPDATE wishes SET priority=? WHERE ID=?');
|
||||
$u->bind_param('ii', $next, $wishId);
|
||||
$message = $u->execute() ? ['msg' => 'Wunschpriorität aktualisiert', 'type' => 'success'] : ['msg' => 'Uups, irgendwas ist schief gegangen!', 'type' => 'danger'];
|
||||
$u->close();
|
||||
$c->close();
|
||||
}
|
||||
|
||||
// LÖSCHEN (nur eingeloggt)
|
||||
if (isset($_POST['delete']) && $loggedin === true) {
|
||||
$WhishID = (int) ($_POST['WhishID'] ?? -1);
|
||||
$WhishTitle = '';
|
||||
$c = db();
|
||||
$s = $c->prepare('SELECT image, title FROM wishes WHERE ID=?');
|
||||
$s->bind_param('i', $WhishID);
|
||||
$s->execute();
|
||||
$r = $s->get_result();
|
||||
if ($r && ($row = $r->fetch_assoc())) {
|
||||
$WhishTitle = (string) $row['title'];
|
||||
$imageFile = (string) $row['image'];
|
||||
if (!empty($imageFile)) {
|
||||
global $imagedir;
|
||||
$full = rtrim($imagedir, '/') . '/' . $imageFile;
|
||||
if (is_file($full))
|
||||
@unlink($full);
|
||||
}
|
||||
}
|
||||
$s->close();
|
||||
$d = $c->prepare('DELETE FROM wishes WHERE ID=?');
|
||||
$d->bind_param('i', $WhishID);
|
||||
$message = $d->execute() ? ['msg' => 'Wunsch <b>"' . e($WhishTitle) . '"</b> gelöscht', 'type' => 'success'] : ['msg' => 'Uups, irgendwas ist schief gegangen!', 'type' => 'danger'];
|
||||
$d->close();
|
||||
$c->close();
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Simple Wishlist</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="css/tweaks.css">
|
||||
<link rel="stylesheet" href="css/fontawesome.min.css" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png">
|
||||
<link rel="mask-icon" href="img/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<link rel="shortcut icon" href="img/favicon.ico">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="navbar navbar-dark bg-dark shadow-sm">
|
||||
<div class="container">
|
||||
<a href="#" class="navbar-brand d-flex align-items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2 bi bi-gift"
|
||||
viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M3 2.5a2.5 2.5 0 0 1 5 0 2.5 2.5 0 0 1 5 0v.006c0 .07 0 .27-.038.494H15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1v7.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 14.5V7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.038A2.968 2.968 0 0 1 3 2.506V2.5zm1.068.5H7v-.5a1.5 1.5 0 1 0-3 0c0 .085.002.274.045.43a.522.522 0 0 0 .023.07zM9 3h2.932a.56.56 0 0 0 .023-.07c.043-.156.045-.345.045-.43a1.5 1.5 0 0 0-3 0V3zM1 4v2h6V4H1zm8 0v2h6V4H9zm5 3H9v8h4.5a.5.5 0 0 0 .5-.5V7zm-7 8V7H2v7.5a.5.5 0 0 0 .5.5H7z" />
|
||||
</svg>
|
||||
<strong>Simple Wishlist</strong>
|
||||
</a>
|
||||
<div class="nav navbar-nav navbar-right">
|
||||
<div class="d-grid gap-2 d-flex">
|
||||
<button type="button" class="btn btn-outline-success my-2 my-sm-0" data-bs-toggle="modal"
|
||||
data-bs-target="#createListModal">Neue Liste</button>
|
||||
<?php if ($showCreateEmptyState == false): ?>
|
||||
<?php if ($loggedin): ?>
|
||||
<button type="button" class="btn btn-outline-secondary my-2 my-sm-0" data-bs-toggle="modal"
|
||||
data-bs-target="#itemModal" data-mode="add" data-listuuid="<?= e($ListUUID) ?>"
|
||||
data-sort="<?= e($sortby) ?>">Add Item</button>
|
||||
<form class="form-inline" action="" method="POST">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
<button type="submit" class="btn btn-outline-secondary my-2 my-sm-0" name="logout">Logout</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-outline-secondary my-2 my-sm-0" data-bs-toggle="modal"
|
||||
data-bs-target="#loginModal">Login</button>
|
||||
<?php endif; ?>
|
||||
<form class="form-inline" action="" method="POST">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
<select class="form-control" name="sortby" id="sortby">
|
||||
<option <?= $sortby === 'priority' ? 'selected' : '' ?> value="priority">Priorität</option>
|
||||
<option <?= $sortby === 'price_asc' ? 'selected' : '' ?> value="price_asc">Preis aufsteigend</option>
|
||||
<option <?= $sortby === 'price_desc' ? 'selected' : '' ?> value="price_desc">Preis absteigend</option>
|
||||
<option <?= $sortby === 'date_desc' ? 'selected' : '' ?> value="date_desc">Datum, neu → alt</option>
|
||||
<option <?= $sortby === 'date_asc' ? 'selected' : '' ?> value="date_asc">Datum, alt → neu</option>
|
||||
<option <?= $sortby === 'random' ? 'selected' : '' ?> value="random">Zufall</option>
|
||||
</select>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="pb-4">
|
||||
<?php if (isset($message)): ?>
|
||||
<div class="alert alert-<?= e($message['type']) ?> alert-dismissible fade show m-3" role="alert">
|
||||
<?= $message['msg'] ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($showCreateEmptyState)): ?>
|
||||
<!-- Empty State -->
|
||||
<section class="py-5 text-center container">
|
||||
<div class="row py-lg-4">
|
||||
<div class="col-lg-8 col-md-10 mx-auto">
|
||||
<h1 class="fw-light">Willkommen bei Simple Wishlist</h1>
|
||||
<p class="lead text-muted">
|
||||
Es wurde keine gültige Liste ausgewählt. Du kannst jetzt eine neue Liste anlegen.
|
||||
</p>
|
||||
<p>
|
||||
<button class="btn btn-success btn-lg" data-bs-toggle="modal" data-bs-target="#createListModal">
|
||||
Neue Liste anlegen
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<?php wishlistMainBuilder($ListID, $sortby); ?>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<footer class="mt-5 text-center text-muted small">
|
||||
<div class="container">
|
||||
<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">
|
||||
and
|
||||
<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">
|
||||
Shakaru
|
||||
</strong>
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
© <?= date('Y') ?> Simple Wishlist · Marcel Peterkau
|
||||
· <a href="#" class="text-decoration-none">Back to top ↑</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<?php if (!$loggedin): ?>
|
||||
<!-- Login Modal -->
|
||||
<div class="modal fade" id="loginModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Login</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form action="" method="POST">
|
||||
<div class="modal-body">
|
||||
<label for="ListPassword" class="form-label">Passwort</label>
|
||||
<input type="password" class="form-control" id="ListPassword" name="ListPassword" required>
|
||||
<input type="hidden" id="ListID" name="ListID" value="<?= (int) $ListID ?>">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" name="login" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Add/Edit Modal (ein Modal für beides) -->
|
||||
<div class="modal fade" id="itemModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<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">
|
||||
<div class="modal-body">
|
||||
<label class="form-label">Titel</label>
|
||||
<input class="form-control" name="ItemTitle" id="ItemTitle" required>
|
||||
|
||||
<label class="form-label mt-2">Beschreibung</label>
|
||||
<textarea class="form-control" name="ItemDescription" id="ItemDescription" rows="3"></textarea>
|
||||
|
||||
<label class="form-label mt-2">Preis</label>
|
||||
<div class="input-group">
|
||||
<input class="form-control" name="ItemPrice" id="ItemPrice" placeholder="0,00">
|
||||
<span class="input-group-text">€</span>
|
||||
</div>
|
||||
|
||||
<label class="form-label mt-2">Anzahl</label>
|
||||
<input class="form-control" name="ItemQty" id="ItemQty" type="number" min="1" step="1" value="1" required>
|
||||
|
||||
<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?://.+">
|
||||
|
||||
<div class="form-check mt-2" 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">
|
||||
<input type="hidden" name="WhishID" id="WhishID" value="-1">
|
||||
<input type="hidden" name="ItemListUUID" value="<?= e($ListUUID) ?>">
|
||||
<input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" id="ItemSubmitBtn">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Wunsch löschen</h5><button type="button" class="btn-close"
|
||||
data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 id="del-whish-title">WunschTitel</h5>
|
||||
<p>Soll dieser Wunsch wirklich gelöscht werden?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<form action="" method="POST">
|
||||
<input type="hidden" name="WhishID" id="DelWhishID" value="-1">
|
||||
<input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Nein</button>
|
||||
<button type="submit" name="delete" class="btn btn-danger">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push Priority Modal -->
|
||||
<div class="modal fade" id="pushprioModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Wunschpriorität</h5><button type="button" class="btn-close"
|
||||
data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5 id="prio-whish-title">WunschTitel</h5>
|
||||
<p>Priorität ganz nach oben setzen?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<form action="" method="POST">
|
||||
<input type="hidden" name="WhishID" id="PrioWhishID" value="-1">
|
||||
<input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Nein</button>
|
||||
<button type="submit" name="pushprio" class="btn btn-primary">Ja</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reservation Modal (öffentlich) -->
|
||||
<div class="modal fade" id="reservationModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="reservationModalLabel">Wunsch reservieren</h5><button type="button"
|
||||
class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="ReservationInfoText">Bitte vergeben Sie ein Passwort, um diesen Wunsch zu reservieren. Nur mit diesem
|
||||
Passwort (oder durch den Listeneigentümer) kann die Reservierung wieder aufgehoben werden.</p>
|
||||
<form action="reservations.php" method="POST" id="reservationForm">
|
||||
<label for="WishPassword" class="form-label">Passwort</label>
|
||||
<input type="password" class="form-control" id="WishPassword" name="WishPassword" required>
|
||||
<input type="hidden" name="wishid" id="modal-wishid" value="">
|
||||
<input type="hidden" name="reservedstat" id="modal-reservedstat" value="">
|
||||
<input type="hidden" name="sortby_transfer" value="<?= e($sortby) ?>">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" form="reservationForm"
|
||||
id="reservation-submit">Reservieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create List Modal -->
|
||||
<div class="modal fade" id="createListModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Neue Liste anlegen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<form action="" method="POST">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="listName" class="form-label">Titel der Liste</label>
|
||||
<input type="text" class="form-control" id="listName" name="listName" required maxlength="255">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="listDescription" class="form-label">Beschreibung (optional)</label>
|
||||
<textarea class="form-control" id="listDescription" name="listDescription" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="listPassword" class="form-label">Admin-Passwort (zum Bearbeiten)</label>
|
||||
<input type="password" class="form-control" id="listPassword" name="listPassword" required>
|
||||
<div class="form-text">
|
||||
Dieses Passwort brauchst du später für Login/Bearbeitung der Liste.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" name="listadd">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/jquery.min.js"></script>
|
||||
<script src="js/wishlist.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
426
item.php
Normal file
@@ -0,0 +1,426 @@
|
||||
<?php
|
||||
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',
|
||||
]);
|
||||
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';
|
||||
}
|
||||
function ip_in_cidr(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));
|
||||
}
|
||||
}
|
||||
function is_private_ip(string $ip): bool
|
||||
{
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
foreach (['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'] as $c)
|
||||
if (ip_in_cidr($ip, $c))
|
||||
return true;
|
||||
} elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
|
||||
foreach (['::1/128', 'fc00::/7', 'fe80::/10'] as $c)
|
||||
if (ip_in_cidr($ip, $c))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function validate_remote_host_not_private(string $url): void
|
||||
{
|
||||
$p = parse_url($url);
|
||||
if (!$p || empty($p['host']))
|
||||
fail('Ungültige URL', 400);
|
||||
$host = $p['host'];
|
||||
global $image_host_whitelist;
|
||||
if (isset($image_host_whitelist) && is_array($image_host_whitelist) && count($image_host_whitelist) > 0) {
|
||||
$ok = false;
|
||||
foreach ($image_host_whitelist as $allowed) {
|
||||
if (strcasecmp($host, $allowed) === 0) {
|
||||
$ok = true;
|
||||
break;
|
||||
}
|
||||
if (preg_match('/\.' . preg_quote($allowed, '/') . '$/i', $host)) {
|
||||
$ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$ok)
|
||||
fail('Host nicht erlaubt', 400);
|
||||
}
|
||||
$recs = dns_get_record($host, DNS_A + DNS_AAAA);
|
||||
if (!$recs || !count($recs))
|
||||
fail('Host nicht auflösbar', 400);
|
||||
foreach ($recs as $r) {
|
||||
$ip = $r['type'] === 'A' ? ($r['ip'] ?? null) : ($r['ipv6'] ?? null);
|
||||
if (!$ip)
|
||||
continue;
|
||||
if (is_private_ip($ip))
|
||||
fail('Zieladresse unzulässig', 400);
|
||||
}
|
||||
}
|
||||
function download_remote_image_limited(string $url, int $maxBytes = 5_000_000, int $timeout = 8): string
|
||||
{
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'wlimg_');
|
||||
if ($tmp === false)
|
||||
fail('Temp-Datei Fehler', 500);
|
||||
$fh = fopen($tmp, 'wb');
|
||||
if ($fh === false) {
|
||||
@unlink($tmp);
|
||||
fail('Temp-Datei Fehler', 500);
|
||||
}
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
fclose($fh);
|
||||
@unlink($tmp);
|
||||
fail('Download Fehler', 500);
|
||||
}
|
||||
$received = 0;
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_USERAGENT => 'wishlist/1.0',
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_WRITEFUNCTION => function ($ch, $data) use (&$received, $maxBytes, $fh) {
|
||||
$len = strlen($data);
|
||||
$received += $len;
|
||||
if ($received > $maxBytes)
|
||||
return 0;
|
||||
return fwrite($fh, $data);
|
||||
}
|
||||
]);
|
||||
$ok = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
fclose($fh);
|
||||
if (!$ok || $code < 200 || $code >= 300) {
|
||||
@unlink($tmp);
|
||||
fail('Bild-Download fehlgeschlagen', 400);
|
||||
}
|
||||
return $tmp;
|
||||
}
|
||||
function safe_image_filename_from_url(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;
|
||||
}
|
||||
|
||||
/* ============= 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'] ?? ''));
|
||||
$ItemImageUrl = trim((string) ($_POST['ItemImage'] ?? '')); // optional URL zum Pull
|
||||
$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);
|
||||
|
||||
/* Optional: Bild von externer URL holen */
|
||||
$imageLocalLink = null;
|
||||
if (!$removeImage && $ItemImageUrl !== '') {
|
||||
if (!is_valid_http_url($ItemImageUrl)) {
|
||||
$conn->close();
|
||||
fail('Ungültiger Bildlink', 400);
|
||||
}
|
||||
validate_remote_host_not_private($ItemImageUrl);
|
||||
$tmp = download_remote_image_limited($ItemImageUrl, 5_000_000, 8);
|
||||
$info = @getimagesize($tmp);
|
||||
if ($info === false || empty($info['mime']) || stripos($info['mime'], 'image/') !== 0) {
|
||||
@unlink($tmp);
|
||||
$conn->close();
|
||||
fail('Link ist kein gültiges Bild', 400);
|
||||
}
|
||||
global $imagedir;
|
||||
if (!is_dir($imagedir)) {
|
||||
@mkdir($imagedir, 0755, true);
|
||||
}
|
||||
$filename = safe_image_filename_from_url($ItemImageUrl);
|
||||
$target = rtrim($imagedir, '/') . '/' . $filename;
|
||||
|
||||
if (!@rename($tmp, $target)) {
|
||||
// Fallback falls rename scheitert
|
||||
if (!@copy($tmp, $target)) {
|
||||
@unlink($tmp);
|
||||
$conn->close();
|
||||
fail('Bildspeicherung fehlgeschlagen', 500);
|
||||
}
|
||||
@unlink($tmp);
|
||||
}
|
||||
|
||||
// HIER: Permissions fixen
|
||||
@chmod($target, 0644);
|
||||
|
||||
$imageLocalLink = $filename;
|
||||
}
|
||||
|
||||
/* ====== 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)) {
|
||||
global $imagedir;
|
||||
$full = rtrim($imagedir, '/') . '/' . $oldImage;
|
||||
if (is_file($full))
|
||||
@unlink($full);
|
||||
}
|
||||
$newImage = '';
|
||||
} elseif ($imageLocalLink !== null) {
|
||||
if (!empty($oldImage)) {
|
||||
global $imagedir;
|
||||
$full = rtrim($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();
|
||||
}
|
||||
/* ====== Unbekannt ====== */ 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;
|
7
js/bootstrap.bundle.min.js
vendored
Normal file
1
js/bootstrap.bundle.min.js.map
Normal file
2
js/jquery.min.js
vendored
Normal file
151
js/wishlist.js
Normal file
@@ -0,0 +1,151 @@
|
||||
// js/wishlist.js
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Sort direkt submitten
|
||||
const sortSel = document.getElementById("sortby");
|
||||
if (sortSel && sortSel.form) {
|
||||
sortSel.addEventListener("change", function () {
|
||||
this.form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Reservation Modal
|
||||
const reservationModal = document.getElementById("reservationModal");
|
||||
if (reservationModal) {
|
||||
reservationModal.addEventListener("show.bs.modal", function (ev) {
|
||||
const btn = ev.relatedTarget;
|
||||
if (!btn) return;
|
||||
const wishid = btn.getAttribute("data-wishid");
|
||||
const reserved = btn.getAttribute("data-reserved");
|
||||
|
||||
reservationModal.querySelector("#modal-wishid").value = wishid || "";
|
||||
reservationModal.querySelector("#modal-reservedstat").value =
|
||||
reserved || "";
|
||||
|
||||
const submitBtn = reservationModal.querySelector("#reservation-submit");
|
||||
const titleEl = reservationModal.querySelector("#reservationModalLabel");
|
||||
const infoEl = reservationModal.querySelector("#ReservationInfoText");
|
||||
|
||||
if (reserved === "1") {
|
||||
submitBtn.textContent = "Reservierung aufheben";
|
||||
titleEl.textContent = "Reservierung aufheben";
|
||||
if (infoEl) infoEl.style.display = "none";
|
||||
} else {
|
||||
submitBtn.textContent = "Reservieren";
|
||||
titleEl.textContent = "Wunsch reservieren";
|
||||
if (infoEl) infoEl.style.display = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete Modal
|
||||
const deleteModal = document.getElementById("deleteModal");
|
||||
if (deleteModal) {
|
||||
deleteModal.addEventListener("show.bs.modal", function (ev) {
|
||||
const btn = ev.relatedTarget;
|
||||
if (!btn) return;
|
||||
const card = btn.closest(".card");
|
||||
const title = card
|
||||
? card.querySelector(".card-title")?.textContent?.trim() || ""
|
||||
: "";
|
||||
const wishid = btn.getAttribute("data-wishid") || "";
|
||||
|
||||
// robust: suche entweder #DelWhishID ODER #WhishID
|
||||
const idInput = deleteModal.querySelector(
|
||||
'#DelWhishID, #WhishID, input[name="WhishID"]'
|
||||
);
|
||||
const titleEl = deleteModal.querySelector(
|
||||
"#del-whish-title, #whish-title"
|
||||
);
|
||||
|
||||
if (idInput) idInput.value = wishid;
|
||||
if (titleEl) titleEl.textContent = title || "Wunsch";
|
||||
});
|
||||
}
|
||||
|
||||
// Push Prio Modal
|
||||
const prioModal = document.getElementById("pushprioModal");
|
||||
if (prioModal) {
|
||||
prioModal.addEventListener("show.bs.modal", function (ev) {
|
||||
const btn = ev.relatedTarget;
|
||||
if (!btn) return;
|
||||
const card = btn.closest(".card");
|
||||
const title = card
|
||||
? card.querySelector(".card-title")?.textContent?.trim() || ""
|
||||
: "";
|
||||
const wishid = btn.getAttribute("data-wishid") || "";
|
||||
|
||||
const idInput = prioModal.querySelector(
|
||||
'#PrioWhishID, #WhishID, input[name="WhishID"]'
|
||||
);
|
||||
const titleEl = prioModal.querySelector(
|
||||
"#prio-whish-title, #whish-title"
|
||||
);
|
||||
|
||||
if (idInput) idInput.value = wishid;
|
||||
if (titleEl) titleEl.textContent = title || "Wunsch";
|
||||
});
|
||||
}
|
||||
|
||||
// Add/Edit Item Modal
|
||||
const itemModal = document.getElementById("itemModal");
|
||||
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");
|
||||
|
||||
// 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
|
||||
|
||||
if (mode === "edit") {
|
||||
titleEl.textContent = "Wunsch bearbeiten";
|
||||
actionEl.value = "edit";
|
||||
submitEl.textContent = "Speichern";
|
||||
if (removeWrap) removeWrap.style.display = "";
|
||||
if (removeChk) removeChk.checked = false;
|
||||
|
||||
const wishid = btn.getAttribute("data-wishid") || "-1";
|
||||
const dTitle = btn.getAttribute("data-title") || "";
|
||||
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
|
||||
|
||||
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
|
||||
} else {
|
||||
titleEl.textContent = "Wunsch hinzufügen";
|
||||
actionEl.value = "add";
|
||||
submitEl.textContent = "Hinzufügen";
|
||||
if (fId) fId.value = "-1";
|
||||
if (fTitle) fTitle.value = "";
|
||||
if (fDesc) fDesc.value = "";
|
||||
if (fPrice) fPrice.value = "";
|
||||
if (fLink) fLink.value = "";
|
||||
if (fImg) fImg.value = "";
|
||||
if (fQty) fQty.value = "1"; // <-- NEU
|
||||
if (removeWrap) removeWrap.style.display = "none";
|
||||
if (removeChk) removeChk.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
143
reservations.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
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',
|
||||
]);
|
||||
session_name('WLISTSESSID');
|
||||
session_start();
|
||||
umask(0022); // neue Dateien: 0644, Ordner: 0755
|
||||
|
||||
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);
|
||||
} 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, int $code=400): void {
|
||||
http_response_code($code);
|
||||
// kleine Flash-Message für index.php
|
||||
$_SESSION['flash'] = ['msg'=>$msg,'type'=> ($code>=400?'danger':'success')];
|
||||
safe_redirect_back();
|
||||
}
|
||||
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 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'] ?? '');
|
||||
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);
|
||||
} else {
|
||||
header('Location: '.$ref, true, 303);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
/* ============= Controller ============= */
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
fail('Methode nicht erlaubt', 405);
|
||||
}
|
||||
require_csrf();
|
||||
|
||||
$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);
|
||||
|
||||
$conn = db();
|
||||
|
||||
/* --- qty des Wunsch + bestehende Reservierungen zählen --- */
|
||||
$qty = 1;
|
||||
$stmt = $conn->prepare('SELECT qty FROM wishes WHERE ID = ?');
|
||||
$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);
|
||||
}
|
||||
$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->execute();
|
||||
$res = $stmt->get_result();
|
||||
if ($res && ($row = $res->fetch_assoc())) $cnt = (int)$row['c'];
|
||||
$stmt->close();
|
||||
|
||||
/* --- Operationen --- */
|
||||
if ($reservedstat === 0) {
|
||||
// setzen
|
||||
if ($cnt >= $qty) {
|
||||
$conn->close();
|
||||
fail('Für diesen Wunsch sind bereits alle Exemplare reserviert.', 409);
|
||||
}
|
||||
$hash = password_hash($pw, PASSWORD_BCRYPT);
|
||||
$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();
|
||||
fail('Reservierung fehlgeschlagen', 500);
|
||||
}
|
||||
$ins->close();
|
||||
$conn->close();
|
||||
$_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->execute();
|
||||
$res = $sel->get_result();
|
||||
|
||||
$deleted = false;
|
||||
while ($row = $res->fetch_assoc()) {
|
||||
$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);
|
||||
$deleted = $del->execute();
|
||||
$del->close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
$sel->close();
|
||||
$conn->close();
|
||||
|
||||
if ($deleted) {
|
||||
$_SESSION['flash'] = ['msg'=>'Reservierung aufgehoben','type'=>'success'];
|
||||
} else {
|
||||
$_SESSION['flash'] = ['msg'=>'Kein passender Reservierungseintrag gefunden (Passwort korrekt?)','type'=>'warning'];
|
||||
}
|
||||
safe_redirect_back();
|
||||
}
|
BIN
webfonts/comfortaa.woff2
Normal file
BIN
webfonts/fa-brands-400.woff2
Normal file
BIN
webfonts/fa-regular-400.woff2
Normal file
BIN
webfonts/fa-solid-900.woff2
Normal file
98
wishlist.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- ============================================================
|
||||
-- Simple Wishlist – Minimal-Schema
|
||||
-- Tested on MariaDB 10.x / MySQL 8.x
|
||||
-- ============================================================
|
||||
|
||||
/* Optional: eigene DB anlegen/verwenden
|
||||
CREATE DATABASE IF NOT EXISTS `wishlist`
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
USE `wishlist`;
|
||||
*/
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Tabellen löschen (idempotent für wiederholte Importe)
|
||||
-- ------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS `wishes_reservations`;
|
||||
DROP TABLE IF EXISTS `wishes`;
|
||||
DROP TABLE IF EXISTS `lists`;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- LISTEN (Metadaten & Admin-Passwort)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE `lists` (
|
||||
`ID` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL, -- öffentliche, stabile ID (Links)
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NOT NULL,
|
||||
`edit_pw` VARCHAR(255) NOT NULL, -- Passwort-Hash (password_hash)
|
||||
PRIMARY KEY (`ID`),
|
||||
UNIQUE KEY `uq_lists_uuid` (`uuid`)
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- WÜNSCHE (einer Liste zugeordnet)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE `wishes` (
|
||||
`ID` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`wishlist` INT(11) NOT NULL, -- FK -> lists.ID
|
||||
`title` VARCHAR(128) NOT NULL,
|
||||
`description` TEXT NOT NULL,
|
||||
`link` TEXT NOT NULL, -- optionaler Anbieter-Link (leer erlaubt)
|
||||
`image` TEXT DEFAULT NULL, -- Dateiname (lokal) oder NULL
|
||||
`price` INT(11) NOT NULL DEFAULT 0, -- Preis in Cent
|
||||
`date` DATE NOT NULL DEFAULT (CURRENT_DATE),
|
||||
`priority` INT(11) NOT NULL DEFAULT 0, -- Sortierung (höher = weiter oben)
|
||||
`qty` INT(11) NOT NULL DEFAULT 1, -- Anzahl benötigter Exemplare
|
||||
|
||||
PRIMARY KEY (`ID`),
|
||||
KEY `idx_wishes_wishlist` (`wishlist`),
|
||||
CONSTRAINT `fk_wishes_list`
|
||||
FOREIGN KEY (`wishlist`) REFERENCES `lists` (`ID`)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- RESERVIERUNGEN (pro Wunsch mehrere Einträge, je Reservierung einer)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE `wishes_reservations` (
|
||||
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||
`wish_id` INT(11) NOT NULL, -- FK -> wishes.ID
|
||||
`pass_hash` VARCHAR(255) NOT NULL, -- Passwort-Hash (password_hash)
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_wr_wish` (`wish_id`),
|
||||
CONSTRAINT `fk_wr_wish`
|
||||
FOREIGN KEY (`wish_id`) REFERENCES `wishes` (`ID`)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- OPTIONALE HILFS-VIEW: reserved_count je Wunsch
|
||||
-- Erleichtert das Rendern (Badge/Buttons) ohne Subquery im Code.
|
||||
-- Nutzung: SELECT w.*, rc.reserved_count FROM wishes w
|
||||
-- LEFT JOIN v_wish_reserved_counts rc ON rc.wish_id = w.ID
|
||||
-- ------------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW `v_wish_reserved_counts` AS
|
||||
SELECT
|
||||
wr.wish_id,
|
||||
COUNT(*) AS reserved_count
|
||||
FROM wishes_reservations wr
|
||||
GROUP BY wr.wish_id;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- EMPFOHLENE INDIZES (optional – je nach Abfragen)
|
||||
-- ------------------------------------------------------------
|
||||
-- Wenn häufig nach (wishlist, priority DESC) sortiert wird:
|
||||
CREATE INDEX `idx_wishes_wishlist_priority` ON `wishes` (`wishlist`, `priority`);
|
||||
|
||||
-- Wenn oft nach Datum sortiert/gefiltert wird:
|
||||
CREATE INDEX `idx_wishes_wishlist_date` ON `wishes` (`wishlist`, `date`);
|
||||
|
||||
-- Fertig.
|