db = $db; add_action('wp_ajax_studiou_wcfpp_upload_chunk', array($this, 'handle_chunk_upload')); add_action('wp_ajax_nopriv_studiou_wcfpp_upload_chunk', array($this, 'handle_chunk_upload')); add_action('wp_ajax_studiou_wcfpp_remove_upload', array($this, 'handle_remove_upload')); add_action('wp_ajax_nopriv_studiou_wcfpp_remove_upload', array($this, 'handle_remove_upload')); } private function get_chunks_dir() { $upload_dir = wp_upload_dir(); return $upload_dir['basedir'] . '/studiou-fpp-chunks'; } private function get_allowed_mime_types() { return array( 'jpg|jpeg|jpe' => 'image/jpeg', 'png' => 'image/png', 'tiff|tif' => 'image/tiff', 'bmp' => 'image/bmp', 'psd' => 'image/vnd.adobe.photoshop', 'cr2' => 'image/x-canon-cr2', 'nef' => 'image/x-nikon-nef', 'arw' => 'image/x-sony-arw', 'dng' => 'image/x-adobe-dng', 'orf' => 'image/x-olympus-orf', 'rw2' => 'image/x-panasonic-rw2', 'raf' => 'image/x-fuji-raf', 'webp' => 'image/webp', ); } public function handle_chunk_upload() { // Clean output buffers while (ob_get_level()) { ob_end_clean(); } check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce'); $product_id = isset($_POST['product_id']) ? absint($_POST['product_id']) : 0; $upload_id = isset($_POST['upload_id']) ? sanitize_text_field($_POST['upload_id']) : ''; $chunk_index = isset($_POST['chunk_index']) ? absint($_POST['chunk_index']) : 0; $total_chunks = isset($_POST['total_chunks']) ? absint($_POST['total_chunks']) : 0; $file_name = isset($_POST['file_name']) ? sanitize_file_name($_POST['file_name']) : ''; $file_size = isset($_POST['file_size']) ? absint($_POST['file_size']) : 0; // Validate product if (!$product_id || !Studiou_WC_FPP_Product::is_fpp_product($product_id)) { wp_send_json_error(array('message' => __('Invalid product.', 'studiou-wc-free-photo-product'))); return; } // Validate file size $max_size = Studiou_WC_FPP_Product::get_product_max_file_size($product_id); if ($file_size > ($max_size * 1024 * 1024)) { wp_send_json_error(array('message' => sprintf( __('File is too large. Maximum size: %s MB', 'studiou-wc-free-photo-product'), $max_size ))); return; } // Validate upload_id format (should be alphanumeric) if (!preg_match('/^[a-zA-Z0-9_-]+$/', $upload_id)) { wp_send_json_error(array('message' => __('Invalid upload ID.', 'studiou-wc-free-photo-product'))); return; } // Validate chunk file exists if (!isset($_FILES['chunk']) || $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) { wp_send_json_error(array('message' => __('Chunk upload failed.', 'studiou-wc-free-photo-product'))); 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); } // Store the chunk $chunk_file = $chunks_dir . '/' . $upload_id . '_chunk_' . $chunk_index; if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $chunk_file)) { wp_send_json_error(array('message' => __('Failed to store chunk.', 'studiou-wc-free-photo-product'))); return; } // If this is the last chunk, assemble the file if ($chunk_index === $total_chunks - 1) { // Clean any output that may have been generated while (ob_get_level()) { ob_end_clean(); } $result = $this->assemble_chunks($upload_id, $total_chunks, $file_name, $product_id, $batch_token); // Clean again before sending JSON while (ob_get_level()) { ob_end_clean(); } if (is_wp_error($result)) { wp_send_json_error(array('message' => $result->get_error_message())); return; } // Since 1.5.3 the batch_token cookie (set by resolve_or_mint_batch_token above // and persisted in the DB row's batch_token column) is the sole transport — // no more WC-session writes, no more legacy per-upload cookies. wp_send_json_success(array( 'complete' => true, 'attachment_id' => $result['attachment_id'], 'file_record_id' => $result['file_record_id'], 'thumbnail_url' => $result['thumbnail_url'], 'preview_url' => $result['preview_url'], 'file_name' => $result['file_name'], )); return; } wp_send_json_success(array( 'complete' => false, 'chunk_index' => $chunk_index, )); } 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(); // Create a subdirectory for free photo uploads $fpp_dir = $upload_dir['path'] . '/free-photo'; if (!file_exists($fpp_dir)) { wp_mkdir_p($fpp_dir); } // Generate unique filename $ext = pathinfo($file_name, PATHINFO_EXTENSION); $base = sanitize_file_name(pathinfo($file_name, PATHINFO_FILENAME)); $unique_name = $base . '_' . uniqid() . '.' . $ext; $assembled_path = $fpp_dir . '/' . $unique_name; // Assemble chunks $output = fopen($assembled_path, 'wb'); if (!$output) { $this->cleanup_chunks($upload_id, $total_chunks); return new WP_Error('assemble_failed', __('Failed to create output file.', 'studiou-wc-free-photo-product')); } for ($i = 0; $i < $total_chunks; $i++) { $chunk_file = $chunks_dir . '/' . $upload_id . '_chunk_' . $i; if (!file_exists($chunk_file)) { fclose($output); unlink($assembled_path); $this->cleanup_chunks($upload_id, $total_chunks); return new WP_Error('chunk_missing', sprintf( __('Missing chunk %d.', 'studiou-wc-free-photo-product'), $i )); } $chunk_data = file_get_contents($chunk_file); fwrite($output, $chunk_data); } fclose($output); // Clean up chunk files $this->cleanup_chunks($upload_id, $total_chunks); // Validate file extension $allowed = $this->get_allowed_mime_types(); $ext_lower = strtolower($ext); $valid = false; foreach ($allowed as $exts => $mime) { $ext_list = explode('|', $exts); if (in_array($ext_lower, $ext_list)) { $valid = true; break; } } if (!$valid) { unlink($assembled_path); return new WP_Error('invalid_type', __('File type not allowed.', 'studiou-wc-free-photo-product')); } // Validate image resolution (if limits are set) $res_error = $this->validate_image_resolution($assembled_path, $product_id); if (is_wp_error($res_error)) { unlink($assembled_path); return $res_error; } // Create WP attachment $relative_path = str_replace($upload_dir['basedir'] . '/', '', $assembled_path); $filetype = wp_check_filetype($unique_name, $allowed); $attachment_data = array( 'post_mime_type' => $filetype['type'] ?: 'application/octet-stream', 'post_title' => sanitize_file_name($file_name), 'post_content' => '', 'post_status' => 'inherit', 'post_parent' => $product_id, ); $attachment_id = wp_insert_attachment($attachment_data, $assembled_path, $product_id); if (is_wp_error($attachment_id)) { unlink($assembled_path); return $attachment_id; } // Generate metadata (wrapped in output buffer to prevent stray output corrupting JSON) require_once(ABSPATH . 'wp-admin/includes/image.php'); ob_start(); try { @set_time_limit(120); $metadata = @wp_generate_attachment_metadata($attachment_id, $assembled_path); if (!empty($metadata)) { wp_update_attachment_metadata($attachment_id, $metadata); } } catch (\Throwable $e) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('STUDIOU FPP: metadata generation failed - ' . $e->getMessage()); } } ob_end_clean(); // Assign media category $media_cat_id = Studiou_WC_FPP_Product::get_product_media_category($product_id); if ($media_cat_id) { wp_set_object_terms($attachment_id, array((int) $media_cat_id), 'studiou_media_category'); } // Get thumbnail URL (small - for upload preview) $thumbnail_url = ''; $image_src = wp_get_attachment_image_src($attachment_id, 'thumbnail'); if ($image_src) { $thumbnail_url = $image_src[0]; } else { $thumbnail_url = wp_mime_type_icon($attachment_id); } // Get preview URL (larger - for product gallery replacement) $preview_url = ''; $preview_src = wp_get_attachment_image_src($attachment_id, 'woocommerce_single'); if ($preview_src) { $preview_url = $preview_src[0]; } elseif ($image_src) { $preview_url = $image_src[0]; } // Get session key for guests $session_key = ''; if (!is_user_logged_in()) { if (WC()->session) { $session_key = WC()->session->get_customer_id(); } } // Insert file record $file_record_id = $this->db->insert_file_record(array( 'product_id' => $product_id, 'attachment_id' => $attachment_id, 'customer_id' => get_current_user_id(), 'session_key' => $session_key, 'batch_token' => $batch_token, 'file_name' => $file_name, )); if (!$file_record_id) { wp_delete_attachment($attachment_id, true); return new WP_Error('record_failed', __('Failed to create file record.', 'studiou-wc-free-photo-product')); } return array( 'attachment_id' => $attachment_id, 'file_record_id' => $file_record_id, 'thumbnail_url' => $thumbnail_url, 'preview_url' => $preview_url, 'file_name' => $file_name, ); } /** * Validate image resolution against product limits. * Width/height are commutable — a 3000x2000 image matches both 3000x2000 and 2000x3000 limits. */ private function validate_image_resolution($file_path, $product_id) { $limits = Studiou_WC_FPP_Product::get_product_resolution_limits($product_id); // Skip if no limits set $has_min = ($limits['min_width'] > 0 || $limits['min_height'] > 0); $has_max = ($limits['max_width'] > 0 || $limits['max_height'] > 0); if (!$has_min && !$has_max) { return true; } // Get image dimensions $size = @getimagesize($file_path); if (!$size || !isset($size[0], $size[1])) { // Cannot determine dimensions (e.g. RAW files) — skip validation return true; } $img_w = $size[0]; $img_h = $size[1]; // Normalize: always compare min(w,h) vs min(limit_w,limit_h) and max(w,h) vs max(limit_w,limit_h) // This makes portrait/landscape interchangeable $img_short = min($img_w, $img_h); $img_long = max($img_w, $img_h); // Minimum resolution check if ($has_min) { $min_short = min($limits['min_width'] ?: 0, $limits['min_height'] ?: 0); $min_long = max($limits['min_width'] ?: 0, $limits['min_height'] ?: 0); // If only one dimension is set, use it for both if ($min_short === 0) $min_short = $min_long; if ($img_short < $min_short || $img_long < $min_long) { return new WP_Error('resolution_too_small', sprintf( __('Image resolution %1$dx%2$d px is too small. Minimum required: %3$dx%4$d px.', 'studiou-wc-free-photo-product'), $img_w, $img_h, $limits['min_width'] ?: $limits['min_height'], $limits['min_height'] ?: $limits['min_width'] )); } } // Maximum resolution check if ($has_max) { $max_short = min($limits['max_width'] ?: PHP_INT_MAX, $limits['max_height'] ?: PHP_INT_MAX); $max_long = max($limits['max_width'] ?: PHP_INT_MAX, $limits['max_height'] ?: PHP_INT_MAX); if ($max_short === PHP_INT_MAX) $max_short = $max_long; if ($img_short > $max_short || $img_long > $max_long) { return new WP_Error('resolution_too_large', sprintf( __('Image resolution %1$dx%2$d px is too large. Maximum allowed: %3$dx%4$d px.', 'studiou-wc-free-photo-product'), $img_w, $img_h, $limits['max_width'] ?: $limits['max_height'], $limits['max_height'] ?: $limits['max_width'] )); } } return true; } private function cleanup_chunks($upload_id, $total_chunks) { $chunks_dir = $this->get_chunks_dir(); for ($i = 0; $i < $total_chunks; $i++) { $chunk_file = $chunks_dir . '/' . $upload_id . '_chunk_' . $i; if (file_exists($chunk_file)) { unlink($chunk_file); } } } public function handle_remove_upload() { check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce'); $file_record_id = isset($_POST['file_record_id']) ? absint($_POST['file_record_id']) : 0; if (!$file_record_id) { wp_send_json_error(array('message' => __('Invalid file.', 'studiou-wc-free-photo-product'))); return; } $record = $this->db->get_file_record($file_record_id); if (!$record) { wp_send_json_error(array('message' => __('File not found.', 'studiou-wc-free-photo-product'))); return; } // Only allow removal if not yet linked to an order if ($record->order_id > 0) { wp_send_json_error(array('message' => __('Cannot remove a file linked to an order.', 'studiou-wc-free-photo-product'))); return; } // Verify ownership $current_user_id = get_current_user_id(); if ($current_user_id > 0 && (int) $record->customer_id !== $current_user_id) { wp_send_json_error(array('message' => __('Permission denied.', 'studiou-wc-free-photo-product'))); return; } $this->db->delete_file_record($file_record_id); wp_send_json_success(array('message' => __('File removed.', 'studiou-wc-free-photo-product'))); } const COOKIE_BATCH = 'studiou_fpp_batch'; /** * 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]); } 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', ); } }