Browse Source

Harden checkout and admin flows before customer deploy.

Fail orders when JSON save fails, defer checkout rate-limit until success, merge settings saves with existing intro text, fix admin CSRF and self-delete, cart remove by product key, 10 MB uploads, backorder status helpers, remove legacy redirects and dead confirmation code, and refresh config sample and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Medowar 6 days ago
parent
commit
b05b7abe9e

+ 10 - 5
README.md

@@ -32,12 +32,17 @@ Dieses Projekt ist ein internes Bestellsystem für persönliche Schutzausrüstun
 
 ## Einrichtung
 
-1. `config.php` prüfen und insbesondere `SITE_URL`, `FROM_EMAIL` und die Bestell-Voreinstellungen anpassen.
-2. Adminzugänge in `data/admins.json` pflegen.
-3. Empfängeradresse und PDF-Anhang im Admin unter `Einstellungen` prüfen.
-4. Organisationen im Admin unter `Organisationen verwalten` pflegen.
+1. `config.sample.php` nach `config.php` kopieren und anpassen (`SITE_URL`, `FROM_EMAIL`, `ORDER_PREFIX`, …).
+2. Schreibrechte auf `data/` und `data/ratelimit/` (für Rate-Limits) sicherstellen.
+3. Statische Dateien bereitstellen: `favicon.png` (Document Root), `assets/branding/`, `assets/fonts/`, `assets/no-image.jpg`.
+4. Adminzugänge in `data/admins.json` auf dem Server pflegen (nicht aus dem Repo übernehmen).
+5. Empfängeradresse und PDF-Anhang im Admin unter `Einstellungen` prüfen.
+6. Organisationen im Admin unter `Organisationen verwalten` pflegen.
+7. Apache: `.htaccess` aktiv (schützt `config.php` und JSON unter `data/`).
+
+Weitere Konstanten: [docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md).
 
 ## Hinweise
 
-- Alte Reservierungs-, Backorder- und Bestellhistorien-Features werden nicht mehr verwendet.
 - Bestellungen werden nicht im Browser für Endnutzer gespeichert oder nachverfolgt.
+- Kein automatisiertes Test-/CI-Setup vorgesehen.

+ 9 - 11
admin/admins.php

@@ -156,6 +156,12 @@ if ($_SERVER['REQUEST_METHOD'] === "POST") {
             if (!isset($adminAccounts[$targetUsername])) {
                 $message = "Admin nicht gefunden.";
                 $messageType = "error";
+            } elseif (
+                $targetUsername ===
+                normalizeAdminUsername($_SESSION['admin_username'] ?? "")
+            ) {
+                $message = "Sie können Ihr eigenes Admin-Konto nicht löschen.";
+                $messageType = "error";
             } else {
                 unset($adminAccounts[$targetUsername]);
                 if (!saveAdminAccounts($adminAccounts)) {
@@ -167,17 +173,6 @@ if ($_SERVER['REQUEST_METHOD'] === "POST") {
                         "username" => $targetUsername,
                     ]);
 
-                    if (
-                        isset($_SESSION['admin_username']) &&
-                        $_SESSION['admin_username'] === $targetUsername
-                    ) {
-                        $_SESSION['admin_logged_in'] = false;
-                        unset($_SESSION['admin_username']);
-                        session_destroy();
-                        header("Location: login.php");
-                        exit();
-                    }
-
                     $message = "Admin wurde gelöscht.";
                     $messageType = "success";
                 }
@@ -309,12 +304,15 @@ include __DIR__ . "/../includes/header.php";
                         <a href="admins.php?change=<?php echo urlencode(
                             $username,
                         ); ?>" class="btn btn-small btn-secondary">Passwort ändern</a>
+                        <?php if ($username !== $currentAdmin): ?>
                         <form method="POST" class="inline-form" onsubmit="return confirm('Admin wirklich löschen?');">
+                            <?php echo csrfField(); ?>
                             <input type="hidden" name="target_username" value="<?php echo htmlspecialchars(
                                 $username,
                             ); ?>">
                             <button type="submit" name="delete_admin" class="btn btn-small">Löschen</button>
                         </form>
