Преглед изворни кода

Fix add-to-cart blocked after upload with PVT 1.9.3 + WC 10.7.0 — v1.3.4 -> v1.3.5

Root cause: PVT 1.9.3 / WC 10.7.0 now routes Add to Cart through the WC
Store API, which identifies its session via the Cart-Token header rather
than the classic wp_woocommerce_session_* cookie. The session the upload
writes to and the session the add-to-cart request reads from are therefore
two different rows in wp_woocommerce_sessions, so the attachment_id set at
upload time is invisible during validation — triggering the "Please upload
a photo before adding to cart" error even immediately after a successful
upload (and reproducible in a fresh incognito window).

Fix: stop relying solely on WC session as the transport. On the final
chunk, also write a pair of first-party HttpOnly cookies
(studiou_fpp_att_id / studiou_fpp_rec_id) alongside the session write.
Introduce Studiou_WC_FPP_Single_Product::resolve_uploaded_file() which
reads WC session first and falls back to the cookies, then verifies the
referenced wp_studiou_fpp_files row matches and is not yet attached to
an order. validate_add_to_cart, add_cart_item_data, and render_upload_area
all go through the same resolver, so Add to Cart now works regardless of
whether PVT goes through admin-ajax or Store API. Cookies are cleared on
remove alongside the session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dalibor Votruba пре 2 месеци
родитељ
комит
3aa5b6967a

+ 3 - 3
studiou-wc-free-photo-product/CLAUDE.md

@@ -2,7 +2,7 @@
 
 ## Project Overview
 
-WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.3.4.
+WordPress/WooCommerce plugin called **studiou-wc-free-photo-product** (QDR - Studiou WC Free Photo Product) v1.3.5.
 Allows publishing special variable products where customers upload custom raw images to be printed for a price.
 
 ## Initial Requirements
@@ -36,8 +36,8 @@ Allows publishing special variable products where customers upload custom raw im
 
 - `Studiou_WC_FPP_DB` - Custom table CRUD, media category taxonomy
 - `Studiou_WC_FPP_Product` - WC product data tab, product meta
-- `Studiou_WC_FPP_Upload` - Chunked upload AJAX, file assembly, attachment creation
-- `Studiou_WC_FPP_Single_Product` - Upload area on product page (`woocommerce_single_product_summary` priority 35), WC session storage, server-side validation (`woocommerce_add_to_cart_validation` priority 1) + safety net (`woocommerce_add_to_cart` removes items without photo)
+- `Studiou_WC_FPP_Upload` - Chunked upload AJAX, file assembly, attachment creation. On the final chunk also writes a first-party HttpOnly cookie pair (`studiou_fpp_att_id` / `studiou_fpp_rec_id`) in addition to WC session — this is the transport that survives the split between classic `wp_woocommerce_session_*` cookies and the WC Store API's Cart-Token (pvtfw 1.9.3+ / WC 10.7.0+ route Add to Cart through Store API, which sees a different empty session).
+- `Studiou_WC_FPP_Single_Product` - Upload area on product page (`woocommerce_single_product_summary` priority 35), WC session storage, server-side validation (`woocommerce_add_to_cart_validation` priority 1) + safety net (`woocommerce_add_to_cart` removes items without photo). Exposes the shared `resolve_uploaded_file(Studiou_WC_FPP_DB $db)` helper that reads WC session first, then falls back to the upload cookies, and verifies the record is unattached to an order.
 - `Studiou_WC_FPP_Shortcode` - `[studiou_free_photo_product id="X"]` renders product with WC native variation form
 - `Studiou_WC_FPP_Cart` - Reads file data from WC session via `woocommerce_add_cart_item_data`, replaces cart/order item thumbnails (classic cart via `woocommerce_cart_item_thumbnail`, block cart via `woocommerce_product_get_image_id`/`woocommerce_product_variation_get_image_id`), order item meta, order linking
 - `Studiou_WC_FPP_Admin` - Summary page with filters/pagination, status management, ZIP download, file download with auto-status-change, bulk actions with confirmation dialogs

