소스 검색

1.5.0 phase 1: batch-token transport for multi-upload

- DB: add batch_token VARCHAR(32) + KEY to wp_studiou_fpp_files.
  New get_batch_files()/count_batch_files() queries scoped to
  (batch_token, order_id=0 [, product_id]).
- DB: dbDelta runs automatically when studiou_wcfpp_db_version is
  older than STUDIOU_WCFPP_VERSION (maybe_upgrade_db on load).
- Upload: introduce studiou_fpp_batch cookie — single opaque token
  minted on first upload, reused by subsequent uploads. Handler now
  resolves/mints the token, writes it into the new record, and
  enforces the per-product max-uploads cap before accepting chunk 0.
  Legacy studiou_fpp_att_id/rec_id cookies kept as read-fallback
  for one release.
- Product: new _studiou_fpp_max_uploads meta + admin field ("Max
  Uploads", 0 = unlimited). get_product_max_uploads() helper.
- Single Product: new resolve_batch_files() helper returning the
  list of this visitor's unbound uploads, optionally scoped to a
  product. resolve_uploaded_file() remains as the single-file
  shortcode path.

Version bumped to 1.5.0 so the db upgrade fires on next load.
This is a pure-infrastructure phase — no user-visible change yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dalibor Votruba 2 달 전
부모
커밋
78a24b3032

+ 60 - 2
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-db.php

@@ -61,6 +61,7 @@ class Studiou_WC_FPP_DB {
             attachment_id bigint(20) unsigned NOT NULL,
             customer_id bigint(20) unsigned DEFAULT 0,
             session_key varchar(64) DEFAULT '',
+            batch_token varchar(32) NOT NULL DEFAULT '',
             file_name varchar(255) NOT NULL,
             status varchar(20) NOT NULL DEFAULT 'ready',
             created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -70,7 +71,8 @@ class Studiou_WC_FPP_DB {
             KEY order_id (order_id),
             KEY attachment_id (attachment_id),
             KEY status (status),
-            KEY customer_id (customer_id)
+            KEY customer_id (customer_id),
+            KEY batch_token (batch_token)
         ) {$charset_collate};";
 
         require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
@@ -90,6 +92,7 @@ class Studiou_WC_FPP_DB {
             'attachment_id' => 0,
             'customer_id'   => get_current_user_id(),
             'session_key'   => '',
+            'batch_token'   => '',
             'file_name'     => '',
             'status'        => 'ready',
             'created_at'    => current_time('mysql'),
@@ -101,7 +104,7 @@ class Studiou_WC_FPP_DB {
         $result = $wpdb->insert(
             $this->get_table_name(),
             $data,
-            array('%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s')
+            array('%d', '%d', '%d', '%d', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s')
         );
 
         if ($result === false) {
@@ -261,6 +264,61 @@ class Studiou_WC_FPP_DB {
         return $wpdb->get_results($sql);
     }
 
+    /**
+     * Return all file records belonging to a batch token that are not yet bound to an order.
+     *
+     * @param string $batch_token
+     * @param int    $product_id  Optional — restrict to a single product_id.
+     * @return array of row objects, oldest first (ascending id).
+     */
+    public function get_batch_files($batch_token, $product_id = 0) {
+        global $wpdb;
+        if (empty($batch_token)) {
+            return array();
+        }
+        $table = $this->get_table_name();
+
+        if ((int) $product_id > 0) {
+            $sql = $wpdb->prepare(
+                "SELECT * FROM {$table}
+                 WHERE batch_token = %s
+                   AND order_id = 0
+                   AND product_id = %d
+                 ORDER BY id ASC",
+                $batch_token,
+                (int) $product_id
+            );
+        } else {
+            $sql = $wpdb->prepare(
+                "SELECT * FROM {$table}
+                 WHERE batch_token = %s
+                   AND order_id = 0
+                 ORDER BY id ASC",
+                $batch_token
+            );
+        }
+        return $wpdb->get_results($sql);
+    }
+
+    /**
+     * Count batch files for a product (enforces per-product max-uploads cap).
+     */
+    public function count_batch_files($batch_token, $product_id) {
+        global $wpdb;
+        if (empty($batch_token) || (int) $product_id <= 0) {
+            return 0;
+        }
+        $table = $this->get_table_name();
+        return (int) $wpdb->get_var($wpdb->prepare(
+            "SELECT COUNT(*) FROM {$table}
+             WHERE batch_token = %s
+               AND order_id = 0
+               AND product_id = %d",
+            $batch_token,
+            (int) $product_id
+        ));
+    }
+
     public function link_file_to_order($file_record_id, $order_id, $order_item_id) {
         return $this->update_file_record($file_record_id, array(
             'order_id'      => $order_id,

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

@@ -32,6 +32,7 @@ class Studiou_WC_FPP_Product {
         $enabled = get_post_meta($product_id, '_studiou_fpp_enabled', true);
         $media_category = get_post_meta($product_id, '_studiou_fpp_media_category', true);
         $max_file_size = get_post_meta($product_id, '_studiou_fpp_max_file_size', true);
+        $max_uploads = get_post_meta($product_id, '_studiou_fpp_max_uploads', true);
         $upload_info_text = get_post_meta($product_id, '_studiou_fpp_upload_info_text', true);
         $min_width = get_post_meta($product_id, '_studiou_fpp_min_width', true);
         $min_height = get_post_meta($product_id, '_studiou_fpp_min_height', true);
@@ -102,6 +103,18 @@ class Studiou_WC_FPP_Product {
                     ),
                     'value'             => $max_file_size,
                 ));
+                woocommerce_wp_text_input(array(
+                    'id'                => '_studiou_fpp_max_uploads',
+                    'label'             => __('Max Uploads', 'studiou-wc-free-photo-product'),
+                    'description'       => __('Maximum number of photos a customer can upload for this product in a single visit. Leave empty or 0 for unlimited.', 'studiou-wc-free-photo-product'),
+                    'desc_tip'          => true,
+                    'type'              => 'number',
+                    'custom_attributes' => array(
+                        'step' => '1',
+                        'min'  => '0',
+                    ),
+                    'value'             => $max_uploads,
+                ));
                 ?>
             </div>
 
@@ -165,6 +178,9 @@ class Studiou_WC_FPP_Product {
             update_post_meta($product_id, '_studiou_fpp_max_file_size', $max_size);
         }
 
+        $max_uploads = isset($_POST['_studiou_fpp_max_uploads']) ? absint($_POST['_studiou_fpp_max_uploads']) : 0;
+        update_post_meta($product_id, '_studiou_fpp_max_uploads', $max_uploads);
+
         if (isset($_POST['_studiou_fpp_upload_info_text'])) {
             update_post_meta($product_id, '_studiou_fpp_upload_info_text', sanitize_textarea_field($_POST['_studiou_fpp_upload_info_text']));
         }
@@ -212,6 +228,14 @@ class Studiou_WC_FPP_Product {
         return !empty($size) ? (int) $size : 50;
     }
 
+    /**
+     * Max simultaneous uploads per batch for this product. 0 = unlimited.
+     */
+    public static function get_product_max_uploads($product_id) {
+        $val = get_post_meta($product_id, '_studiou_fpp_max_uploads', true);
+        return (int) $val;
+    }
+
     public static function get_product_upload_info_text($product_id) {
         return get_post_meta($product_id, '_studiou_fpp_upload_info_text', true);
     }

+ 17 - 6
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-single-product.php

@@ -104,13 +104,24 @@ class Studiou_WC_FPP_Single_Product {
     }
 
     /**
-     * Resolve the currently "attached" upload for this visitor, checking WC session first
-     * and falling back to a first-party cookie written at upload time.
+     * Return all upload records belonging to the current visitor's batch
+     * that are not yet attached to an order, optionally scoped to a product.
      *
-     * 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<int,object>  list of DB rows, oldest first (ascending id)
+     */
+    public static function resolve_batch_files(Studiou_WC_FPP_DB $db, $product_id = 0) {
+        $token = Studiou_WC_FPP_Upload::get_batch_token();
+        if (empty($token)) {
+            return array();
+        }
+        return $db->get_batch_files($token, (int) $product_id);
+    }
+
+    /**
+     * Resolve the currently "attached" upload for this visitor (legacy single-file path).
+     * Checks WC session first, then legacy cookie pair, and finally the first file in the
+     * current batch as a last-resort fallback. Used only by the shortcode flow on 1.5+ —
+     * the product-detail page uses resolve_batch_files() and pre-stamped file_record_id.
      *
      * @return array{attachment_id:int,file_record_id:int,file_name:string,thumb_url:string}|null
      */

+ 59 - 4
studiou-wc-free-photo-product/includes/class-studiou-wc-fpp-upload.php

@@ -82,6 +82,25 @@ class Studiou_WC_FPP_Upload {
             return;
         }
 
+        // Resolve/mint batch token — a stable per-visitor identifier that survives classic
+        // cookie <-> Store API Cart-Token session splits. Write it to the cookie once.
+        $batch_token = self::resolve_or_mint_batch_token();
+
+        // Enforce per-product max-uploads cap on the first chunk of a new file.
+        if ($chunk_index === 0) {
+            $max_uploads = Studiou_WC_FPP_Product::get_product_max_uploads($product_id);
+            if ($max_uploads > 0) {
+                $current_count = $this->db->count_batch_files($batch_token, $product_id);
+                if ($current_count >= $max_uploads) {
+                    wp_send_json_error(array('message' => sprintf(
+                        __('Maximum number of uploads reached (%d). Remove a photo before uploading another.', 'studiou-wc-free-photo-product'),
+                        $max_uploads
+                    )));
+                    return;
+                }
+            }
+        }
+
         $chunks_dir = $this->get_chunks_dir();
         if (!file_exists($chunks_dir)) {
             wp_mkdir_p($chunks_dir);
@@ -101,7 +120,7 @@ class Studiou_WC_FPP_Upload {
                 ob_end_clean();
             }
 
-            $result = $this->assemble_chunks($upload_id, $total_chunks, $file_name, $product_id);
+            $result = $this->assemble_chunks($upload_id, $total_chunks, $file_name, $product_id, $batch_token);
 
             // Clean again before sending JSON
             while (ob_get_level()) {
@@ -145,7 +164,7 @@ class Studiou_WC_FPP_Upload {
         ));
     }
 
-    private function assemble_chunks($upload_id, $total_chunks, $file_name, $product_id) {
+    private function assemble_chunks($upload_id, $total_chunks, $file_name, $product_id, $batch_token = '') {
         $chunks_dir = $this->get_chunks_dir();
         $upload_dir = wp_upload_dir();
 
@@ -283,6 +302,7 @@ class Studiou_WC_FPP_Upload {
             'attachment_id' => $attachment_id,
             'customer_id'   => get_current_user_id(),
             'session_key'   => $session_key,
+            'batch_token'   => $batch_token,
             'file_name'     => $file_name,
         ));
 
@@ -406,8 +426,43 @@ class Studiou_WC_FPP_Upload {
         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';
+    const COOKIE_BATCH      = 'studiou_fpp_batch';
+    const COOKIE_ATTACHMENT = 'studiou_fpp_att_id';  // legacy — read-fallback only
+    const COOKIE_RECORD     = 'studiou_fpp_rec_id';  // legacy — read-fallback only
+
+    /**
+     * Return the current visitor's batch token, minting + persisting a new one if absent.
+     * Reads / writes the studiou_fpp_batch cookie.
+     */
+    public static function resolve_or_mint_batch_token() {
+        if (!empty($_COOKIE[self::COOKIE_BATCH])) {
+            $raw = preg_replace('/[^A-Za-z0-9]/', '', (string) $_COOKIE[self::COOKIE_BATCH]);
+            if (strlen($raw) >= 16 && strlen($raw) <= 64) {
+                return $raw;
+            }
+        }
+        $token = wp_generate_password(32, false);
+        $options = self::cookie_options(time() + DAY_IN_SECONDS);
+        @setcookie(self::COOKIE_BATCH, $token, $options);
+        $_COOKIE[self::COOKIE_BATCH] = $token;
+        return $token;
+    }
+
+    public static function get_batch_token() {
+        if (!empty($_COOKIE[self::COOKIE_BATCH])) {
+            $raw = preg_replace('/[^A-Za-z0-9]/', '', (string) $_COOKIE[self::COOKIE_BATCH]);
+            if (strlen($raw) >= 16 && strlen($raw) <= 64) {
+                return $raw;
+            }
+        }
+        return '';
+    }
+
+    public static function clear_batch_cookie() {
+        $options = self::cookie_options(time() - DAY_IN_SECONDS);
+        @setcookie(self::COOKIE_BATCH, '', $options);
+        unset($_COOKIE[self::COOKIE_BATCH]);
+    }
 
     public static function set_upload_cookies($attachment_id, $file_record_id) {
         $attachment_id  = (int) $attachment_id;

+ 14 - 2
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.6
+ * Version: 1.5.0
  * 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.6');
+if (!defined('STUDIOU_WCFPP_VERSION')) define('STUDIOU_WCFPP_VERSION', '1.5.0');
 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__));
@@ -77,6 +77,7 @@ class Studiou_WC_Free_Photo_Product {
 
     private function init_components() {
         $this->db = new Studiou_WC_FPP_DB();
+        $this->maybe_upgrade_db();
         $this->product = new Studiou_WC_FPP_Product();
         $this->upload = new Studiou_WC_FPP_Upload($this->db);
         $this->shortcode = new Studiou_WC_FPP_Shortcode();
@@ -86,6 +87,17 @@ class Studiou_WC_Free_Photo_Product {
         $this->pricing = new Studiou_WC_FPP_Pricing();
     }
 
+    /**
+     * Run dbDelta when the installed schema is older than the current plugin version.
+     * dbDelta handles adding the batch_token column and its KEY on existing installs.
+     */
+    private function maybe_upgrade_db() {
+        $installed = get_option('studiou_wcfpp_db_version', '');
+        if (version_compare((string) $installed, STUDIOU_WCFPP_VERSION, '<')) {
+            $this->db->create_tables();
+        }
+    }
+
     public function check_required_plugins() {
         if (!class_exists('WooCommerce')) {
             add_action('admin_notices', function () {