+                        <?php endif; ?>
                     </td>
                 </tr>
             <?php endforeach; ?>

+ 1 - 0
admin/categories.php

@@ -191,6 +191,7 @@ include __DIR__ . "/../includes/header.php";
     <div class="panel panel-lg">
         <h3>Neue Kategorie anlegen</h3>
         <form method="POST">
+            <?php echo csrfField(); ?>
             <div class="form-group">
                 <label for="label">Name *</label>
                 <input type="text" id="label" name="label" maxlength="80" required placeholder="z.B. Accessoires">

+ 14 - 0
admin/products.php

@@ -22,10 +22,24 @@ function handleImageUpload($fileInputName = "image_file")
     }
 
     $file = $_FILES[$fileInputName];
+    if ($file["error"] === UPLOAD_ERR_INI_SIZE || $file["error"] === UPLOAD_ERR_FORM_SIZE) {
+        return [
+            "success" => false,
+            "message" => "Die Datei ist zu groß (maximal 10 MB).",
+        ];
+    }
     if ($file["error"] !== UPLOAD_ERR_OK) {
         return ["success" => false, "message" => "Upload fehlgeschlagen."];
     }
 
+    $maxBytes = 10 * 1024 * 1024;
+    if ((int) ($file["size"] ?? 0) > $maxBytes) {
+        return [
+            "success" => false,
+            "message" => "Die Datei ist zu groß (maximal 10 MB).",
+        ];
+    }
+
     $allowedExtensions = ["jpg", "jpeg", "png", "webp", "gif"];
     $originalName = basename($file["name"]);
     $extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));

+ 0 - 5
admin/reservations.php

@@ -1,5 +0,0 @@
-<?php
-require_once __DIR__ . '/../config.php';
-
-header('Location: orders.php');
-exit;

+ 2 - 2
admin/settings.php

@@ -17,12 +17,12 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['save_settings'])) {
         $message = "Ungültiges Token. Bitte versuchen Sie es erneut.";
         $messageType = "error";
     } else {
-        $settings = [
+        $settings = array_merge(getSystemSettings(), [
             "order_recipient_email" => $_POST['order_recipient_email'] ?? "",
             "attach_order_pdf_to_admin_email" => isset(
                 $_POST['attach_order_pdf_to_admin_email'],
             ),
-        ];
+        ]);
 
         if (saveSystemSettings($settings)) {
             logAccess("Admin updated system settings");

+ 20 - 6
cart.php

@@ -6,11 +6,21 @@ $pageTitle = "Warenkorb";
 
 if (
     $_SERVER['REQUEST_METHOD'] === "POST" &&
-    isset($_POST['remove_item_index'])
+    isset($_POST['remove_product_id'])
 ) {
-    // Validate CSRF token
     if (validateCsrfToken($_POST['csrf_token'] ?? "")) {
-        removeCartItemByIndex((int) $_POST['remove_item_index']);
+        removeCartItem(
+            (int) ($_POST['remove_product_id'] ?? 0),
+            (string) ($_POST['remove_size'] ?? ""),
+        );
+    } else {
+        setFlashMessage(
+            "cart_notice",
+            "error",
+            "Ungültiges Token. Bitte versuchen Sie es erneut.",
+        );
+        header("Location: cart.php");
+        exit();
     }
 }
 
@@ -52,9 +62,13 @@ include __DIR__ . "/includes/header.php";
             <div class="cart-item-actions">
                 <form method="POST">
                     <?php echo csrfField(); ?>
-                    <button type="submit" name="remove_item_index" value="<?php echo (int) $cartItem[
-                        "cart_index"
-                    ]; ?>" class="btn btn-secondary btn-small">Entfernen</button>
+                    <input type="hidden" name="remove_product_id" value="<?php echo (int) $cartItem[
+                        "product"
+                    ]["id"]; ?>">
+                    <input type="hidden" name="remove_size" value="<?php echo escape(
+                        $cartItem["size"],
+                    ); ?>">
+                    <button type="submit" class="btn btn-secondary btn-small">Entfernen</button>
                 </form>
             </div>
         </div>

+ 2 - 1
checkout.php