+ 10 - 23
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-cart.php

@@ -54,38 +54,25 @@ class Studiou_WC_FPP_Cart {
             }
         }
 
-        // Read file data from WC session
-        if (!WC()->session) {
+        // Resolve upload reference from WC session (preferred) or first-party cookie
+        // (fallback for Store API / Cart-Token add-to-cart paths used by pvtfw 1.9.3+).
+        $resolved = Studiou_WC_FPP_Single_Product::resolve_uploaded_file($this->db);
+        if (!$resolved) {
             return $cart_item_data;
         }
 
-        $attachment_id = WC()->session->get('studiou_fpp_attachment_id');
-        $file_record_id = WC()->session->get('studiou_fpp_file_record_id');
-        $file_name = WC()->session->get('studiou_fpp_file_name');
-        $thumb_url = WC()->session->get('studiou_fpp_thumb_url');
-
-        if (!$attachment_id || !$file_record_id) {
-            return $cart_item_data;
-        }
-
-        // Verify the file record exists
-        $record = $this->db->get_file_record($file_record_id);
-        if (!$record || (int) $record->attachment_id !== (int) $attachment_id) {
-            return $cart_item_data;
-        }
-
-        $cart_item_data['studiou_fpp_attachment_id'] = $attachment_id;
-        $cart_item_data['studiou_fpp_file_record_id'] = $file_record_id;
-        $cart_item_data['studiou_fpp_file_name'] = $file_name ?: $record->file_name;
-        $cart_item_data['studiou_fpp_thumb_url'] = $thumb_url ?: '';
+        $cart_item_data['studiou_fpp_attachment_id']  = (int) $resolved['attachment_id'];
+        $cart_item_data['studiou_fpp_file_record_id'] = (int) $resolved['file_record_id'];
+        $cart_item_data['studiou_fpp_file_name']      = $resolved['file_name'];
+        $cart_item_data['studiou_fpp_thumb_url']      = $resolved['thumb_url'];
 
         // Update file record with variation
         $actual_variation_id = $variation_id ?: $product_id;
-        $this->db->update_file_record($file_record_id, array(
+        $this->db->update_file_record((int) $resolved['file_record_id'], array(
             'variation_id' => $actual_variation_id,
         ));
 
-        // Keep session data so the same photo can be used for multiple variants
+        // Keep session / cookie intact so the same photo can be used for multiple variants
         return $cart_item_data;
     }
 

+ 98 - 22
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-single-product.php

@@ -5,7 +5,12 @@ if (!defined('WPINC')) {
 
 class Studiou_WC_FPP_Single_Product {
 
-    public function __construct() {
+    /** @var Studiou_WC_FPP_DB */
+    private $db;
+
+    public function __construct($db) {
+        $this->db = $db;
+
         // Render upload area below the variation table on single product page
         add_action('woocommerce_single_product_summary', array($this, 'render_upload_area'), 35);
 
@@ -34,21 +39,20 @@ class Studiou_WC_FPP_Single_Product {
         $product_id = $product->get_id();
         $max_file_size = Studiou_WC_FPP_Product::get_product_max_file_size($product_id);
 
-        // Pass existing session state to JS for restore on page load
+        // Pass existing state (WC session or cookie fallback) to JS for restore on page load
         $session_data = array();
-        if (WC()->session) {
-            $att_id = WC()->session->get('studiou_fpp_attachment_id');
-            if ($att_id) {
-                $thumb = wp_get_attachment_image_src($att_id, 'thumbnail');
-                $preview = wp_get_attachment_image_src($att_id, 'woocommerce_single');
-                $session_data = array(
-                    'attachment_id'  => $att_id,
-                    'file_record_id' => WC()->session->get('studiou_fpp_file_record_id'),
-                    'file_name'      => WC()->session->get('studiou_fpp_file_name'),
-                    'thumbnail_url'  => $thumb ? $thumb[0] : '',
-                    'preview_url'    => $preview ? $preview[0] : ($thumb ? $thumb[0] : ''),
-                );
-            }
+        $resolved = self::resolve_uploaded_file($this->db);
+        if ($resolved) {
+            $att_id  = (int) $resolved['attachment_id'];
+            $thumb   = wp_get_attachment_image_src($att_id, 'thumbnail');
+            $preview = wp_get_attachment_image_src($att_id, 'woocommerce_single');
+            $session_data = array(
+                'attachment_id'  => $att_id,
+                'file_record_id' => (int) $resolved['file_record_id'],
+                'file_name'      => $resolved['file_name'],
+                'thumbnail_url'  => $thumb ? $thumb[0] : '',
+                'preview_url'    => $preview ? $preview[0] : ($thumb ? $thumb[0] : ''),
+            );
         }
 
         wp_localize_script('studiou-wcfpp-frontend', 'studiouWcfppSession', $session_data);
@@ -83,7 +87,7 @@ class Studiou_WC_FPP_Single_Product {
     }
 
     /**
-     * Validate before add-to-cart: block if FPP product and no photo in session.
+     * Validate before add-to-cart: block if FPP product and no photo reference resolvable.
      */
     public function validate_add_to_cart($passed, $product_id, $quantity, $variation_id = 0, $variations = array(), $cart_item_data = array()) {
         $fpp_id = $this->resolve_fpp_product_id($product_id, $variation_id);
@@ -91,18 +95,87 @@ class Studiou_WC_FPP_Single_Product {
             return $passed;
         }
 
-        // Check WC session for uploaded file
-        if (WC()->session) {
-            $attachment_id = WC()->session->get('studiou_fpp_attachment_id');
-            if ($attachment_id) {
-                return $passed;
-            }
+        if (self::resolve_uploaded_file($this->db)) {
+            return $passed;
         }
 
         wc_add_notice(__('Please upload a photo before adding to cart.', 'studiou-wc-free-photo-product'), 'error');
         return false;
     }
 
+    /**
+     * Resolve the currently "attached" upload for this visitor, checking WC session first
+     * and falling back to a first-party cookie written at upload time.
+     *
+     * The cookie fallback is required because pvtfw 1.9.3+ / WC 10.7.0+ may route
+     * Add to Cart through the WC Store API, which identifies the session via the
+     * Cart-Token header rather than the classic wp_woocommerce_session_* cookie —
+     * that path sees a different (empty) session than the one the upload wrote to.
+     *
+     * @return array{attachment_id:int,file_record_id:int,file_name:string,thumb_url:string}|null
+     */
+    public static function resolve_uploaded_file(Studiou_WC_FPP_DB $db) {
+        $attachment_id  = 0;
+        $file_record_id = 0;
+        $file_name      = '';
+        $thumb_url      = '';
+
+        if (function_exists('WC') && WC()->session) {
+            $attachment_id  = (int) WC()->session->get('studiou_fpp_attachment_id');
+            $file_record_id = (int) WC()->session->get('studiou_fpp_file_record_id');
+            $file_name      = (string) WC()->session->get('studiou_fpp_file_name');
+            $thumb_url      = (string) WC()->session->get('studiou_fpp_thumb_url');
+        }
+
+        if ($attachment_id <= 0 || $file_record_id <= 0) {
+            $cookie_att = isset($_COOKIE[Studiou_WC_FPP_Upload::COOKIE_ATTACHMENT])
+                ? (int) $_COOKIE[Studiou_WC_FPP_Upload::COOKIE_ATTACHMENT] : 0;
+            $cookie_rec = isset($_COOKIE[Studiou_WC_FPP_Upload::COOKIE_RECORD])
+                ? (int) $_COOKIE[Studiou_WC_FPP_Upload::COOKIE_RECORD] : 0;
+            if ($cookie_att > 0 && $cookie_rec > 0) {
+                $attachment_id  = $cookie_att;
+                $file_record_id = $cookie_rec;
+                $file_name      = '';
+                $thumb_url      = '';
+            }
+        }
+
+        if ($attachment_id <= 0 || $file_record_id <= 0) {
+            return null;
+        }
+
+        $record = $db->get_file_record($file_record_id);
+        if (!$record) {
+            return null;
+        }
+        if ((int) $record->attachment_id !== $attachment_id) {
+            return null;
+        }
+        // A record already attached to an order is no longer "in flight" for add-to-cart
+        if ((int) $record->order_id !== 0) {
+            return null;
+        }
+
+        if ($file_name === '') {
+            $file_name = (string) $record->file_name;
+        }
+        if ($thumb_url === '') {
+            $thumb = wp_get_attachment_image_src($attachment_id, 'thumbnail');
+            if ($thumb) {
+                $thumb_url = $thumb[0];
+            } else {
+                $thumb_url = (string) wp_get_attachment_url($attachment_id);
+            }
+        }
+
+        return array(
+            'attachment_id'  => $attachment_id,
+            'file_record_id' => $file_record_id,
+            'file_name'      => $file_name,
+            'thumb_url'      => $thumb_url,
+        );
+    }
+
     /**
      * Safety net: if an FPP item was added without photo data, remove it.
      * This catches cases where validation was somehow bypassed.
@@ -158,8 +231,11 @@ class Studiou_WC_FPP_Single_Product {
             WC()->session->set('studiou_fpp_attachment_id', null);
             WC()->session->set('studiou_fpp_file_record_id', null);
             WC()->session->set('studiou_fpp_file_name', null);
+            WC()->session->set('studiou_fpp_thumb_url', null);
         }
 
+        Studiou_WC_FPP_Upload::clear_upload_cookies();
+
         wp_send_json_success();
     }
 }

+ 44 - 0
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-upload.php

@@ -123,6 +123,11 @@ class Studiou_WC_FPP_Upload {
                 WC()->session->set('studiou_fpp_thumb_url', $result['thumbnail_url']);
             }
 
+            // Also persist identifiers in a first-party cookie so the reference survives session
+            // paths that use a different identifier than the classic wp_woocommerce_session_* cookie
+            // (e.g. WC Store API / Cart-Token used by pvtfw 1.9.3+ add-to-cart).
+            self::set_upload_cookies($result['attachment_id'], $result['file_record_id']);
+
             wp_send_json_success(array(
                 'complete'       => true,
                 'attachment_id'  => $result['attachment_id'],
@@ -396,6 +401,45 @@ class Studiou_WC_FPP_Upload {
 
         $this->db->delete_file_record($file_record_id);
 
+        self::clear_upload_cookies();
+
         wp_send_json_success(array('message' => __('File removed.', 'studiou-wc-free-photo-product')));
     }
+
+    const COOKIE_ATTACHMENT = 'studiou_fpp_att_id';
+    const COOKIE_RECORD     = 'studiou_fpp_rec_id';
+
+    public static function set_upload_cookies($attachment_id, $file_record_id) {
+        $attachment_id  = (int) $attachment_id;
+        $file_record_id = (int) $file_record_id;
+        if ($attachment_id <= 0 || $file_record_id <= 0) {
+            return;
+        }
+
+        $options = self::cookie_options(time() + DAY_IN_SECONDS);
+        @setcookie(self::COOKIE_ATTACHMENT, (string) $attachment_id, $options);
+        @setcookie(self::COOKIE_RECORD, (string) $file_record_id, $options);
+        // Mirror into $_COOKIE so code running later in this request sees the value too
+        $_COOKIE[self::COOKIE_ATTACHMENT] = (string) $attachment_id;
+        $_COOKIE[self::COOKIE_RECORD]     = (string) $file_record_id;
+    }
+
+    public static function clear_upload_cookies() {
+        $options = self::cookie_options(time() - DAY_IN_SECONDS);
+        @setcookie(self::COOKIE_ATTACHMENT, '', $options);
+        @setcookie(self::COOKIE_RECORD, '', $options);
+        unset($_COOKIE[self::COOKIE_ATTACHMENT], $_COOKIE[self::COOKIE_RECORD]);
+    }
+
+    private static function cookie_options($expires) {
+        $path = defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/';
+        return array(
+            'expires'  => (int) $expires,
+            'path'     => $path,
+            'domain'   => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '',
+            'secure'   => is_ssl(),
+            'httponly' => true,
+            'samesite' => 'Lax',
+        );
+    }
 }

+ 1 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product-cs_CZ.po

@@ -3,7 +3,7 @@
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.4\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.5\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: 2026-04-02 00:00+0000\n"

+ 1 - 1
studiou-wc-free-photo-product/languages/studiou-wc-free-photo-product.pot

@@ -2,7 +2,7 @@
 # This file is distributed under the GPL v2 or later.
 msgid ""
 msgstr ""
-"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.4\n"
+"Project-Id-Version: QDR - Studiou WC Free Photo Product 1.3.5\n"
 "Report-Msgid-Bugs-To: https://www.quadarax.com\n"
 "POT-Creation-Date: 2026-04-02 00:00+0000\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"

+ 4 - 4
studiou-wc-free-photo-product/readme.md

@@ -39,7 +39,7 @@ When a variable product has Free Photo enabled, the upload area automatically ap
 4. If no photo is uploaded, the server blocks the add-to-cart and shows an error notice
 5. The uploaded photo stays — the same photo can be used for multiple variants without re-uploading
 
-The uploaded file reference is stored in the WooCommerce session, so it works with any add-to-cart method (form POST, AJAX buttons, individual variation buttons). The session persists across add-to-cart actions, allowing the same photo to be used for multiple variants. The session is only cleared when the user explicitly removes the uploaded photo.
+The uploaded file reference is stored in two parallel locations — the WooCommerce session **and** a pair of first-party HttpOnly cookies (`studiou_fpp_att_id` / `studiou_fpp_rec_id`) written server-side on the same request that assembles the file. The cookie pair is required because Product Variant Table for WooCommerce 1.9.3+ and WooCommerce 10.7.0+ route Add to Cart through the WC **Store API**, which identifies its session via the `Cart-Token` request header rather than the classic `wp_woocommerce_session_*` cookie — and that path sees a different, empty session than the one the upload wrote to. The cookies travel with *every* same-origin request (classic admin-ajax **and** Store API fetch calls), so whichever endpoint PVT / WC decide to use, the upload reference is reachable. Both the session and the cookies persist across add-to-cart actions, allowing the same photo to be used for multiple variants without re-uploading; both are cleared when the user explicitly removes the uploaded photo.
 
 ### Quantity Discount Tiers (per variation)
 
@@ -68,11 +68,11 @@ Rules:
 - Per-row price updates are **display-only**. The authoritative pricing happens server-side in `woocommerce_before_calculate_totals` (priority 20), which calls `set_price()` on the cart item's product instance. The pvtfw AJAX add-to-cart handler only forwards `product_id`, `variation_id`, and `quantity` — it does not send the displayed price — so there is no risk of client-side tampering.
 
 Add-to-cart is blocked server-side until a photo is uploaded:
-- **Validation**: `woocommerce_add_to_cart_validation` (priority 1) checks the WC session for uploaded file data and blocks with an error notice if missing.
+- **Validation**: `woocommerce_add_to_cart_validation` (priority 1) calls `Studiou_WC_FPP_Single_Product::resolve_uploaded_file()` which looks at the WC session first and then falls back to the `studiou_fpp_att_id` / `studiou_fpp_rec_id` cookies, verifies the referenced row in `wp_studiou_fpp_files` matches and is not yet attached to an order, and blocks with an error notice if nothing resolves.
 - **Safety net**: `woocommerce_add_to_cart` action removes any FPP cart item that was added without a photo (catches edge cases where validation was bypassed).
 - Product detection uses `resolve_fpp_product_id()` which checks the product itself, its parent, and the variation's parent to handle all add-to-cart methods.
 
-On page reload, existing session state is restored — the upload preview and product gallery image are reconstructed from the server-side session data. When the photo is removed, the original product image is restored. Add-to-cart buttons always look and behave normally — blocking is entirely server-side.
+On page reload, the previously uploaded file is restored — the upload preview and product gallery image are reconstructed from whichever transport currently carries the reference (WC session or the cookie pair). When the photo is removed, both are cleared and the original product image is restored. Add-to-cart buttons always look and behave normally — blocking is entirely server-side.
 
 ### Shortcode
 
@@ -121,7 +121,7 @@ Navigate to **Products > Free Photo Files** to manage uploaded files.
 
 ## Cart and Order Integration
 
-The plugin integrates with WooCommerce's native add-to-cart flow using WC session storage. When a photo is uploaded, the file reference is stored in the WC session via AJAX. When any add-to-cart button is clicked, `woocommerce_add_cart_item_data` reads the file data from the session and attaches it to the cart item. The session persists so the same photo can be reused for multiple variants. The `woocommerce_add_to_cart_validation` filter ensures a photo is uploaded before the item can be added. This approach works with any theme or plugin that handles variable product display (individual variation buttons, dropdowns, tables, etc.).
+The plugin integrates with WooCommerce's native add-to-cart flow using the `resolve_uploaded_file()` helper that reads WC session first and then the `studiou_fpp_att_id` / `studiou_fpp_rec_id` cookie pair as a fallback. When a photo is uploaded, the file reference is written to both transports in the same request that assembles the file. When any add-to-cart button is clicked — whether it goes through classic admin-ajax or the WC Store API — `woocommerce_add_cart_item_data` runs the same resolver and attaches the file data to the cart item. Both the session and the cookies persist so the same photo can be reused for multiple variants. The `woocommerce_add_to_cart_validation` filter uses the same resolver to ensure a photo is uploaded before the item can be added. This approach works with any theme or plugin that handles variable product display (individual variation buttons, dropdowns, tables, Store API block cart, etc.).
 
 When a customer adds a photo product to the cart:
 - The cart item thumbnail shows the uploaded photo — works with both **classic cart** (`woocommerce_cart_item_thumbnail` filter) and **block cart** (`woocommerce_product_get_image_id` / `woocommerce_product_variation_get_image_id` filters that override the product image for the Store API)

+ 3 - 3
studiou-wc-free-photo-product/studiou-wc-free-photo-product.php

@@ -3,7 +3,7 @@
  * Plugin Name: QDR - Studiou WC Free Photo Product
  * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-free-photo-product
  * Description: Allows publishing special WooCommerce products with variants and custom raw image upload for photo printing
- * Version: 1.3.4
+ * Version: 1.3.5
  * Requires at least: 6.9.4
  * Requires PHP: 8.2
  * Requires Plugins: woocommerce, product-variant-table-for-woocommerce
@@ -22,7 +22,7 @@ if (!defined('WPINC')) {
     die;
 }
 
-if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.3.4');
+if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.3.5');
 if (!defined('STUDIOU_WCFPP_PLUGIN_DIR')) define('STUDIOU_WCFPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_URL')) define('STUDIOU_WCFPP_PLUGIN_URL', plugin_dir_url(__FILE__));
 if (!defined('STUDIOU_WCFPP_PLUGIN_BASENAME')) define('STUDIOU_WCFPP_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -80,7 +80,7 @@ class Studiou_WC_Free_Photo_Product {
         $this->product = new Studiou_WC_FPP_Product();
         $this->upload = new Studiou_WC_FPP_Upload($this->db);
         $this->shortcode = new Studiou_WC_FPP_Shortcode();
-        $this->single_product = new Studiou_WC_FPP_Single_Product();
+        $this->single_product = new Studiou_WC_FPP_Single_Product($this->db);
         $this->cart = new Studiou_WC_FPP_Cart($this->db);
         $this->admin = new Studiou_WC_FPP_Admin($this->db);
         $this->pricing = new Studiou_WC_FPP_Pricing();