class-studiou-wc-product-manage-product-export.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. <?php
  2. /**
  3. * Product export class
  4. *
  5. * Handles product export to CSV files
  6. *
  7. * @since 1.4.0
  8. * @package Studiou_WC_Product_Cat_Manage
  9. * @subpackage Studiou_WC_Product_Cat_Manage/includes
  10. */
  11. // If this file is called directly, abort.
  12. if (!defined('WPINC')) {
  13. die;
  14. }
  15. class Studiou_WC_Product_Manage_Product_Export {
  16. /**
  17. * @var Studiou_WC_Product_Manage_Product_DB
  18. */
  19. private $db;
  20. /**
  21. * Constructor
  22. *
  23. * @param Studiou_WC_Product_Manage_Product_DB $db Database instance
  24. */
  25. public function __construct($db) {
  26. $this->db = $db;
  27. // v1.6.4 — gated download endpoint, mirroring the category export.
  28. add_action('admin_init', array($this, 'handle_export_download'));
  29. }
  30. /**
  31. * Return (and lazily create) the guarded export directory.
  32. *
  33. * @return string Absolute path with trailing slash.
  34. */
  35. private function get_export_dir() {
  36. $upload_dir = wp_upload_dir();
  37. $dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-product-exports/';
  38. if (!file_exists($dir)) {
  39. wp_mkdir_p($dir);
  40. }
  41. // Guard against direct access. Cheap idempotent writes.
  42. $idx = $dir . 'index.php';
  43. if (!file_exists($idx)) {
  44. @file_put_contents($idx, '<?php // Silence is golden.');
  45. }
  46. $ht = $dir . '.htaccess';
  47. if (!file_exists($ht)) {
  48. @file_put_contents($ht, "Deny from all\n");
  49. }
  50. return $dir;
  51. }
  52. /**
  53. * Delete export files older than 24 hours. Called on every new export.
  54. */
  55. private function prune_old_exports() {
  56. $dir = $this->get_export_dir();
  57. $cutoff = time() - DAY_IN_SECONDS;
  58. $files = glob($dir . 'product-export-*.csv');
  59. if (!is_array($files)) { return; }
  60. foreach ($files as $f) {
  61. $mtime = @filemtime($f);
  62. if ($mtime !== false && $mtime < $cutoff) {
  63. @unlink($f);
  64. }
  65. }
  66. }
  67. /**
  68. * v1.6.4 — gated CSV download.
  69. *
  70. * Hooked on admin_init. Verifies nonce + manage_woocommerce capability +
  71. * that the requested file is a bare basename inside the export dir
  72. * (defends against path traversal), then streams via readfile().
  73. */
  74. public function handle_export_download() {
  75. if (!isset($_GET['page']) || $_GET['page'] !== 'studiou-product-export') {
  76. return;
  77. }
  78. if (!isset($_GET['action']) || $_GET['action'] !== 'studiou_wcpcm_download_product_export') {
  79. return;
  80. }
  81. if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'studiou-wcpcm-product-export-download')) {
  82. return;
  83. }
  84. if (!current_user_can('manage_woocommerce')) {
  85. wp_die(__('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'));
  86. }
  87. $requested = isset($_GET['file']) ? (string) $_GET['file'] : '';
  88. // Bare basename only — no slashes, no .., no directory components.
  89. if ($requested === '' || $requested !== basename($requested) || strpos($requested, '..') !== false) {
  90. wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
  91. }
  92. // Only our own filename shape: product-export-<32 hex>.csv
  93. if (!preg_match('/^product-export-[a-f0-9]{32}\.csv$/', $requested)) {
  94. wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
  95. }
  96. $dir = $this->get_export_dir();
  97. $path = $dir . $requested;
  98. if (!file_exists($path)) {
  99. wp_die(__('Export file not found. Please try exporting again.', 'studiou-wc-product-cat-manage'));
  100. }
  101. // Belt-and-braces realpath check to prevent symlink escape.
  102. $real_dir = realpath($dir);
  103. $real_path = realpath($path);
  104. if ($real_dir === false || $real_path === false || strpos($real_path, $real_dir) !== 0) {
  105. wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
  106. }
  107. header('Content-Type: text/csv; charset=utf-8');
  108. header('Content-Disposition: attachment; filename=product-export-' . date('Y-m-d-H-i-s') . '.csv');
  109. header('Content-Length: ' . filesize($real_path));
  110. header('Pragma: no-cache');
  111. header('Expires: 0');
  112. readfile($real_path);
  113. exit;
  114. }
  115. /**
  116. * Export products by category IDs
  117. *
  118. * @param array $category_ids Array of category IDs
  119. * @return string CSV content
  120. */
  121. public function export_products($category_ids) {
  122. $product_ids = $this->db->get_products_by_categories($category_ids);
  123. if (empty($product_ids)) {
  124. return '';
  125. }
  126. // Define CSV header
  127. $header = array(
  128. 'ID', 'Typ', 'Katalogové číslo', 'Jméno', 'Nadřazené', 'Krátký popis', 'Popis',
  129. 'Název 1 vlastnosti', 'Hodnota(y) 1 vlastnosti', 'Kategorie', 'Obrázky', 'Běžná cena',
  130. 'Vlastnost 1 viditelnost', 'Vlastnost 1 globální', 'Vlastnost 1 varianta',
  131. 'Povolit zákaznické recenze?', 'Minimum Quantity', 'Maximum Quantity'
  132. );
  133. // Start CSV output
  134. $csv = '';
  135. $csv .= '"' . implode('","', $header) . '"' . "\n";
  136. // Process each product
  137. foreach ($product_ids as $product_id) {
  138. $product = wc_get_product($product_id);
  139. if (!$product) {
  140. continue;
  141. }
  142. // Only export variable products and variations
  143. if (!$product->is_type('variable') && !$product->is_type('variation')) {
  144. continue;
  145. }
  146. $csv .= $this->format_product_row($product);
  147. }
  148. return $csv;
  149. }
  150. /**
  151. * Format product data as CSV row
  152. *
  153. * @param WC_Product $product Product object
  154. * @return string CSV row
  155. */
  156. private function format_product_row($product) {
  157. $row = array();
  158. // ID
  159. $row[] = $product->get_id();
  160. // Typ (Type)
  161. $row[] = $product->is_type('variation') ? 'variation' : 'variable';
  162. // Katalogové číslo (SKU)
  163. $row[] = $product->get_sku();
  164. // Jméno (Name)
  165. $row[] = $product->get_name();
  166. // Nadřazené (Parent) - only for variations
  167. if ($product->is_type('variation')) {
  168. $parent_id = $product->get_parent_id();
  169. if ($parent_id) {
  170. $parent = wc_get_product($parent_id);
  171. $row[] = $parent ? $parent->get_sku() : '';
  172. } else {
  173. $row[] = '';
  174. }
  175. } else {
  176. $row[] = '';
  177. }
  178. // Krátký popis (Short description)
  179. $row[] = $product->get_short_description();
  180. // Popis (Description)
  181. $row[] = $product->get_description();
  182. // Název 1 vlastnosti (Attribute 1 name)
  183. // Hodnota(y) 1 vlastnosti (Attribute 1 value(s))
  184. $attributes = $product->get_attributes();
  185. $attribute_name = '';
  186. $attribute_values = '';
  187. $attribute_visible = '0';
  188. $attribute_global = '0';
  189. $attribute_variation = '0';
  190. if (!empty($attributes)) {
  191. $first_attribute = reset($attributes);
  192. if ($first_attribute instanceof WC_Product_Attribute) {
  193. $attribute_name = $first_attribute->get_name();
  194. // Remove 'pa_' prefix if present
  195. if (strpos($attribute_name, 'pa_') === 0) {
  196. $attribute_name = substr($attribute_name, 3);
  197. }
  198. // Get attribute values
  199. if ($product->is_type('variation')) {
  200. // For variations, get the specific value
  201. $variation_attributes = $product->get_variation_attributes();
  202. if (!empty($variation_attributes)) {
  203. $attribute_values = reset($variation_attributes);
  204. }
  205. } else {
  206. // For variable products, get all options
  207. $options = $first_attribute->get_options();
  208. if (is_array($options)) {
  209. $attribute_values = implode('|', $options);
  210. }
  211. }
  212. $attribute_visible = $first_attribute->get_visible() ? '1' : '0';
  213. $attribute_global = $first_attribute->is_taxonomy() ? '1' : '0';
  214. $attribute_variation = $first_attribute->get_variation() ? '1' : '0';
  215. }
  216. }
  217. $row[] = $attribute_name;
  218. $row[] = $attribute_values;
  219. // Kategorie (Categories) - with full hierarchy for subcategories
  220. $category_ids = $product->get_category_ids();
  221. $category_names = array();
  222. foreach ($category_ids as $cat_id) {
  223. $category_path = $this->get_category_path($cat_id);
  224. if ($category_path) {
  225. $category_names[] = $category_path;
  226. }
  227. }
  228. $row[] = implode('|', $category_names);
  229. // Obrázky (Images)
  230. $image_id = $product->get_image_id();
  231. $row[] = $image_id ? wp_get_attachment_url($image_id) : '';
  232. // Běžná cena (Regular price)
  233. $row[] = $product->get_regular_price();
  234. // Vlastnost 1 viditelnost (Attribute 1 visibility)
  235. $row[] = $attribute_visible;
  236. // Vlastnost 1 globální (Attribute 1 global)
  237. $row[] = $attribute_global;
  238. // Vlastnost 1 varianta (Attribute 1 variation)
  239. $row[] = $attribute_variation;
  240. // Povolit zákaznické recenze? (Enable reviews)
  241. $row[] = $product->get_reviews_allowed() ? '1' : '0';
  242. // Minimum Quantity
  243. $min_qty = $product->get_meta('_minimum_quantity');
  244. $row[] = $min_qty ? $min_qty : '';
  245. // Maximum Quantity
  246. $max_qty = $product->get_meta('_maximum_quantity');
  247. $row[] = $max_qty ? $max_qty : '';
  248. // Format as CSV row
  249. $formatted_row = array();
  250. foreach ($row as $value) {
  251. // Escape double quotes
  252. $value = str_replace('"', '""', $value);
  253. $formatted_row[] = '"' . $value . '"';
  254. }
  255. return implode(',', $formatted_row) . "\n";
  256. }
  257. /**
  258. * Get full category path (Parent > Child format for subcategories)
  259. *
  260. * @param int $category_id Category term ID
  261. * @return string Category path
  262. */
  263. private function get_category_path($category_id) {
  264. $term = get_term($category_id, 'product_cat');
  265. if (!$term || is_wp_error($term)) {
  266. return '';
  267. }
  268. // Build path from child to root
  269. $path = array($term->name);
  270. $current_term = $term;
  271. while ($current_term->parent != 0) {
  272. $parent_term = get_term($current_term->parent, 'product_cat');
  273. if (!$parent_term || is_wp_error($parent_term)) {
  274. break;
  275. }
  276. array_unshift($path, $parent_term->name);
  277. $current_term = $parent_term;
  278. }
  279. // Return full path with ">" separator if subcategory, otherwise just the name
  280. return count($path) > 1 ? implode(' > ', $path) : $path[0];
  281. }
  282. /**
  283. * Generate CSV file and return download URL
  284. *
  285. * @param array $category_ids Array of category IDs
  286. * @return array Result with download URL or error message
  287. */
  288. public function generate_export_file($category_ids) {
  289. $csv_content = $this->export_products($category_ids);
  290. if (empty($csv_content)) {
  291. return array(
  292. 'success' => false,
  293. 'message' => __('No products found in selected categories', 'studiou-wc-product-cat-manage')
  294. );
  295. }
  296. // v1.6.4 — write into the guarded export dir and prune anything older
  297. // than 24 hours. Filename gets a 32-char random token so the file is
  298. // unguessable even if directory listing leaks.
  299. $export_dir = $this->get_export_dir();
  300. $this->prune_old_exports();
  301. $token = bin2hex(random_bytes(16)); // 32 hex chars
  302. $filename = 'product-export-' . $token . '.csv';
  303. $file_path = $export_dir . $filename;
  304. // Write CSV content to file with UTF-8 BOM so Excel double-click
  305. // opens it with the correct encoding (Policy A, v1.6.2 — match the
  306. // category exporter, which has always written the BOM).
  307. $result = file_put_contents($file_path, "\xEF\xBB\xBF" . $csv_content);
  308. if ($result === false) {
  309. return array(
  310. 'success' => false,
  311. 'message' => __('Failed to create export file', 'studiou-wc-product-cat-manage')
  312. );
  313. }
  314. // Return an admin URL routed through handle_export_download(),
  315. // which checks the nonce + capability + basename. The raw public
  316. // uploads URL is no longer exposed.
  317. $download_url = add_query_arg(
  318. array(
  319. 'page' => 'studiou-product-export',
  320. 'action' => 'studiou_wcpcm_download_product_export',
  321. 'file' => $filename,
  322. '_wpnonce' => wp_create_nonce('studiou-wcpcm-product-export-download'),
  323. ),
  324. admin_url('edit.php?post_type=product')
  325. );
  326. return array(
  327. 'success' => true,
  328. 'message' => sprintf(
  329. __('Export successful! %d products exported.', 'studiou-wc-product-cat-manage'),
  330. substr_count($csv_content, "\n") - 1 // Subtract header row (note: L2 fixes the multi-line case in v1.7.1)
  331. ),
  332. 'download_url' => $download_url
  333. );
  334. }
  335. }