@@ -40,7 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['create_order'])) {
 
         if (!$validator->isValid()) {
             $errors = array_merge($errors, $validator->getErrors());
-        } elseif (!checkoutRateLimitTryConsume()) {
+        } elseif (!checkoutRateLimitWouldAllow()) {
             $errors[] =
                 "Zu viele Bestellversuche von dieser Verbindung. Bitte versuchen Sie es später erneut.";
         } else {
@@ -59,6 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === "POST" && isset($_POST['create_order'])) {
             if (!$result["success"]) {
                 $errors[] = $result["message"];
             } else {
+                checkoutRateLimitTryConsume();
                 clearCart();
                 logAccess("Order created", [
                     "order_id" => $result["order"]["id"],

+ 13 - 10
config.sample.php

@@ -1,5 +1,8 @@
 <?php
 // Configuration for the PSA order system.
+//
+// Copy this file to config.php on the server and adjust values for your environment.
+// config.php is not tracked in Git (.gitignore).
 
 // Site settings
 define('SITE_NAME', 'Stadt Freising');
@@ -10,7 +13,7 @@ define('SITE_ADDRESS_LINE', 'Dr.-von-Daller-Straße 7, 85354 Freising');
 define('SITE_IMPRINT_URL', 'https://www.freising.de/impressum/');
 define('SITE_PRIVACY_URL', 'https://www.freising.de/datenschutz');
 define('SITE_FULL_NAME', SITE_NAME . ' - ' . SITE_SERVICE_HEADER);
-define('SITE_URL', '/shop'); // Leave empty for root, or use absolute URL
+define('SITE_URL', '/shop'); // Path under web root, or '' for document root
 
 // Optional: scheme + host only (no path) for absolute links in e-mails when HTTP_HOST is wrong behind proxies.
 // define('SITE_CANONICAL_ORIGIN', 'https://www.example.org');
@@ -30,12 +33,12 @@ define('DISCLAIMER_LINES', [
 // Runtime source of truth for admin logins is data/admins.json.
 
 // Order settings
-define('ORDER_PREFIX', 'FWFS');
-define('ORDER_RECIPIENT_EMAIL', 'psa@feuerwehr-freising.de');
+define('ORDER_PREFIX', 'FS');
+define('ORDER_RECIPIENT_EMAIL', 'orders@example.org');
 define('ATTACH_ORDER_PDF_TO_ADMIN_EMAIL', true);
 
 // Email settings
-define('ADMIN_EMAIL', 'psa@feuerwehr-freising.de'); // Fallback for admin profile email defaults
+define('ADMIN_EMAIL', 'admin@example.org'); // Fallback for admin profile email defaults
 define('FROM_EMAIL', 'shop@example.org');
 define('FROM_NAME', SITE_FULL_NAME);
 
@@ -44,25 +47,25 @@ define('DATA_DIR', __DIR__ . '/data/');
 define('UPLOADS_DIR', DATA_DIR . 'uploads/');
 define('PRODUCTS_FILE', DATA_DIR . 'products.json');
 define('ORDERS_FILE', DATA_DIR . 'orders.json');
+define('MANUAL_BACKORDERS_FILE', DATA_DIR . 'manual_backorders.json');
 define('ORGANIZATIONS_FILE', DATA_DIR . 'organizations.json');
 define('SETTINGS_FILE', DATA_DIR . 'settings.json');
 define('ADMINS_FILE', DATA_DIR . 'admins.json');
 define('CATEGORIES_FILE', DATA_DIR . 'categories.json');
 define('FAQ_FILE', DATA_DIR . 'faq.json');
-define('MANUAL_BACKORDERS_FILE', DATA_DIR . 'manual_backorders.json');
 define('UPLOADS_URL', SITE_URL . '/data/uploads');
 
 // Session settings
 if (session_status() === PHP_SESSION_NONE) {
     $isHttps =
-        (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== "off") ||
+        (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
         (isset($_SERVER['SERVER_PORT']) &&
             (int) $_SERVER['SERVER_PORT'] === 443);
 
-    ini_set("session.use_strict_mode", "1");
-    ini_set("session.cookie_httponly", "1");
-    ini_set("session.cookie_secure", $isHttps ? "1" : "0");
-    ini_set("session.cookie_samesite", "Lax");
+    ini_set('session.use_strict_mode', '1');
+    ini_set('session.cookie_httponly', '1');
+    ini_set('session.cookie_secure', $isHttps ? '1' : '0');
+    ini_set('session.cookie_samesite', 'Lax');
 
     session_start();
 }

+ 2 - 2
docs/ADMIN_SYSTEM.md

@@ -30,7 +30,7 @@ Hinweise:
 
 - `password_hash` muss ein gültiger Hash für `password_verify()` sein.
 - `description` ist Pflicht (max. 120 Zeichen).
-- `email` ist Pflicht und wird für neue Bestell-Benachrichtigungen verwendet.
+- `email` ist Pflicht (Kontakt/Profil); **interne Bestellmails** gehen an die Adresse aus `data/settings.json` (`order_recipient_email`), nicht an die Admin-Profil-E-Mails.
 - Ältere Einträge im Legacy-Format (`"username": "$2y$..."`) werden weiterhin gelesen.
 - Es gibt **keine automatische Migration** mehr beim Lesen.
 
@@ -39,7 +39,7 @@ Hinweise:
 - Jeder eingeloggte Admin darf:
   - neue Admins anlegen,
   - Passwörter anderer Admins ändern,
-  - andere Admins löschen,
+  - andere Admins löschen (nicht das **eigene** Konto),
   - Beschreibungen bearbeiten.
 
 Diese Regeln werden serverseitig geprüft.

+ 21 - 1
docs/CONFIG_REFERENCE.md

@@ -1,5 +1,7 @@
 # Config Reference (`config.php`)
 
+`config.php` wird nicht versioniert. Vorlage: `config.sample.php` (auf dem Server nach `config.php` kopieren und anpassen).
+
 ## Relevante Konstanten
 
 | Konstante | Zweck |
@@ -16,7 +18,7 @@
 | `SITE_CANONICAL_ORIGIN` | Optional: `https://hostname` (ohne Pfad) für absolute E-Mail-Links, falls `HTTP_HOST` unzuverlässig ist |
 | `ADMIN_LOGIN_RATE_LIMIT_MAX` | Optional: max. fehlgeschlagene Admin-Logins pro IP und Zeitfenster (Standard: 10) |
 | `ADMIN_LOGIN_RATE_LIMIT_WINDOW` | Optional: Zeitfenster in Sekunden für Admin-Login-Limit (Standard: 900) |
-| `CHECKOUT_RATE_LIMIT_MAX` | Optional: max. Checkout-Versuche pro IP und Fenster nach erfolgreicher Formularvalidierung (Standard: 30) |
+| `CHECKOUT_RATE_LIMIT_MAX` | Optional: max. Checkout-Versuche pro IP und Fenster (Zähler erst nach erfolgreich gespeicherter Bestellung; Standard: 30) |
 | `CHECKOUT_RATE_LIMIT_WINDOW` | Optional: Zeitfenster in Sekunden für Checkout-Limit (Standard: 3600) |
 | `DISCLAIMER_LINES` | Hinweistext auf der Startseite |
 | `ORDER_PREFIX` | Präfix für Bestellnummern |
@@ -25,6 +27,9 @@
 | `ADMIN_EMAIL` | Fallback für Admin-Profile ohne gültige Mailadresse |
 | `FROM_EMAIL` | Absenderadresse ausgehender Mails |
 | `FROM_NAME` | Anzeigename ausgehender Mails |
+| `DATA_DIR` | Verzeichnis für JSON-Daten und Unterordner (`data/`) |
+| `UPLOADS_DIR` | Verzeichnis für Produktbilder (`data/uploads/`) |
+| `UPLOADS_URL` | Öffentliche URL-Basis für Uploads (`SITE_URL` + `/data/uploads`) |
 | `PRODUCTS_FILE` | JSON-Datei für Produkte |
 | `ORDERS_FILE` | JSON-Datei für Bestellungen |
 | `ORGANIZATIONS_FILE` | JSON-Datei für Organisationen |
@@ -34,6 +39,21 @@
 | `FAQ_FILE` | JSON-Datei für FAQ-Inhalte |
 | `MANUAL_BACKORDERS_FILE` | JSON-Datei für manuelle Nachbestell-Einträge (ohne Bestellbezug) |
 
+## Runtime (`data/settings.json`)
+
+Im Admin unter **Einstellungen** überschreibbar (schreibt `data/settings.json`):
+
+- `order_recipient_email` — Empfänger interner Bestellmails (hat Vorrang vor `ORDER_RECIPIENT_EMAIL`)
+- `attach_order_pdf_to_admin_email` — PDF-Anhang an interne Bestellmails
+
+Der Startseiten-Introtext wird unter **FAQ** gepflegt (`startpage_intro_text` in derselben Datei). Beim Speichern der Einstellungen bleibt der Introtext erhalten.
+
+## Rate limits und Logging
+
+- Rate-Limit-Zähler liegen unter `data/ratelimit/` (wird bei Bedarf angelegt).
+- Wenn das Verzeichnis nicht beschreibbar ist, gelten Limits als **nicht aktiv** (Anfragen werden zugelassen — Verfügbarkeit auf Shared Hosting).
+- Zugriffs- und Fehlerprotokolle: `data/logs/` (siehe `logAccess` / `logError` in `includes/functions.php`).
+
 ## Hinweis
 
 Die Konstanten definieren die Startwerte. Änderbare Betriebsparameter wie interne Empfängeradresse können zusätzlich im Adminbereich unter `Einstellungen` angepasst werden.

+ 1 - 1
docs/ORDER_PROCESS.md

@@ -83,4 +83,4 @@ Positionen mit Nachbestell-Status (**Nachzubestellen** oder **Wartet auf Lieferu
 - Keine Lieferadresse / kein Versandprozess / kein Fulfillment-Tracking.
 - Keine Endnutzer-Bestellhistorie oder Kundenkonto-Workflow.
 - Bearbeitung ist positionsbasiert im Admin (operativer Abarbeitungsstatus statt klassischer Versandstatus).
-- Legacy-Routen `reservation.php` und `orders.php` (Frontend) sowie `admin/reservations.php` leiten auf die Bestellverwaltung um.
+- Checkout-Zähler für Rate-Limits wird erst nach erfolgreich gespeicherter Bestellung erhöht.

+ 2 - 0
docs/STYLE_SYSTEM.md

@@ -1,5 +1,7 @@
 # Portable Intranet Style System Spec
 
+> **Hinweis (dieses Projekt):** Die produktive Oberfläche nutzt ein helles Freising-Theme in `assets/css/style.css` (gelb/schwarz/weiß). Dieses Dokument beschreibt ein älteres/portables dunkles Token-Set als Referenz für andere Dienste — nicht 1:1 das aktuelle Shop-CSS.
+
 ## Purpose and Scope
 This file is the canonical style contract for reproducing the current dark-theme design system across an intranet collection of services.
 

+ 113 - 56
includes/functions.php

@@ -111,10 +111,14 @@ function getRateLimitStatePath(string $name): ?string
 }
 
 /**
- * @return bool true if under limit (and counter updated), false if blocked
+ * @return bool true if under limit; when $consume is true, increments counter on allow
  */
-function rateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool
-{
+function rateLimitEvaluate(
+    string $name,
+    int $maxAttempts,
+    int $windowSeconds,
+    bool $consume,
+): bool {
     if ($maxAttempts < 1 || $windowSeconds < 1) {
         return true;
     }
@@ -185,21 +189,23 @@ function rateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds)
         return false;
     }
 
-    $count++;
-    $data[$ip] = ["w" => $windowStart, "c" => $count];
+    if ($consume) {
+        $count++;
+        $data[$ip] = ["w" => $windowStart, "c" => $count];
 
-    $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
-    if ($payload === false) {
-        flock($fh, LOCK_UN);
-        fclose($fh);
-        return true;
-    }
+        $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
+        if ($payload === false) {
+            flock($fh, LOCK_UN);
+            fclose($fh);
+            return true;
+        }
 
-    ftruncate($fh, 0);
-    rewind($fh);
-    fwrite($fh, $payload);
-    fflush($fh);
-    @chmod($path, 0660);
+        ftruncate($fh, 0);
+        rewind($fh);
+        fwrite($fh, $payload);
+        fflush($fh);
+        @chmod($path, 0660);
+    }
 
     flock($fh, LOCK_UN);
     fclose($fh);
@@ -207,6 +213,14 @@ function rateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds)
     return true;
 }
 
+/**
+ * @return bool true if under limit (and counter updated), false if blocked
+ */
+function rateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool
+{
+    return rateLimitEvaluate($name, $maxAttempts, $windowSeconds, true);
+}
+
 function rateLimitClearIp(string $name, ?string $ip = null): void
 {
     $path = getRateLimitStatePath($name);
@@ -338,6 +352,16 @@ function getCheckoutRateLimitWindow(): int
         : 3600;
 }
 
+function checkoutRateLimitWouldAllow(): bool
+{
+    return rateLimitEvaluate(
+        "checkout",
+        getCheckoutRateLimitMax(),
+        getCheckoutRateLimitWindow(),
+        false,
+    );
+}
+
 function checkoutRateLimitTryConsume(): bool
 {
     return rateLimitTryConsume(
@@ -1356,6 +1380,47 @@ function getStartpageIntroLines()
     return $lines;
 }
 
+function backorderStatusEmpty(): string
+{
+    return "";
+}
+
+function backorderStatusToBeBackordered(): string
+{
+    return "to_be_backordered";
+}
+
+function backorderStatusOrdered(): string
+{
+    return "ordered";
+}
+
+/** Statuses persisted on order line items (load/save). */
+function getBackorderStatusesForStorage(): array
+{
+    return [
+        backorderStatusEmpty(),
+        backorderStatusToBeBackordered(),
+        backorderStatusOrdered(),
+    ];
+}
+
+/** Statuses settable from order detail (mark for external reorder). */
+function getBackorderStatusesForAdminMarking(): array
+{
+    return [backorderStatusEmpty(), backorderStatusToBeBackordered()];
+}
+
+function isValidBackorderStatusForStorage(string $status): bool
+{
+    return in_array($status, getBackorderStatusesForStorage(), true);
+}
+
+function isValidBackorderStatusForAdminMarking(string $status): bool
+{
+    return in_array($status, getBackorderStatusesForAdminMarking(), true);
+}
+
 function normalizeOrderItem($item)
 {
     if (!is_array($item)) {
@@ -1383,9 +1448,8 @@ function normalizeOrderItem($item)
     }
 
     $backorderStatus = trim((string) ($item["backorder_status"] ?? ""));
-    $allowedBackorderStatuses = ["", "to_be_backordered", "ordered"];
-    if (!in_array($backorderStatus, $allowedBackorderStatuses, true)) {
-        $backorderStatus = "";
+    if (!isValidBackorderStatusForStorage($backorderStatus)) {
+        $backorderStatus = backorderStatusEmpty();
     }
 
     return [
@@ -1593,28 +1657,6 @@ function getOrderById($orderId)
     return null;
 }
 
-function findOrderByConfirmationToken($token)
-{
-    $token = trim((string) $token);
-    if ($token === "") {
-        return null;
-    }
-
-    foreach (getOrders() as $order) {
-        if (($order["confirmation_token"] ?? "") === $token) {
-            return $order;
-        }
-    }
-
-    return null;
-}
-
-function buildOrderConfirmationUrl($token)
-{
-    $path = "/order-confirm.php?token=" . urlencode($token);
-    return buildAbsoluteUrl($path);
-}
-
 function buildAbsoluteUrl($path)
 {
     $path = "/" . ltrim((string) $path, "/");
@@ -1733,7 +1775,13 @@ function createOrder(
 
     $orders = getOrders();
     $orders[] = $order;
-    saveOrders($orders);
+    if (!saveOrders($orders)) {
+        return [
+            "success" => false,
+            "message" =>
+                "Die Bestellung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
+        ];
+    }
 
     logAccess("Order created in createOrder", [
         "order_id" => $order["id"],
@@ -1892,8 +1940,7 @@ function orderItemCanBeManaged($order)
 
 function setOrderItemBackorderStatus($orderId, $itemIndex, $status)
 {
-    $allowedStatuses = ["", "to_be_backordered"];
-    if (!in_array($status, $allowedStatuses, true)) {
+    if (!isValidBackorderStatusForAdminMarking($status)) {
         return ["success" => false, "message" => "Ungültiger Nachbestellstatus."];
     }
 
@@ -1917,7 +1964,7 @@ function setOrderItemBackorderStatus($orderId, $itemIndex, $status)
             ];
         }
 
-        if ($status === "to_be_backordered") {
+        if ($status === backorderStatusToBeBackordered()) {
             if (!empty($order["items"][$itemIndex]["is_processed"])) {
                 return [
                     "success" => false,
@@ -1925,11 +1972,12 @@ function setOrderItemBackorderStatus($orderId, $itemIndex, $status)
                         "Bearbeitete Positionen können nicht als Nachbestellung markiert werden.",
                 ];
             }
-            $order["items"][$itemIndex]["backorder_status"] = "to_be_backordered";
+            $order["items"][$itemIndex]["backorder_status"] =
+                backorderStatusToBeBackordered();
             $order["items"][$itemIndex]["backordered_at"] = $now;
             $order["items"][$itemIndex]["ordered_at"] = "";
         } else {
-            $order["items"][$itemIndex]["backorder_status"] = "";
+            $order["items"][$itemIndex]["backorder_status"] = backorderStatusEmpty();
             $order["items"][$itemIndex]["backordered_at"] = "";
             $order["items"][$itemIndex]["ordered_at"] = "";
         }
@@ -2284,11 +2332,11 @@ function applyBackorderBulkUpdate($productId, $size, $fromStatus, $toStatus, $qu
                 continue 2;
             }
 
-            if ($toStatus === "ordered") {
-                $order["items"][$itemIndex]["backorder_status"] = "ordered";
+            if ($toStatus === backorderStatusOrdered()) {
+                $order["items"][$itemIndex]["backorder_status"] = backorderStatusOrdered();
                 $order["items"][$itemIndex]["ordered_at"] = $now;
             } else {
-                $order["items"][$itemIndex]["backorder_status"] = "";
+                $order["items"][$itemIndex]["backorder_status"] = backorderStatusEmpty();
                 $order["items"][$itemIndex]["ordered_at"] = "";
             }
 
@@ -2557,13 +2605,23 @@ function addCartItem($productId, $size = "")
     ];
 }
 
-function removeCartItemByIndex($index)
+function removeCartItem($productId, $size)
 {
+    $productId = (int) $productId;
+    $size = trim((string) $size);
     $cart = getCart();
-    if (isset($cart[$index])) {
-        unset($cart[$index]);
-        $_SESSION["cart"] = array_values($cart);
+    $filtered = [];
+
+    foreach ($cart as $item) {
+        $itemProductId = (int) ($item["product_id"] ?? 0);
+        $itemSize = trim((string) ($item["size"] ?? ""));
+        if ($itemProductId === $productId && $itemSize === $size) {
+            continue;
+        }
+        $filtered[] = $item;
     }
+
+    $_SESSION["cart"] = array_values($filtered);
 }
 
 function clearCart()
@@ -2574,7 +2632,7 @@ function clearCart()
 function getCartItemsDetailed()
 {
     $items = [];
-    foreach (getCart() as $index => $cartItem) {
+    foreach (getCart() as $cartItem) {
         $product = getProductById($cartItem["product_id"]);
         if ($product === null) {
             continue;
@@ -2582,7 +2640,6 @@ function getCartItemsDetailed()
 
         $size = trim((string) ($cartItem["size"] ?? ""));
         $items[] = [
-            "cart_index" => $index,
             "product" => $product,
             "size" => $size,
             "availability_label" =>

+ 0 - 5
orders.php

@@ -1,5 +0,0 @@
-<?php
-require_once __DIR__ . '/config.php';
-
-header('Location: index.php');
-exit;

+ 0 - 5
reservation.php

@@ -1,5 +0,0 @@
-<?php
-require_once __DIR__ . '/config.php';
-
-header('Location: index.php');
-exit;