class-studiou-wc-fpp-upload.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. <?php
  2. if (!defined('WPINC')) {
  3. die;
  4. }
  5. class Studiou_WC_FPP_Upload {
  6. /** @var Studiou_WC_FPP_DB */
  7. private $db;
  8. public function __construct($db) {
  9. $this->db = $db;
  10. add_action('wp_ajax_studiou_wcfpp_upload_chunk', array($this, 'handle_chunk_upload'));
  11. add_action('wp_ajax_nopriv_studiou_wcfpp_upload_chunk', array($this, 'handle_chunk_upload'));
  12. add_action('wp_ajax_studiou_wcfpp_remove_upload', array($this, 'handle_remove_upload'));
  13. add_action('wp_ajax_nopriv_studiou_wcfpp_remove_upload', array($this, 'handle_remove_upload'));
  14. }
  15. private function get_chunks_dir() {
  16. $upload_dir = wp_upload_dir();
  17. return $upload_dir['basedir'] . '/studiou-fpp-chunks';
  18. }
  19. private function get_allowed_mime_types() {
  20. return array(
  21. 'jpg|jpeg|jpe' => 'image/jpeg',
  22. 'png' => 'image/png',
  23. 'tiff|tif' => 'image/tiff',
  24. 'bmp' => 'image/bmp',
  25. 'psd' => 'image/vnd.adobe.photoshop',
  26. 'cr2' => 'image/x-canon-cr2',
  27. 'nef' => 'image/x-nikon-nef',
  28. 'arw' => 'image/x-sony-arw',
  29. 'dng' => 'image/x-adobe-dng',
  30. 'orf' => 'image/x-olympus-orf',
  31. 'rw2' => 'image/x-panasonic-rw2',
  32. 'raf' => 'image/x-fuji-raf',
  33. 'webp' => 'image/webp',
  34. );
  35. }
  36. public function handle_chunk_upload() {
  37. // Clean output buffers
  38. while (ob_get_level()) {
  39. ob_end_clean();
  40. }
  41. check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
  42. $product_id = isset($_POST['product_id']) ? absint($_POST['product_id']) : 0;
  43. $upload_id = isset($_POST['upload_id']) ? sanitize_text_field($_POST['upload_id']) : '';
  44. $chunk_index = isset($_POST['chunk_index']) ? absint($_POST['chunk_index']) : 0;
  45. $total_chunks = isset($_POST['total_chunks']) ? absint($_POST['total_chunks']) : 0;
  46. $file_name = isset($_POST['file_name']) ? sanitize_file_name($_POST['file_name']) : '';
  47. $file_size = isset($_POST['file_size']) ? absint($_POST['file_size']) : 0;
  48. // Validate product
  49. if (!$product_id || !Studiou_WC_FPP_Product::is_fpp_product($product_id)) {
  50. wp_send_json_error(array('message' => __('Invalid product.', 'studiou-wc-free-photo-product')));
  51. return;
  52. }
  53. // Validate file size
  54. $max_size = Studiou_WC_FPP_Product::get_product_max_file_size($product_id);
  55. if ($file_size > ($max_size * 1024 * 1024)) {
  56. wp_send_json_error(array('message' => sprintf(
  57. __('File is too large. Maximum size: %s MB', 'studiou-wc-free-photo-product'),
  58. $max_size
  59. )));
  60. return;
  61. }
  62. // Validate upload_id format (should be alphanumeric)
  63. if (!preg_match('/^[a-zA-Z0-9_-]+$/', $upload_id)) {
  64. wp_send_json_error(array('message' => __('Invalid upload ID.', 'studiou-wc-free-photo-product')));
  65. return;
  66. }
  67. // Validate chunk file exists
  68. if (!isset($_FILES['chunk']) || $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
  69. wp_send_json_error(array('message' => __('Chunk upload failed.', 'studiou-wc-free-photo-product')));
  70. return;
  71. }
  72. $chunks_dir = $this->get_chunks_dir();
  73. if (!file_exists($chunks_dir)) {
  74. wp_mkdir_p($chunks_dir);
  75. }
  76. // Store the chunk
  77. $chunk_file = $chunks_dir . '/' . $upload_id . '_chunk_' . $chunk_index;
  78. if (!move_uploaded_file($_FILES['chunk']['tmp_name'], $chunk_file)) {
  79. wp_send_json_error(array('message' => __('Failed to store chunk.', 'studiou-wc-free-photo-product')));
  80. return;
  81. }
  82. // If this is the last chunk, assemble the file
  83. if ($chunk_index === $total_chunks - 1) {
  84. // Clean any output that may have been generated
  85. while (ob_get_level()) {
  86. ob_end_clean();
  87. }
  88. $result = $this->assemble_chunks($upload_id, $total_chunks, $file_name, $product_id);
  89. // Clean again before sending JSON
  90. while (ob_get_level()) {
  91. ob_end_clean();
  92. }
  93. if (is_wp_error($result)) {
  94. wp_send_json_error(array('message' => $result->get_error_message()));
  95. return;
  96. }
  97. // Store the upload reference in the WC session immediately, in the same request.
  98. // This avoids a race with a follow-up "set_upload_session" AJAX call that could
  99. // still be in flight when the user clicks Add to Cart.
  100. if (function_exists('WC') && WC()->session) {
  101. WC()->session->set('studiou_fpp_attachment_id', $result['attachment_id']);
  102. WC()->session->set('studiou_fpp_file_record_id', $result['file_record_id']);
  103. WC()->session->set('studiou_fpp_file_name', $result['file_name']);
  104. WC()->session->set('studiou_fpp_thumb_url', $result['thumbnail_url']);
  105. }
  106. // Also persist identifiers in a first-party cookie so the reference survives session
  107. // paths that use a different identifier than the classic wp_woocommerce_session_* cookie
  108. // (e.g. WC Store API / Cart-Token used by pvtfw 1.9.3+ add-to-cart).
  109. self::set_upload_cookies($result['attachment_id'], $result['file_record_id']);
  110. wp_send_json_success(array(
  111. 'complete' => true,
  112. 'attachment_id' => $result['attachment_id'],
  113. 'file_record_id' => $result['file_record_id'],
  114. 'thumbnail_url' => $result['thumbnail_url'],
  115. 'preview_url' => $result['preview_url'],
  116. 'file_name' => $result['file_name'],
  117. ));
  118. return;
  119. }
  120. wp_send_json_success(array(
  121. 'complete' => false,
  122. 'chunk_index' => $chunk_index,
  123. ));
  124. }
  125. private function assemble_chunks($upload_id, $total_chunks, $file_name, $product_id) {
  126. $chunks_dir = $this->get_chunks_dir();
  127. $upload_dir = wp_upload_dir();
  128. // Create a subdirectory for free photo uploads
  129. $fpp_dir = $upload_dir['path'] . '/free-photo';
  130. if (!file_exists($fpp_dir)) {
  131. wp_mkdir_p($fpp_dir);
  132. }
  133. // Generate unique filename
  134. $ext = pathinfo($file_name, PATHINFO_EXTENSION);
  135. $base = sanitize_file_name(pathinfo($file_name, PATHINFO_FILENAME));
  136. $unique_name = $base . '_' . uniqid() . '.' . $ext;
  137. $assembled_path = $fpp_dir . '/' . $unique_name;
  138. // Assemble chunks
  139. $output = fopen($assembled_path, 'wb');
  140. if (!$output) {
  141. $this->cleanup_chunks($upload_id, $total_chunks);
  142. return new WP_Error('assemble_failed', __('Failed to create output file.', 'studiou-wc-free-photo-product'));
  143. }
  144. for ($i = 0; $i < $total_chunks; $i++) {
  145. $chunk_file = $chunks_dir . '/' . $upload_id . '_chunk_' . $i;
  146. if (!file_exists($chunk_file)) {
  147. fclose($output);
  148. unlink($assembled_path);
  149. $this->cleanup_chunks($upload_id, $total_chunks);
  150. return new WP_Error('chunk_missing', sprintf(
  151. __('Missing chunk %d.', 'studiou-wc-free-photo-product'),
  152. $i
  153. ));
  154. }
  155. $chunk_data = file_get_contents($chunk_file);
  156. fwrite($output, $chunk_data);
  157. }
  158. fclose($output);
  159. // Clean up chunk files
  160. $this->cleanup_chunks($upload_id, $total_chunks);
  161. // Validate file extension
  162. $allowed = $this->get_allowed_mime_types();
  163. $ext_lower = strtolower($ext);
  164. $valid = false;
  165. foreach ($allowed as $exts => $mime) {
  166. $ext_list = explode('|', $exts);
  167. if (in_array($ext_lower, $ext_list)) {
  168. $valid = true;
  169. break;
  170. }
  171. }
  172. if (!$valid) {
  173. unlink($assembled_path);
  174. return new WP_Error('invalid_type', __('File type not allowed.', 'studiou-wc-free-photo-product'));
  175. }
  176. // Validate image resolution (if limits are set)
  177. $res_error = $this->validate_image_resolution($assembled_path, $product_id);
  178. if (is_wp_error($res_error)) {
  179. unlink($assembled_path);
  180. return $res_error;
  181. }
  182. // Create WP attachment
  183. $relative_path = str_replace($upload_dir['basedir'] . '/', '', $assembled_path);
  184. $filetype = wp_check_filetype($unique_name, $allowed);
  185. $attachment_data = array(
  186. 'post_mime_type' => $filetype['type'] ?: 'application/octet-stream',
  187. 'post_title' => sanitize_file_name($file_name),
  188. 'post_content' => '',
  189. 'post_status' => 'inherit',
  190. 'post_parent' => $product_id,
  191. );
  192. $attachment_id = wp_insert_attachment($attachment_data, $assembled_path, $product_id);
  193. if (is_wp_error($attachment_id)) {
  194. unlink($assembled_path);
  195. return $attachment_id;
  196. }
  197. // Generate metadata (wrapped in output buffer to prevent stray output corrupting JSON)
  198. require_once(ABSPATH . 'wp-admin/includes/image.php');
  199. ob_start();
  200. try {
  201. @set_time_limit(120);
  202. $metadata = @wp_generate_attachment_metadata($attachment_id, $assembled_path);
  203. if (!empty($metadata)) {
  204. wp_update_attachment_metadata($attachment_id, $metadata);
  205. }
  206. } catch (\Throwable $e) {
  207. if (defined('WP_DEBUG') && WP_DEBUG) {
  208. error_log('STUDIOU FPP: metadata generation failed - ' . $e->getMessage());
  209. }
  210. }
  211. ob_end_clean();
  212. // Assign media category
  213. $media_cat_id = Studiou_WC_FPP_Product::get_product_media_category($product_id);
  214. if ($media_cat_id) {
  215. wp_set_object_terms($attachment_id, array((int) $media_cat_id), 'studiou_media_category');
  216. }
  217. // Get thumbnail URL (small - for upload preview)
  218. $thumbnail_url = '';
  219. $image_src = wp_get_attachment_image_src($attachment_id, 'thumbnail');
  220. if ($image_src) {
  221. $thumbnail_url = $image_src[0];
  222. } else {
  223. $thumbnail_url = wp_mime_type_icon($attachment_id);
  224. }
  225. // Get preview URL (larger - for product gallery replacement)
  226. $preview_url = '';
  227. $preview_src = wp_get_attachment_image_src($attachment_id, 'woocommerce_single');
  228. if ($preview_src) {
  229. $preview_url = $preview_src[0];
  230. } elseif ($image_src) {
  231. $preview_url = $image_src[0];
  232. }
  233. // Get session key for guests
  234. $session_key = '';
  235. if (!is_user_logged_in()) {
  236. if (WC()->session) {
  237. $session_key = WC()->session->get_customer_id();
  238. }
  239. }
  240. // Insert file record
  241. $file_record_id = $this->db->insert_file_record(array(
  242. 'product_id' => $product_id,
  243. 'attachment_id' => $attachment_id,
  244. 'customer_id' => get_current_user_id(),
  245. 'session_key' => $session_key,
  246. 'file_name' => $file_name,
  247. ));
  248. if (!$file_record_id) {
  249. wp_delete_attachment($attachment_id, true);
  250. return new WP_Error('record_failed', __('Failed to create file record.', 'studiou-wc-free-photo-product'));
  251. }
  252. return array(
  253. 'attachment_id' => $attachment_id,
  254. 'file_record_id' => $file_record_id,
  255. 'thumbnail_url' => $thumbnail_url,
  256. 'preview_url' => $preview_url,
  257. 'file_name' => $file_name,
  258. );
  259. }
  260. /**
  261. * Validate image resolution against product limits.
  262. * Width/height are commutable — a 3000x2000 image matches both 3000x2000 and 2000x3000 limits.
  263. */
  264. private function validate_image_resolution($file_path, $product_id) {
  265. $limits = Studiou_WC_FPP_Product::get_product_resolution_limits($product_id);
  266. // Skip if no limits set
  267. $has_min = ($limits['min_width'] > 0 || $limits['min_height'] > 0);
  268. $has_max = ($limits['max_width'] > 0 || $limits['max_height'] > 0);
  269. if (!$has_min && !$has_max) {
  270. return true;
  271. }
  272. // Get image dimensions
  273. $size = @getimagesize($file_path);
  274. if (!$size || !isset($size[0], $size[1])) {
  275. // Cannot determine dimensions (e.g. RAW files) — skip validation
  276. return true;
  277. }
  278. $img_w = $size[0];
  279. $img_h = $size[1];
  280. // Normalize: always compare min(w,h) vs min(limit_w,limit_h) and max(w,h) vs max(limit_w,limit_h)
  281. // This makes portrait/landscape interchangeable
  282. $img_short = min($img_w, $img_h);
  283. $img_long = max($img_w, $img_h);
  284. // Minimum resolution check
  285. if ($has_min) {
  286. $min_short = min($limits['min_width'] ?: 0, $limits['min_height'] ?: 0);
  287. $min_long = max($limits['min_width'] ?: 0, $limits['min_height'] ?: 0);
  288. // If only one dimension is set, use it for both
  289. if ($min_short === 0) $min_short = $min_long;
  290. if ($img_short < $min_short || $img_long < $min_long) {
  291. return new WP_Error('resolution_too_small', sprintf(
  292. __('Image resolution %1$dx%2$d px is too small. Minimum required: %3$dx%4$d px.', 'studiou-wc-free-photo-product'),
  293. $img_w, $img_h, $limits['min_width'] ?: $limits['min_height'], $limits['min_height'] ?: $limits['min_width']
  294. ));
  295. }
  296. }
  297. // Maximum resolution check
  298. if ($has_max) {
  299. $max_short = min($limits['max_width'] ?: PHP_INT_MAX, $limits['max_height'] ?: PHP_INT_MAX);
  300. $max_long = max($limits['max_width'] ?: PHP_INT_MAX, $limits['max_height'] ?: PHP_INT_MAX);
  301. if ($max_short === PHP_INT_MAX) $max_short = $max_long;
  302. if ($img_short > $max_short || $img_long > $max_long) {
  303. return new WP_Error('resolution_too_large', sprintf(
  304. __('Image resolution %1$dx%2$d px is too large. Maximum allowed: %3$dx%4$d px.', 'studiou-wc-free-photo-product'),
  305. $img_w, $img_h, $limits['max_width'] ?: $limits['max_height'], $limits['max_height'] ?: $limits['max_width']
  306. ));
  307. }
  308. }
  309. return true;
  310. }
  311. private function cleanup_chunks($upload_id, $total_chunks) {
  312. $chunks_dir = $this->get_chunks_dir();
  313. for ($i = 0; $i < $total_chunks; $i++) {
  314. $chunk_file = $chunks_dir . '/' . $upload_id . '_chunk_' . $i;
  315. if (file_exists($chunk_file)) {
  316. unlink($chunk_file);
  317. }
  318. }
  319. }
  320. public function handle_remove_upload() {
  321. check_ajax_referer('studiou-wcfpp-front-nonce', 'nonce');
  322. $file_record_id = isset($_POST['file_record_id']) ? absint($_POST['file_record_id']) : 0;
  323. if (!$file_record_id) {
  324. wp_send_json_error(array('message' => __('Invalid file.', 'studiou-wc-free-photo-product')));
  325. return;
  326. }
  327. $record = $this->db->get_file_record($file_record_id);
  328. if (!$record) {
  329. wp_send_json_error(array('message' => __('File not found.', 'studiou-wc-free-photo-product')));
  330. return;
  331. }
  332. // Only allow removal if not yet linked to an order
  333. if ($record->order_id > 0) {
  334. wp_send_json_error(array('message' => __('Cannot remove a file linked to an order.', 'studiou-wc-free-photo-product')));
  335. return;
  336. }
  337. // Verify ownership
  338. $current_user_id = get_current_user_id();
  339. if ($current_user_id > 0 && (int) $record->customer_id !== $current_user_id) {
  340. wp_send_json_error(array('message' => __('Permission denied.', 'studiou-wc-free-photo-product')));
  341. return;
  342. }
  343. $this->db->delete_file_record($file_record_id);
  344. self::clear_upload_cookies();
  345. wp_send_json_success(array('message' => __('File removed.', 'studiou-wc-free-photo-product')));
  346. }
  347. const COOKIE_ATTACHMENT = 'studiou_fpp_att_id';
  348. const COOKIE_RECORD = 'studiou_fpp_rec_id';
  349. public static function set_upload_cookies($attachment_id, $file_record_id) {
  350. $attachment_id = (int) $attachment_id;
  351. $file_record_id = (int) $file_record_id;
  352. if ($attachment_id <= 0 || $file_record_id <= 0) {
  353. return;
  354. }
  355. $options = self::cookie_options(time() + DAY_IN_SECONDS);
  356. @setcookie(self::COOKIE_ATTACHMENT, (string) $attachment_id, $options);
  357. @setcookie(self::COOKIE_RECORD, (string) $file_record_id, $options);
  358. // Mirror into $_COOKIE so code running later in this request sees the value too
  359. $_COOKIE[self::COOKIE_ATTACHMENT] = (string) $attachment_id;
  360. $_COOKIE[self::COOKIE_RECORD] = (string) $file_record_id;
  361. }
  362. public static function clear_upload_cookies() {
  363. $options = self::cookie_options(time() - DAY_IN_SECONDS);
  364. @setcookie(self::COOKIE_ATTACHMENT, '', $options);
  365. @setcookie(self::COOKIE_RECORD, '', $options);
  366. unset($_COOKIE[self::COOKIE_ATTACHMENT], $_COOKIE[self::COOKIE_RECORD]);
  367. }
  368. private static function cookie_options($expires) {
  369. $path = defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/';
  370. return array(
  371. 'expires' => (int) $expires,
  372. 'path' => $path,
  373. 'domain' => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '',
  374. 'secure' => is_ssl(),
  375. 'httponly' => true,
  376. 'samesite' => 'Lax',
  377. );
  378. }
  379. }