| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410 |
- <?php
- /**
- * Product export class
- *
- * Handles product export to CSV files
- *
- * @since 1.4.0
- * @package Studiou_WC_Product_Cat_Manage
- * @subpackage Studiou_WC_Product_Cat_Manage/includes
- */
- // If this file is called directly, abort.
- if (!defined('WPINC')) {
- die;
- }
- class Studiou_WC_Product_Manage_Product_Export {
- /**
- * @var Studiou_WC_Product_Manage_Product_DB
- */
- private $db;
- /**
- * Constructor
- *
- * @param Studiou_WC_Product_Manage_Product_DB $db Database instance
- */
- public function __construct($db) {
- $this->db = $db;
- // v1.6.4 — gated download endpoint, mirroring the category export.
- add_action('admin_init', array($this, 'handle_export_download'));
- }
- /**
- * Return (and lazily create) the guarded export directory.
- *
- * @return string Absolute path with trailing slash.
- */
- private function get_export_dir() {
- $upload_dir = wp_upload_dir();
- $dir = trailingslashit($upload_dir['basedir']) . 'studiou-wcpcm-product-exports/';
- if (!file_exists($dir)) {
- wp_mkdir_p($dir);
- }
- // Guard against direct access. Cheap idempotent writes.
- $idx = $dir . 'index.php';
- if (!file_exists($idx)) {
- @file_put_contents($idx, '<?php // Silence is golden.');
- }
- $ht = $dir . '.htaccess';
- if (!file_exists($ht)) {
- @file_put_contents($ht, "Deny from all\n");
- }
- return $dir;
- }
- /**
- * Delete export files older than 24 hours. Called on every new export.
- */
- private function prune_old_exports() {
- $dir = $this->get_export_dir();
- $cutoff = time() - DAY_IN_SECONDS;
- $files = glob($dir . 'product-export-*.csv');
- if (!is_array($files)) { return; }
- foreach ($files as $f) {
- $mtime = @filemtime($f);
- if ($mtime !== false && $mtime < $cutoff) {
- @unlink($f);
- }
- }
- }
- /**
- * v1.6.4 — gated CSV download.
- *
- * Hooked on admin_init. Verifies nonce + manage_woocommerce capability +
- * that the requested file is a bare basename inside the export dir
- * (defends against path traversal), then streams via readfile().
- */
- public function handle_export_download() {
- if (!isset($_GET['page']) || $_GET['page'] !== 'studiou-product-export') {
- return;
- }
- if (!isset($_GET['action']) || $_GET['action'] !== 'studiou_wcpcm_download_product_export') {
- return;
- }
- if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'studiou-wcpcm-product-export-download')) {
- return;
- }
- if (!current_user_can('manage_woocommerce')) {
- wp_die(__('You do not have sufficient permissions', 'studiou-wc-product-cat-manage'));
- }
- $requested = isset($_GET['file']) ? (string) $_GET['file'] : '';
- // Bare basename only — no slashes, no .., no directory components.
- if ($requested === '' || $requested !== basename($requested) || strpos($requested, '..') !== false) {
- wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
- }
- // Only our own filename shape: product-export-<32 hex>.csv
- if (!preg_match('/^product-export-[a-f0-9]{32}\.csv$/', $requested)) {
- wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
- }
- $dir = $this->get_export_dir();
- $path = $dir . $requested;
- if (!file_exists($path)) {
- wp_die(__('Export file not found. Please try exporting again.', 'studiou-wc-product-cat-manage'));
- }
- // Belt-and-braces realpath check to prevent symlink escape.
- $real_dir = realpath($dir);
- $real_path = realpath($path);
- if ($real_dir === false || $real_path === false || strpos($real_path, $real_dir) !== 0) {
- wp_die(__('Invalid file', 'studiou-wc-product-cat-manage'));
- }
- header('Content-Type: text/csv; charset=utf-8');
- header('Content-Disposition: attachment; filename=product-export-' . date('Y-m-d-H-i-s') . '.csv');
- header('Content-Length: ' . filesize($real_path));
- header('Pragma: no-cache');
- header('Expires: 0');
- readfile($real_path);
- exit;
- }
- /**
- * Export products by category IDs
- *
- * @param array $category_ids Array of category IDs
- * @return string CSV content
- */
- public function export_products($category_ids, &$row_count = null) {
- $product_ids = $this->db->get_products_by_categories($category_ids);
- if (empty($product_ids)) {
- return '';
- }
- // Define CSV header
- $header = array(
- 'ID', 'Typ', 'Katalogové číslo', 'Jméno', 'Nadřazené', 'Krátký popis', 'Popis',
- 'Název 1 vlastnosti', 'Hodnota(y) 1 vlastnosti', 'Kategorie', 'Obrázky', 'Běžná cena',
- 'Vlastnost 1 viditelnost', 'Vlastnost 1 globální', 'Vlastnost 1 varianta',
- 'Povolit zákaznické recenze?', 'Minimum Quantity', 'Maximum Quantity'
- );
- // Start CSV output
- $csv = '';
- $csv .= '"' . implode('","', $header) . '"' . "\n";
- // v1.7.1 — L2: accumulate the count during generation. The previous
- // substr_count("\n") - 1 over-counted whenever any field (typically
- // the product description) contained a newline.
- $count = 0;
- // Process each product
- foreach ($product_ids as $product_id) {
- $product = wc_get_product($product_id);
- if (!$product) {
- continue;
- }
- // Only export variable products and variations
- if (!$product->is_type('variable') && !$product->is_type('variation')) {
- continue;
- }
- $csv .= $this->format_product_row($product);
- $count++;
- }
- if (func_num_args() >= 2) {
- $row_count = $count;
- }
- return $csv;
- }
- /**
- * Format product data as CSV row
- *
- * @param WC_Product $product Product object
- * @return string CSV row
- */
- private function format_product_row($product) {
- $row = array();
- // ID
- $row[] = $product->get_id();
- // Typ (Type)
- $row[] = $product->is_type('variation') ? 'variation' : 'variable';
- // Katalogové číslo (SKU)
- $row[] = $product->get_sku();
- // Jméno (Name)
- $row[] = $product->get_name();
- // Nadřazené (Parent) - only for variations
- if ($product->is_type('variation')) {
- $parent_id = $product->get_parent_id();
- if ($parent_id) {
- $parent = wc_get_product($parent_id);
- $row[] = $parent ? $parent->get_sku() : '';
- } else {
- $row[] = '';
- }
- } else {
- $row[] = '';
- }
- // Krátký popis (Short description)
- $row[] = $product->get_short_description();
- // Popis (Description)
- $row[] = $product->get_description();
- // Název 1 vlastnosti (Attribute 1 name)
- // Hodnota(y) 1 vlastnosti (Attribute 1 value(s))
- $attributes = $product->get_attributes();
- $attribute_name = '';
- $attribute_values = '';
- $attribute_visible = '0';
- $attribute_global = '0';
- $attribute_variation = '0';
- if (!empty($attributes)) {
- $first_attribute = reset($attributes);
- if ($first_attribute instanceof WC_Product_Attribute) {
- $attribute_name = $first_attribute->get_name();
- // Remove 'pa_' prefix if present
- if (strpos($attribute_name, 'pa_') === 0) {
- $attribute_name = substr($attribute_name, 3);
- }
- // Get attribute values
- if ($product->is_type('variation')) {
- // For variations, get the specific value
- $variation_attributes = $product->get_variation_attributes();
- if (!empty($variation_attributes)) {
- $attribute_values = reset($variation_attributes);
- }
- } else {
- // For variable products, get all options
- $options = $first_attribute->get_options();
- if (is_array($options)) {
- $attribute_values = implode('|', $options);
- }
- }
- $attribute_visible = $first_attribute->get_visible() ? '1' : '0';
- $attribute_global = $first_attribute->is_taxonomy() ? '1' : '0';
- $attribute_variation = $first_attribute->get_variation() ? '1' : '0';
- }
- }
- $row[] = $attribute_name;
- $row[] = $attribute_values;
- // Kategorie (Categories) - with full hierarchy for subcategories
- $category_ids = $product->get_category_ids();
- $category_names = array();
- foreach ($category_ids as $cat_id) {
- $category_path = $this->get_category_path($cat_id);
- if ($category_path) {
- $category_names[] = $category_path;
- }
- }
- $row[] = implode('|', $category_names);
- // Obrázky (Images)
- $image_id = $product->get_image_id();
- $row[] = $image_id ? wp_get_attachment_url($image_id) : '';
- // Běžná cena (Regular price)
- $row[] = $product->get_regular_price();
- // Vlastnost 1 viditelnost (Attribute 1 visibility)
- $row[] = $attribute_visible;
- // Vlastnost 1 globální (Attribute 1 global)
- $row[] = $attribute_global;
- // Vlastnost 1 varianta (Attribute 1 variation)
- $row[] = $attribute_variation;
- // Povolit zákaznické recenze? (Enable reviews)
- $row[] = $product->get_reviews_allowed() ? '1' : '0';
- // Minimum Quantity
- $min_qty = $product->get_meta('_minimum_quantity');
- $row[] = $min_qty ? $min_qty : '';
- // Maximum Quantity
- $max_qty = $product->get_meta('_maximum_quantity');
- $row[] = $max_qty ? $max_qty : '';
- // Format as CSV row
- $formatted_row = array();
- foreach ($row as $value) {
- // Escape double quotes
- $value = str_replace('"', '""', $value);
- $formatted_row[] = '"' . $value . '"';
- }
- return implode(',', $formatted_row) . "\n";
- }
- /**
- * Get full category path (Parent > Child format for subcategories)
- *
- * @param int $category_id Category term ID
- * @return string Category path
- */
- private function get_category_path($category_id) {
- $term = get_term($category_id, 'product_cat');
- if (!$term || is_wp_error($term)) {
- return '';
- }
- // Build path from child to root
- $path = array($term->name);
- $current_term = $term;
- while ($current_term->parent != 0) {
- $parent_term = get_term($current_term->parent, 'product_cat');
- if (!$parent_term || is_wp_error($parent_term)) {
- break;
- }
- array_unshift($path, $parent_term->name);
- $current_term = $parent_term;
- }
- // Return full path with ">" separator if subcategory, otherwise just the name
- return count($path) > 1 ? implode(' > ', $path) : $path[0];
- }
- /**
- * Generate CSV file and return download URL
- *
- * @param array $category_ids Array of category IDs
- * @return array Result with download URL or error message
- */
- public function generate_export_file($category_ids) {
- // v1.7.1 — L2: ask export_products() to return both the CSV string
- // AND the row count it accumulated during generation. substr_count
- // on "\n" over-counted whenever any product description contained
- // a newline.
- $row_count = 0;
- $csv_content = $this->export_products($category_ids, $row_count);
- if (empty($csv_content)) {
- return array(
- 'success' => false,
- 'message' => __('No products found in selected categories', 'studiou-wc-product-cat-manage')
- );
- }
- // v1.6.4 — write into the guarded export dir and prune anything older
- // than 24 hours. Filename gets a 32-char random token so the file is
- // unguessable even if directory listing leaks.
- $export_dir = $this->get_export_dir();
- $this->prune_old_exports();
- $token = bin2hex(random_bytes(16)); // 32 hex chars
- $filename = 'product-export-' . $token . '.csv';
- $file_path = $export_dir . $filename;
- // Write CSV content to file with UTF-8 BOM so Excel double-click
- // opens it with the correct encoding (Policy A, v1.6.2 — match the
- // category exporter, which has always written the BOM).
- $result = file_put_contents($file_path, "\xEF\xBB\xBF" . $csv_content);
- if ($result === false) {
- return array(
- 'success' => false,
- 'message' => __('Failed to create export file', 'studiou-wc-product-cat-manage')
- );
- }
- // Return an admin URL routed through handle_export_download(),
- // which checks the nonce + capability + basename. The raw public
- // uploads URL is no longer exposed.
- $download_url = add_query_arg(
- array(
- 'page' => 'studiou-product-export',
- 'action' => 'studiou_wcpcm_download_product_export',
- 'file' => $filename,
- '_wpnonce' => wp_create_nonce('studiou-wcpcm-product-export-download'),
- ),
- admin_url('edit.php?post_type=product')
- );
- return array(
- 'success' => true,
- 'message' => sprintf(
- /* translators: %d: number of exported products */
- __('Export successful! %d products exported.', 'studiou-wc-product-cat-manage'),
- $row_count
- ),
- 'download_url' => $download_url
- );
- }
- }
|