filesystem_direct(); if (!$filesystem || !$filesystem->exists($path)) { BuyerErrors::render_panel( __('Download unavailable', '2download-connector'), __('The requested file could not be read.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 404 ); } $contents = $filesystem->get_contents($path); if ($contents === false) { BuyerErrors::render_panel( __('Download unavailable', '2download-connector'), __('The requested file could not be read.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 404 ); } // Streaming download response; do not escape raw file bytes. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo $contents; } public static function instance(): DownloadDelivery { if (!self::$instance) { self::$instance = new DownloadDelivery(); } return self::$instance; } private function __construct() { // Must run after WP parses the request/query vars. `init` fires before `wp()`. add_action('template_redirect', [$this, 'maybe_serve_download'], 0); add_filter('query_vars', function ($vars) { $vars[] = 'ToDownload_download'; $vars[] = 'ToDownload_download_all'; $vars[] = 'ToDownload_token'; $vars[] = 'ToDownload_order'; return $vars; }); add_action('init', function () { add_rewrite_rule('^2download/([0-9]+)/?$', 'index.php?ToDownload_download=$matches[1]', 'top'); add_rewrite_rule('^2download/all/?$', 'index.php?ToDownload_download_all=1', 'top'); }); } public static function generate_link(int $order_id, int $download_id): string { $order_public_id = Orders::order_public_id((int) $order_id); if ($order_public_id === '') { return home_url('/'); } $token = self::sign($order_public_id, $download_id); // Use index.php to avoid reliance on rewrite rules (and reduce caching interference). $base = home_url('/index.php'); return add_query_arg([ 'ToDownload_download' => $download_id, 'ToDownload_order' => $order_public_id, 'ToDownload_token' => $token, ], $base); } public static function generate_download_all_link(int $order_id): string { $order_public_id = Orders::order_public_id((int) $order_id); if ($order_public_id === '') { return home_url('/'); } $token = self::sign($order_public_id, 0); // Use index.php to avoid reliance on rewrite rules. $base = home_url('/index.php'); return add_query_arg([ 'ToDownload_download_all' => 1, 'ToDownload_order' => $order_public_id, 'ToDownload_token' => $token, ], $base); } private static function sign(string $order_public_id, int $download_id): string { $secret = wp_salt('auth'); $payload = $order_public_id . ':' . $download_id; return hash_hmac('sha256', $payload, $secret); } private static function verify(string $order_public_id, int $download_id, string $token): bool { return hash_equals(self::sign($order_public_id, $download_id), $token); } private function default_access_days_for_download(int $download_id): int { if ($download_id <= 0) { return 0; } $seller_user_id = (int) get_post_field('post_author', $download_id); if ($seller_user_id > 0) { $seller_days = Sellers::seller_default_access_days($seller_user_id); if ($seller_days !== null) { return max(0, (int) $seller_days); } } $settings = get_option('ToDownload_settings', []); $days = is_array($settings) ? (int) ($settings['default_access_days'] ?? 0) : 0; return max(0, $days); } public function maybe_serve_download(): void { // Prefer parsed query vars, but fall back to raw GET in case a host/plugin strips unknown vars. // Public download endpoint: requests are authorized via a signed token (HMAC), not a WP nonce. // phpcs:ignore WordPress.Security.NonceVerification.Recommended $get_download_id = isset($_GET['ToDownload_download']) ? absint(wp_unslash($_GET['ToDownload_download'])) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $get_download_all = isset($_GET['ToDownload_download_all']) ? absint(wp_unslash($_GET['ToDownload_download_all'])) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $get_order_public_id = isset($_GET['ToDownload_order']) ? sanitize_text_field((string) wp_unslash($_GET['ToDownload_order'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $get_token = isset($_GET['ToDownload_token']) ? sanitize_text_field((string) wp_unslash($_GET['ToDownload_token'])) : ''; $download_id = (int) (get_query_var('ToDownload_download', 0) ?: $get_download_id); $download_all = (int) (get_query_var('ToDownload_download_all', 0) ?: $get_download_all); $order_public_id = (string) (get_query_var('ToDownload_order', '') ?: $get_order_public_id); $token = (string) (get_query_var('ToDownload_token', '') ?: $get_token); $order_public_id = trim(sanitize_text_field($order_public_id)); $token = trim(sanitize_text_field($token)); if ((!$download_id && $download_all !== 1) || $order_public_id === '' || $token === '') { return; } $order = Orders::get_order_by_public_id($order_public_id); $order_id = $order ? (int) ($order['id'] ?? 0) : 0; if ($order_id <= 0) { BuyerErrors::render_panel( __('Order not found', '2download-connector'), __('We couldn’t find that order. Check your link and try again.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ['href' => home_url('/'), 'label' => __('Go to homepage', '2download-connector')], ], 404 ); } if ($download_all === 1) { if (!self::verify($order_public_id, 0, $token)) { BuyerErrors::render_panel( __('Invalid download link', '2download-connector'), __('This download link is invalid. Use the links in your receipt or account page.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ['href' => home_url('/'), 'label' => __('Go to homepage', '2download-connector')], ], 400 ); } $this->serve_download_all_zip($order_id); return; } if (!self::verify($order_public_id, $download_id, $token)) { BuyerErrors::render_panel( __('Invalid download link', '2download-connector'), __('This download link is invalid. Use the links in your receipt or account page.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ['href' => home_url('/'), 'label' => __('Go to homepage', '2download-connector')], ], 400 ); } if (!$order || $order['status'] !== 'complete') { BuyerErrors::render_panel( __('Order is not complete', '2download-connector'), __('Downloads are available once your order is complete.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 409 ); } // Track fulfillment separately from payment status: record when the customer first downloads. $notes = (string) ($order['notes'] ?? ''); if ($notes === '' || strpos($notes, 'ToDownload_first_download_at:') === false) { $ts = gmdate('Y-m-d H:i:s') . ' UTC'; Orders::append_order_note($order_id, 'ToDownload_first_download_at: ' . $ts); do_action('ToDownload_after_first_download', $order_id, $download_id); } // Authorization: // - Normal: order contains the requested download // - All Access: order contains an All Access Pass authored by the seller of the requested download $has_item = Orders::order_has_item($order_id, $download_id); $pass_id = 0; if (!$has_item) { $seller_user_id = (int) get_post_field('post_author', $download_id); if ($seller_user_id > 0) { $pass_id = Orders::get_all_access_pass_item_for_seller($order_id, $seller_user_id); } } if (!$has_item && $pass_id <= 0) { BuyerErrors::render_panel( __('Not allowed', '2download-connector'), __('This download isn’t included with your order.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 403 ); } // Access expiry (simple subscription-like behavior): // - Per-product meta `_ToDownload_access_days` takes precedence. // - Otherwise, global `ToDownload_settings[default_access_days]` is used. // Expiry: // - If downloading via All Access Pass, use the pass product's access days. // - Otherwise, use the item's access days (or global default). $days = 0; if ($pass_id > 0) { $days = (int) get_post_meta($pass_id, '_ToDownload_access_days', true); if ($days <= 0) { $days = $this->default_access_days_for_download($pass_id); } } else { $days = (int) get_post_meta($download_id, '_ToDownload_access_days', true); if ($days <= 0) { $days = $this->default_access_days_for_download($download_id); } } $days = max(0, $days); if ($days > 0) { $completed_at = (string) ($order['updated_at'] ?? $order['created_at'] ?? ''); $completed_ts = $completed_at !== '' ? strtotime($completed_at . ' UTC') : false; if ($completed_ts) { $expires_ts = $completed_ts + ($days * DAY_IN_SECONDS); $expires_ts = (int) apply_filters('ToDownload_download_expires_ts', $expires_ts, $order_id, $download_id, $order); if ($expires_ts > 0 && time() > $expires_ts) { BuyerErrors::render_panel( __('Link expired', '2download-connector'), __('This download link has expired. Request new links from your account page.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 410 ); } } } $file_id = (int) get_post_meta($download_id, '_ToDownload_file_attachment_id', true); if (!$file_id) { BuyerErrors::render_panel( __('File unavailable', '2download-connector'), __('This product does not have a downloadable file configured.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 404 ); } $path = get_attached_file($file_id); if (!$path || !file_exists($path) || !is_readable($path)) { BuyerErrors::render_panel( __('File not found', '2download-connector'), __('We couldn’t locate the file for this download.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 404 ); } do_action('ToDownload_before_file_download', $order_id, $download_id, $file_id); if (headers_sent()) { BuyerErrors::render_panel( __('Download error', '2download-connector'), __('Could not start the download due to a server error.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 500 ); } nocache_headers(); $filename = sanitize_file_name(basename($path)); $filename = str_replace(["\"", "\r", "\n"], '', (string) $filename); if ($filename === '') { $filename = 'download'; } header('Content-Description: File Transfer'); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('X-Content-Type-Options: nosniff'); $size = @filesize($path); if ($size !== false) { header('Content-Length: ' . (string) $size); } header('Cache-Control: private'); $this->output_file_contents($path); exit; } private function serve_download_all_zip(int $order_id): void { if (!class_exists('ZipArchive')) { BuyerErrors::render_panel( __('Download unavailable', '2download-connector'), __('Zip downloads are not available on this server.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 501 ); } $order = Orders::get_order($order_id); if (!$order || ($order['status'] ?? '') !== 'complete') { BuyerErrors::render_panel( __('Order is not complete', '2download-connector'), __('Downloads are available once your order is complete.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 409 ); } // Track fulfillment separately from payment status: record first download time. $notes = (string) ($order['notes'] ?? ''); if ($notes === '' || strpos($notes, 'ToDownload_first_download_at:') === false) { $ts = gmdate('Y-m-d H:i:s') . ' UTC'; Orders::append_order_note($order_id, 'ToDownload_first_download_at: ' . $ts); do_action('ToDownload_after_first_download', $order_id, 0); } $items = Orders::get_order_items($order_id); if (empty($items)) { BuyerErrors::render_panel( __('No items found', '2download-connector'), __('We couldn’t find any items for this order.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 500 ); } $tmp = tempnam(sys_get_temp_dir(), 'todownload_zip_'); if (!$tmp || !is_string($tmp)) { BuyerErrors::render_panel( __('Download error', '2download-connector'), __('Could not prepare zip file for download.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 500 ); } // ZipArchive can write to any valid path; no rename needed. $zip_path = $tmp; $zip = new \ZipArchive(); $opened = $zip->open($zip_path, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); if ($opened !== true) { wp_delete_file($zip_path); BuyerErrors::render_panel( __('Download error', '2download-connector'), __('Could not create zip file for download.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 500 ); } $added = 0; $seen_names = []; $completed_at = (string) ($order['updated_at'] ?? $order['created_at'] ?? ''); $completed_ts = $completed_at !== '' ? strtotime($completed_at . ' UTC') : false; foreach ($items as $it) { $download_id = (int) ($it['download_id'] ?? 0); if ($download_id <= 0) { continue; } // Respect access expiry for each item (same logic as single-file). $days = (int) get_post_meta($download_id, '_ToDownload_access_days', true); if ($days <= 0) { $days = $this->default_access_days_for_download($download_id); } $days = max(0, $days); if ($days > 0 && $completed_ts) { $expires_ts = (int) ($completed_ts + ($days * DAY_IN_SECONDS)); $expires_ts = (int) apply_filters('ToDownload_download_expires_ts', $expires_ts, $order_id, $download_id, $order); if ($expires_ts > 0 && time() > $expires_ts) { continue; } } $file_id = (int) get_post_meta($download_id, '_ToDownload_file_attachment_id', true); if ($file_id <= 0) { continue; } $path = get_attached_file($file_id); if (!$path || !is_string($path) || !file_exists($path) || !is_readable($path)) { continue; } $title = (string) get_the_title($download_id); $base = $title !== '' ? $title : ('download-' . (string) $download_id); $base = sanitize_file_name($base); $ext = pathinfo($path, PATHINFO_EXTENSION); $entry = $base; if ($ext !== '') { $entry .= '.' . $ext; } if ($entry === '') { $entry = basename($path); } $entry = $entry !== '' ? $entry : ('download-' . (string) $download_id); // Ensure unique names inside the zip. $final = $entry; $counter = 2; while (isset($seen_names[$final])) { $dot = strrpos($entry, '.'); if ($dot !== false) { $final = substr($entry, 0, $dot) . '-' . $counter . substr($entry, $dot); } else { $final = $entry . '-' . $counter; } $counter++; } $seen_names[$final] = true; $ok = $zip->addFile($path, $final); if ($ok) { $added++; } } $zip->close(); if ($added <= 0) { wp_delete_file($zip_path); BuyerErrors::render_panel( __('No files available', '2download-connector'), __('No downloadable files are available for this order.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 404 ); } do_action('ToDownload_before_file_download_all', $order_id); if (headers_sent()) { wp_delete_file($zip_path); BuyerErrors::render_panel( __('Download error', '2download-connector'), __('Could not start the download due to a server error.', '2download-connector'), [ ['href' => BuyerErrors::account_url(), 'label' => __('Back to Account', '2download-connector'), 'primary' => true], ], 500 ); } nocache_headers(); $filename = '2download-order-' . (string) $order_id . '.zip'; header('Content-Description: File Transfer'); header('Content-Type: application/zip'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('X-Content-Type-Options: nosniff'); header('Content-Length: ' . (string) filesize($zip_path)); header('Cache-Control: private'); $this->output_file_contents($zip_path); wp_delete_file($zip_path); exit; } }