فهرست منبع

initial implementation order history

AI 1 ماه پیش
والد
کامیت
12aa172c79
7فایلهای تغییر یافته به همراه428 افزوده شده و 0 حذف شده
  1. 15 0
      README.md
  2. 2 0
      checkout.php
  3. 6 0
      config.sample.php
  4. 56 0
      docs/ORDER_HISTORY.md
  5. 254 0
      includes/functions.php
  6. 1 0
      includes/header.php
  7. 94 0
      orders.php

+ 15 - 0
README.md

@@ -30,6 +30,7 @@ In `config.php` mindestens diese Werte anpassen:
 - `FROM_EMAIL`
 - `FROM_NAME`
 - Optional: `ORDER_PREFIX`, `RESERVATION_EXPIRY_DAYS`
+- `ORDER_HISTORY_COOKIE_SECRET` (Pflicht für signierte Browser-Bestellhistorie)
 
 Passe `ADMIN_USERS` an:
 
@@ -47,3 +48,17 @@ define('ADMIN_USERS', [
 
 Generate the password: 
 https://www.onlinewebtoolkit.com/hash-generator using the crypt functionality.
+
+## 3) Browser-Bestellhistorie konfigurieren
+
+Für die Seite `Meine Bestellungen` wird eine signierte, browsergebundene Historie genutzt.
+
+- Setze in `config.php` einen eigenen Wert für `ORDER_HISTORY_COOKIE_SECRET`
+- Optionale Tuning-Werte:
+  - `ORDER_HISTORY_COOKIE_NAME`
+  - `ORDER_HISTORY_COOKIE_TTL_DAYS`
+  - `ORDER_HISTORY_MAX_IDS`
+
+Details zur Funktion und zum Sicherheitsmodell:
+
+- `docs/ORDER_HISTORY.md`

+ 2 - 0
checkout.php

@@ -96,9 +96,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_reservation'])
             $_SESSION['cart'] = [];
             $query = [];
             if ($regularResult && $regularResult['success']) {
+                rememberOrderId($regularResult['reservation']['id']);
                 $query[] = 'order_number=' . urlencode($regularResult['reservation']['id']);
             }
             if ($backorderResult && $backorderResult['success']) {
+                rememberOrderId($backorderResult['reservation']['id']);
                 $query[] = 'backorder_number=' . urlencode($backorderResult['reservation']['id']);
             }
             header('Location: reservation.php?' . implode('&', $query));

+ 6 - 0
config.sample.php

@@ -37,6 +37,12 @@ define('ADMIN_USERS', [
 define('RESERVATION_EXPIRY_DAYS', 60);
 define('ORDER_PREFIX', 'FWFS'); // Prefix for order number pattern: PREFIX-YEAR-SEQ
 
+// Browser-linked order history settings (no login required)
+define('ORDER_HISTORY_COOKIE_NAME', 'fw_shop_order_history');
+define('ORDER_HISTORY_COOKIE_TTL_DAYS', 365);
+define('ORDER_HISTORY_MAX_IDS', 10);
+define('ORDER_HISTORY_COOKIE_SECRET', 'change-this-order-history-secret'); // Change this to a long random secret
+
 // Email settings
 define('ADMIN_EMAIL', 'inbox@medowar.de'); // Change to your admin email
 define('FROM_EMAIL', 'shop@med0.de'); // Change to your sender email

+ 56 - 0
docs/ORDER_HISTORY.md

@@ -0,0 +1,56 @@
+# Browser-Linked Bestellhistorie
+
+## Zweck
+
+Dieses Feature zeigt Kunden ihre zuletzt erstellten Bestellungen ohne Login-System.  
+Die Ansicht ist unter `orders.php` erreichbar und listet Bestellungen, die in diesem Browser gespeichert wurden.
+
+## Nutzerverhalten
+
+- Nach erfolgreicher Reservierung wird die Bestellnummer automatisch im Browser gemerkt.
+- Die Seite `Meine Bestellungen` zeigt diese Bestellungen mit Typ, Datum, Status und Detail-Link.
+- Es gibt keine manuelle Verknüpfung per Login oder Order-Import.
+
+## Technisches Modell (Best Effort)
+
+- Speicherung erfolgt in einem signierten Cookie.
+- Cookie-Inhalt: `base64url(payload).hmac`
+- Payload-Version `v=1`:
+  - `ids`: Liste der gemerkten Bestellnummern
+  - `iat`: Zeitstempel der Erstellung
+- Signatur: `hash_hmac('sha256', base64url(payload), ORDER_HISTORY_COOKIE_SECRET)`
+- Bei ungültiger/tamperter Signatur wird der Verlauf ignoriert (fail-safe, ohne Fehlerseite).
+
+## Limits und Aufbewahrung
+
+- Maximale Anzahl gemerkter Bestellungen: `ORDER_HISTORY_MAX_IDS` (Standard `10`)
+- Aufbewahrung: `ORDER_HISTORY_COOKIE_TTL_DAYS` (Standard `365` Tage)
+- Reihenfolge: neueste zuerst
+
+## Sicherheit und Datenschutz
+
+- Browser-gebunden, nicht account-gebunden.
+- Tamper-evident durch HMAC-Signatur.
+- Cookie-Flags:
+  - `HttpOnly`
+  - `SameSite=Lax`
+  - `Secure` bei HTTPS
+- Wenn Browserdaten/Cookies gelöscht werden, ist der Verlauf weg.
+
+## Konfiguration
+
+In `config.php` müssen folgende Werte gesetzt sein:
+
+- `ORDER_HISTORY_COOKIE_NAME`
+- `ORDER_HISTORY_COOKIE_TTL_DAYS`
+- `ORDER_HISTORY_MAX_IDS`
+- `ORDER_HISTORY_COOKIE_SECRET`
+
+`ORDER_HISTORY_COOKIE_SECRET` muss ein eigener, ausreichend zufälliger Wert sein.
+
+## Betrieb und Fehlerverhalten
+
+- Das Feature ist bewusst best effort:
+  - Kann der Cookie nicht geschrieben werden, bleibt der Checkout trotzdem erfolgreich.
+  - Nicht mehr vorhandene oder ausgeblendete Bestellungen erscheinen nicht in `orders.php`.
+  - Ungültige Cookie-Daten werden still verworfen.

+ 254 - 0
includes/functions.php

@@ -77,6 +77,260 @@ function getReservationByOrderNumber($orderNumber) {
     return null;
 }
 
+/**
+ * Get remembered order IDs for the current browser profile.
+ */
+function getRememberedOrderIds(): array {
+    return readSignedOrderHistoryCookie();
+}
+
+/**
+ * Remember a newly created order ID in browser history cookie.
+ */
+function rememberOrderId(string $orderId): void {
+    if (!isValidOrderHistoryOrderId($orderId)) {
+        return;
+    }
+
+    $existingIds = getRememberedOrderIds();
+    $updatedIds = [$orderId];
+
+    foreach ($existingIds as $existingId) {
+        if ($existingId !== $orderId) {
+            $updatedIds[] = $existingId;
+        }
+    }
+
+    $maxIds = getOrderHistoryMaxIds();
+    if (count($updatedIds) > $maxIds) {
+        $updatedIds = array_slice($updatedIds, 0, $maxIds);
+    }
+
+    writeSignedOrderHistoryCookie($updatedIds);
+}
+
+/**
+ * Read and validate signed browser order history cookie.
+ */
+function readSignedOrderHistoryCookie(): array {
+    $cookieName = getOrderHistoryCookieName();
+    if (!isset($_COOKIE[$cookieName]) || !is_string($_COOKIE[$cookieName])) {
+        return [];
+    }
+
+    $secret = getOrderHistorySecret();
+    if ($secret === '') {
+        return [];
+    }
+
+    $cookieValue = $_COOKIE[$cookieName];
+    $parts = explode('.', $cookieValue, 2);
+    if (count($parts) !== 2) {
+        return [];
+    }
+
+    $encodedPayload = $parts[0];
+    $signature = $parts[1];
+    if ($encodedPayload === '' || $signature === '') {
+        return [];
+    }
+
+    $expectedSignature = hash_hmac('sha256', $encodedPayload, $secret);
+    if (!hash_equals($expectedSignature, $signature)) {
+        return [];
+    }
+
+    $payloadJson = base64UrlDecode($encodedPayload);
+    if ($payloadJson === null) {
+        return [];
+    }
+
+    $payload = json_decode($payloadJson, true);
+    if (!is_array($payload)) {
+        return [];
+    }
+
+    $version = isset($payload['v']) ? (int)$payload['v'] : 0;
+    if ($version !== 1) {
+        return [];
+    }
+
+    $ids = isset($payload['ids']) && is_array($payload['ids']) ? $payload['ids'] : [];
+    return sanitizeOrderHistoryIds($ids);
+}
+
+/**
+ * Write signed browser order history cookie.
+ */
+function writeSignedOrderHistoryCookie(array $ids): void {
+    if (headers_sent()) {
+        return;
+    }
+
+    $secret = getOrderHistorySecret();
+    if ($secret === '') {
+        return;
+    }
+
+    $sanitizedIds = sanitizeOrderHistoryIds($ids);
+    $payload = [
+        'v' => 1,
+        'ids' => $sanitizedIds,
+        'iat' => time()
+    ];
+
+    $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
+    if ($payloadJson === false) {
+        return;
+    }
+
+    $encodedPayload = base64UrlEncode($payloadJson);
+    $signature = hash_hmac('sha256', $encodedPayload, $secret);
+    $cookieValue = $encodedPayload . '.' . $signature;
+
+    $expires = time() + (getOrderHistoryTtlDays() * 86400);
+    $success = setcookie(getOrderHistoryCookieName(), $cookieValue, [
+        'expires' => $expires,
+        'path' => getOrderHistoryCookiePath(),
+        'secure' => isHttpsRequest(),
+        'httponly' => true,
+        'samesite' => 'Lax'
+    ]);
+
+    if ($success) {
+        $_COOKIE[getOrderHistoryCookieName()] = $cookieValue;
+    }
+}
+
+/**
+ * Build a safe, deduplicated order history ID list.
+ */
+function sanitizeOrderHistoryIds(array $ids): array {
+    $result = [];
+    $seen = [];
+    $maxIds = getOrderHistoryMaxIds();
+
+    foreach ($ids as $id) {
+        if (!is_string($id) || !isValidOrderHistoryOrderId($id)) {
+            continue;
+        }
+        if (isset($seen[$id])) {
+            continue;
+        }
+        $seen[$id] = true;
+        $result[] = $id;
+
+        if (count($result) >= $maxIds) {
+            break;
+        }
+    }
+
+    return $result;
+}
+
+/**
+ * Check if order ID matches configured pattern.
+ */
+function isValidOrderHistoryOrderId($orderId): bool {
+    if (!is_string($orderId) || $orderId === '') {
+        return false;
+    }
+
+    $prefix = defined('ORDER_PREFIX') ? ORDER_PREFIX : 'ORD';
+    $pattern = '/^' . preg_quote($prefix, '/') . '-\d{4}-\d+$/';
+    return preg_match($pattern, $orderId) === 1;
+}
+
+/**
+ * Base64url encode helper.
+ */
+function base64UrlEncode(string $data): string {
+    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
+}
+
+/**
+ * Base64url decode helper.
+ */
+function base64UrlDecode(string $data): ?string {
+    $remainder = strlen($data) % 4;
+    if ($remainder > 0) {
+        $data .= str_repeat('=', 4 - $remainder);
+    }
+
+    $decoded = base64_decode(strtr($data, '-_', '+/'), true);
+    if ($decoded === false) {
+        return null;
+    }
+    return $decoded;
+}
+
+/**
+ * Get cookie name for order history.
+ */
+function getOrderHistoryCookieName(): string {
+    return defined('ORDER_HISTORY_COOKIE_NAME') ? ORDER_HISTORY_COOKIE_NAME : 'order_history';
+}
+
+/**
+ * Get secret for order history cookie signing.
+ */
+function getOrderHistorySecret(): string {
+    return defined('ORDER_HISTORY_COOKIE_SECRET') ? (string) ORDER_HISTORY_COOKIE_SECRET : '';
+}
+
+/**
+ * Get maximum number of remembered order IDs.
+ */
+function getOrderHistoryMaxIds(): int {
+    $maxIds = defined('ORDER_HISTORY_MAX_IDS') ? (int) ORDER_HISTORY_MAX_IDS : 10;
+    return $maxIds > 0 ? $maxIds : 10;
+}
+
+/**
+ * Get order history cookie retention days.
+ */
+function getOrderHistoryTtlDays(): int {
+    $ttlDays = defined('ORDER_HISTORY_COOKIE_TTL_DAYS') ? (int) ORDER_HISTORY_COOKIE_TTL_DAYS : 365;
+    return $ttlDays > 0 ? $ttlDays : 365;
+}
+
+/**
+ * Get cookie path from SITE_URL.
+ */
+function getOrderHistoryCookiePath(): string {
+    $siteUrl = defined('SITE_URL') ? trim((string) SITE_URL) : '';
+    if (strpos($siteUrl, '://') !== false) {
+        $parsedPath = parse_url($siteUrl, PHP_URL_PATH);
+        $siteUrl = is_string($parsedPath) ? $parsedPath : '';
+    }
+
+    if ($siteUrl === '' || $siteUrl === '/') {
+        return '/';
+    }
+
+    if ($siteUrl[0] !== '/') {
+        $siteUrl = '/' . $siteUrl;
+    }
+
+    return rtrim($siteUrl, '/');
+}
+
+/**
+ * Detect whether current request uses HTTPS.
+ */
+function isHttpsRequest(): bool {
+    if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
+        return true;
+    }
+    if (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443) {
+        return true;
+    }
+    if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
+        return true;
+    }
+    return false;
+}
+
 /**
  * Check if reservation is hidden (spam/deleted)
  */

+ 1 - 0
includes/header.php

@@ -19,6 +19,7 @@
             <nav class="site-nav">
                 <a href="<?php echo SITE_URL; ?>/index.php">Startseite</a>
                 <a href="<?php echo SITE_URL; ?>/cart.php">Warenkorb</a>
+                <a href="<?php echo SITE_URL; ?>/orders.php">Meine Bestellungen</a>
                 <a href="<?php echo SITE_URL; ?>/admin/">Admin</a>
             </nav>
         </div>

+ 94 - 0
orders.php

@@ -0,0 +1,94 @@
+<?php
+require_once __DIR__ . '/config.php';
+require_once __DIR__ . '/includes/functions.php';
+
+$pageTitle = 'Meine Bestellungen';
+$rememberedOrderIds = getRememberedOrderIds();
+$orders = [];
+
+foreach ($rememberedOrderIds as $orderId) {
+    $reservation = getReservationByOrderNumber($orderId);
+    if (!$reservation) {
+        continue;
+    }
+    if ((isset($reservation['status']) && $reservation['status'] === 'deleted') || isReservationHidden($reservation)) {
+        continue;
+    }
+
+    $isBackorder = isset($reservation['type']) && $reservation['type'] === 'backorder';
+    $typeLabel = $isBackorder ? 'Vorbestellung' : 'Reservierung';
+
+    if ($isBackorder) {
+        if (isset($reservation['backorder_status']) && $reservation['backorder_status'] === 'notified') {
+            $statusLabel = 'Informiert';
+            $statusClass = 'status-notified';
+        } else {
+            $statusLabel = 'Offen';
+            $statusClass = 'status-open';
+        }
+        $detailsUrl = SITE_URL . '/reservation.php?backorder_number=' . urlencode($reservation['id']);
+    } else {
+        if (!empty($reservation['picked_up'])) {
+            $statusLabel = 'Abgeholt';
+            $statusClass = 'status-picked';
+        } elseif (isset($reservation['status']) && $reservation['status'] === 'expired') {
+            $statusLabel = 'Abgelaufen';
+            $statusClass = 'status-expired';
+        } else {
+            $statusLabel = 'Offen';
+            $statusClass = 'status-open';
+        }
+        $detailsUrl = SITE_URL . '/reservation.php?order_number=' . urlencode($reservation['id']);
+    }
+
+    $orders[] = [
+        'id' => $reservation['id'],
+        'type_label' => $typeLabel,
+        'created_label' => !empty($reservation['created']) ? formatDate($reservation['created']) : '-',
+        'status_label' => $statusLabel,
+        'status_class' => $statusClass,
+        'details_url' => $detailsUrl
+    ];
+}
+
+include __DIR__ . '/includes/header.php';
+?>
+
+<h2>Meine Bestellungen</h2>
+
+<div class="alert alert-info">
+    <p>Hier sehen Sie Ihre zuletzt erstellten Bestellungen.</p>
+</div>
+
+<?php if (empty($orders)): ?>
+    <div class="alert alert-info">
+        <p>Es wurden noch keine Bestellungen in diesem Browser gefunden.</p>
+    </div>
+<?php else: ?>
+    <div class="table-responsive">
+        <table class="responsive-table">
+            <thead>
+                <tr>
+                    <th>Bestellnummer</th>
+                    <th>Typ</th>
+                    <th>Erstellt</th>
+                    <th>Status</th>
+                    <th>Aktionen</th>
+                </tr>
+            </thead>
+            <tbody>
+                <?php foreach ($orders as $order): ?>
+                    <tr>
+                        <td data-label="Bestellnummer"><strong><?php echo htmlspecialchars($order['id']); ?></strong></td>
+                        <td data-label="Typ"><?php echo htmlspecialchars($order['type_label']); ?></td>
+                        <td data-label="Erstellt"><?php echo htmlspecialchars($order['created_label']); ?></td>
+                        <td data-label="Status"><span class="status <?php echo htmlspecialchars($order['status_class']); ?>"><?php echo htmlspecialchars($order['status_label']); ?></span></td>
+                        <td data-label="Aktionen"><a href="<?php echo htmlspecialchars($order['details_url']); ?>" class="btn btn-small">Details</a></td>
+                    </tr>
+                <?php endforeach; ?>
+            </tbody>
+        </table>
+    </div>
+<?php endif; ?>
+
+<?php include __DIR__ . '/includes/footer.php'; ?>