Initial public commit (clean history)
							
								
								
									
										7
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| # Kopieren zu .env und anpassen | ||||
| SFTP_HOST=www374.your-server.de | ||||
| SFTP_USER=peterksd | ||||
| SSH_KEY=/home/souko/.ssh/id_ed25519 | ||||
| TARGET_DIR=/public_html/wishlist.hiabuto.de | ||||
| SOURCE_DIR=./ | ||||
| DRY_RUN=1 | ||||
							
								
								
									
										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.  | ||||