`(new \FluentCart\Api\Orders())->getBy('uuid', $orderHash)` * against Order's own `uuid` column (fct_orders). * Otherwise, `trx_hash` present -> `\FluentCart\App\Models\OrderTransaction::query() * ->where('uuid', $transactionHash)->first()` against OrderTransaction's own `uuid` * column (fct_order_transactions), then the parent order via * `(new \FluentCart\Api\Orders())->getById($transaction->order_id)`. Both are * exact-match lookups on a random uuid string; a guessed/incremented value simply * fails the lookup (no distinguishable error, no timing/enumeration oracle beyond * what FluentCart's own native ->getBy()/->where() already exposes). * 3. Ownership/visibility gate, mirrored field-for-field from ReceiptHandler's own * `$showOrder` boolean (same file, line ~76-81): visible when the current user owns * the order (`order->customer->user_id === get_current_user_id()`), OR the current * user is an administrator (`manage_options`), OR the order's `payment_status` is * `paid`, OR the order was placed within the last two hours (a guest-checkout grace * window covering redirect/pending gateways, the same reasoning ReceiptHandler * documents). This is FluentCart's OWN security model for this exact route: knowledge * of the unguessable uuid is the real capability boundary, ownership/paid/recency are * additional narrowing so a stale, unpaid, someone-else's-cart uuid still hides. An * order that fails this gate is treated identically to "no order resolved": * `resolve()` returns null, never a partially filled object (task 1, "Ownership-check * waar een ingelogde gebruiker vereist is"). * 4. NEVER resolved during a Breakdance builder/SSR/dynamic-data request * (`ProductContext::isBuilderRequest()`, a read-only reference to that fork's own * public detection helper, not reimplemented here): the builder editor, iframe * preview, and dynamic-data-get requests always see null, so no real order data can * ever reach an editor-preview render or a dynamic-data payload (task 1, "geen * orderdata in editor-preview, logs of publieke output"). * 5. Memoized once per PHP request: a real lookup is genuine DB I/O behind two model * queries plus an ownership check, and a single receipt page can have several * OrderConditions callbacks plus the FC_Receipt element itself all wanting "the * current order" in the same request (task 1, "Memoized per request"). * * Explicitly NOT implemented here (task boundary): no write/mutation path, no order * listing/search, no exposure of the raw order id or uuid to any public output -- * every accessor below returns a short fixed-vocabulary string or a boolean, never the * order object's id/uuid/customer PII. `resolve()` itself does return the FluentCart * order object (FC_Receipt's live render needs it to delegate to FluentCart's own * `fluent_cart_receipt` shortcode, which re-resolves the SAME order from the SAME * request parameters independently), but no caller in this fork ever echoes an id off * of it; see FC_Receipt/ssr.php. */ final class OrderContext { private static bool $resolved = false; /** @var object|null */ private static $order = null; /** * @return object|null The resolved, ownership-gated FluentCart order, or null when * no order is resolvable, the visitor may not see it, or this is a builder/ * SSR/dynamic-data request. */ public static function resolve() { if (self::$resolved) { return self::$order; } self::$resolved = true; self::$order = self::resolveUncached(); return self::$order; } /** * Resets memoization. Test-only: a real HTTP request resolves exactly one order, so * production code has no legitimate reason to call this. */ public static function resetForTests(): void { self::$resolved = false; self::$order = null; } private static function resolveUncached() { if (self::isBuilderRequest()) { return null; } $orderHash = self::requestParam('order_hash'); $transactionHash = self::requestParam('trx_hash'); if ($orderHash === '' && $transactionHash === '') { return null; } $order = $orderHash !== '' ? self::findOrderByUuid($orderHash) : self::findOrderByTransactionUuid($transactionHash); if (!$order) { return null; } return self::isVisible($order) ? $order : null; } /** @return object|null */ private static function findOrderByUuid(string $uuid) { if (!class_exists('FluentCart\\Api\\Orders')) { return null; } try { return (new \FluentCart\Api\Orders())->getBy('uuid', $uuid); } catch (\Throwable $error) { return null; } } /** @return object|null */ private static function findOrderByTransactionUuid(string $uuid) { if (!class_exists('FluentCart\\App\\Models\\OrderTransaction') || !class_exists('FluentCart\\Api\\Orders')) { return null; } try { $transaction = \FluentCart\App\Models\OrderTransaction::query()->where('uuid', $uuid)->first(); if (!$transaction) { return null; } $orderId = is_object($transaction) ? ($transaction->order_id ?? 0) : 0; if ((int) $orderId < 1) { return null; } return (new \FluentCart\Api\Orders())->getById((int) $orderId); } catch (\Throwable $error) { return null; } } /** * Mirrors ReceiptHandler's own `$showOrder` boolean exactly (see class docblock * point 3). `$order` is untyped: a real FluentCart Order model instance in * production, a plain stdClass/array-like fixture in tests/account/run-account.php. * * @param mixed $order */ private static function isVisible($order): bool { $userId = function_exists('get_current_user_id') ? (int) get_current_user_id() : 0; $customerUserId = 0; $customer = is_object($order) ? ($order->customer ?? null) : null; if (is_object($customer) && isset($customer->user_id)) { $customerUserId = (int) $customer->user_id; } $isOwn = $userId > 0 && $customerUserId > 0 && $userId === $customerUserId; $isAdmin = function_exists('is_user_logged_in') && is_user_logged_in() && function_exists('current_user_can') && current_user_can('manage_options'); $paidStatus = class_exists('FluentCart\\App\\Helpers\\Status') ? \FluentCart\App\Helpers\Status::PAYMENT_PAID : 'paid'; $isPaid = self::paymentStatusOf($order) === $paidStatus; return $isOwn || $isAdmin || $isPaid || self::withinGuestGracePeriod($order); } /** * @param mixed $order */ private static function withinGuestGracePeriod($order): bool { $createdAt = is_object($order) ? ($order->created_at ?? '') : ''; if (!$createdAt) { return false; } $createdTimestamp = self::toGmtTimestamp((string) $createdAt); if ($createdTimestamp < 1) { return false; } $now = function_exists('current_time') ? (int) current_time('timestamp', true) : time(); // Two hours, matching ReceiptHandler's own `addHours(2)` window exactly. return ($now - $createdTimestamp) < (2 * 3600); } /** * Converts a `created_at` DB value to a GMT unix timestamp, preferring FluentCart's * own converter (guarded, not yet a Contracts.php-checked capability -- flagged as a * follow-up contract entry in this fork's final report) and falling back to a * documented approximate `strtotime(... . ' UTC')` read when unavailable. */ private static function toGmtTimestamp(string $value): int { if (class_exists('FluentCart\\App\\Services\\DateTime\\DateTime') && method_exists('FluentCart\\App\\Services\\DateTime\\DateTime', 'anytimeToGmt')) { try { $gmt = \FluentCart\App\Services\DateTime\DateTime::anytimeToGmt($value); if (is_object($gmt) && method_exists($gmt, 'getTimestamp')) { return (int) $gmt->getTimestamp(); } if (is_string($gmt) && $gmt !== '') { $timestamp = strtotime($gmt . ' UTC'); if ($timestamp !== false) { return $timestamp; } } } catch (\Throwable $error) { // Fall through to the documented approximate fallback below. } } $timestamp = strtotime($value . ' UTC'); return $timestamp !== false ? $timestamp : 0; } /** @param mixed $order */ private static function paymentStatusOf($order): string { return is_object($order) ? (string) ($order->payment_status ?? '') : ''; } /** * The resolved order's own lifecycle `status` column (Status::ORDER_PROCESSING/ * ORDER_COMPLETED/ORDER_ON_HOLD/ORDER_CANCELED/ORDER_FAILED). Never the payment * status; see paymentStatus() for that. */ public static function status(): string { $order = self::resolve(); return $order ? (string) ($order->status ?? '') : ''; } /** The resolved order's own `payment_status` column. */ public static function paymentStatus(): string { $order = self::resolve(); return $order ? self::paymentStatusOf($order) : ''; } /** The resolved order's own `payment_method` gateway slug (e.g. stripe/paypal/mollie/manual). */ public static function paymentMethod(): string { $order = self::resolve(); return $order ? (string) ($order->payment_method ?? '') : ''; } /** * Whether the resolved order contains a given `fluent-products` post id, read off * `fct_order_items.post_id` (OrderItem model, verified: `app/Models/OrderItem.php` * fillable list includes `post_id`; `Order::order_items()` is a real `HasMany` * relation on that table). Never returns true/false for a NOT-visible order (an * unresolved OrderContext already means "false" for every accessor here, matching * the "no data leaks even as a boolean" rule). */ public static function containsProduct(int $productId): bool { if ($productId < 1) { return false; } $order = self::resolve(); if (!$order) { return false; } foreach (self::orderItems($order) as $item) { $itemProductId = is_object($item) ? ($item->post_id ?? null) : (is_array($item) ? ($item['post_id'] ?? null) : null); if ($itemProductId !== null && (int) $itemProductId === $productId) { return true; } } return false; } /** * @param mixed $order * @return array */ private static function orderItems($order): array { if (is_object($order) && method_exists($order, 'loadMissing')) { try { $order->loadMissing(['order_items']); } catch (\Throwable $error) { // Guarded degradation: an inability to eager-load simply yields no // items below (containsProduct() returns false), never a fatal. } } $items = is_object($order) ? ($order->order_items ?? []) : []; if (!is_iterable($items)) { return []; } $result = []; foreach ($items as $item) { $result[] = $item; } return $result; } /** * Read-only reference to ProductContext's own public builder-request detector (WP4 * ownership, not reimplemented here): a Breakdance builder-editor, SSR, or * dynamic-data request must never resolve a real order (class docblock point 4). */ public static function isBuilderRequest(): bool { return class_exists('FluentCartBreakdance\\Context\\ProductContext') && \FluentCartBreakdance\Context\ProductContext::isBuilderRequest(); } /** * Sanitized read of a single GET parameter. Deliberately reads the superglobal * directly rather than FluentCart's own `App::request()` helper, so OrderContext has * no runtime dependency on that internal beyond the two model lookups already * pinned in Contracts.php. */ private static function requestParam(string $name): string { // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput -- Read-only receipt lookup parameters; the value is unslashed and sanitized right below and nothing is changed. if (!isset($_GET[$name]) || !is_scalar($_GET[$name])) { return ''; } $raw = (string) $_GET[$name]; // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput $unslashed = function_exists('wp_unslash') ? wp_unslash($raw) : $raw; return function_exists('sanitize_text_field') ? sanitize_text_field($unslashed) : trim($unslashed); } }