PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / download-handler.php

download-handler.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/download-handler.php

833 lines 30.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use stdClass;
6 use StoreEngine\Addons\Subscription\Classes\Subscription;
7 use StoreEngine\Traits\Singleton;
8 use StoreEngine\Utils\Helper;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 class DownloadHandler {
15 use Singleton;
16
17 protected function __construct() {
18 // phpcs:disable WordPress.Security.NonceVerification.Recommended
19 if ( isset( $_GET['download_file'], $_GET['order'], $_GET['key'] ) && ( isset( $_GET['email'] ) || isset( $_GET['uid'] ) ) ) {
20 add_action( 'init', [ __CLASS__, 'download_product' ], - 1 );
21 }
22
23 if (
24 isset(
25 $_REQUEST['se_secure_dl'],
26 $_REQUEST['resource'],
27 $_REQUEST['type'],
28 $_REQUEST['X-SE-Signature'],
29 $_REQUEST['X-SE-Credential'],
30 $_REQUEST['X-SE-Date'],
31 $_REQUEST['X-SE-Expires'],
32 $_REQUEST['X-SE-SignedHeaders'],
33 $_REQUEST['X-SE-Algorithm']
34 )
35 ) {
36 add_action( 'init', [ __CLASS__, 'handle_secure_download' ], - 1 );
37 }
38 // phpcs:enable WordPress.Security.NonceVerification.Recommended
39 }
40
41 public static function handle_secure_download() {
42 $is_valid = UrlPresigner::init()->validateCurrentRequest();
43
44 if ( is_wp_error( $is_valid ) ) {
45 self::download_error( $is_valid->get_error_message(), '', rest_authorization_required_code() );
46 }
47
48 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
49 $se_secure_dl = absint( $_REQUEST['se_secure_dl'] );
50 $product = Helper::get_product( $se_secure_dl );
51 $resource = sanitize_text_field( wp_unslash( $_REQUEST['resource'] ) );
52 $dl_type = sanitize_text_field( wp_unslash( $_REQUEST['type'] ) );
53 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
54
55 if ( ! $product || empty( $resource ) || empty( $dl_type ) ) {
56 self::download_error( __( 'Invalid download link.', 'storeengine' ), '', rest_authorization_required_code() );
57 }
58
59 if ( 'publish' !== $product->get_status() ) {
60 self::download_error( __( 'File unavailable.', 'storeengine' ) );
61 }
62
63 if ( has_action( "storeengine/secure_download/{$dl_type}" ) ) {
64 try {
65 do_action( "storeengine/secure_download/{$dl_type}", $resource, $product );
66 } catch ( \StoreEngine\Classes\Exceptions\StoreEngineException $e ) {
67 $code = absint( $e->getCode() ?: 500 );
68 if ( 404 === $code ) {
69 self::download_error( __( 'No file defined', 'storeengine' ) );
70 }
71
72 self::download_error( $e->getMessage(), '', $code );
73 } catch ( \Exception $e ) {
74 self::download_error( $e->getMessage(), '', 500 );
75 }
76
77 // Error if not handled properly.
78 self::download_error(
79 __( 'Not Implemented', 'storeengine' ),
80 __( 'Not Implemented', 'storeengine' ),
81 501
82 );
83 } else {
84 /**
85 * Filter download filepath.
86 *
87 * @param string $file_data File path.
88 * @param string $email_address Email address.
89 * @param Order|bool $order Order object or false.
90 * @param AbstractProduct $product Product object.
91 * @param stdClass $download Download data.
92 */
93 $file_data = apply_filters( "storeengine/secure_downloads/$dl_type/file_data", [], $resource, $product );
94
95 if ( is_wp_error( $file_data ) ) {
96 $status = is_numeric( $file_data->get_error_code() ) ? $file_data->get_error_code() : 500;
97 self::download_error( $file_data->get_error_message(), '', $status );
98 }
99
100 if ( empty( $file_data['file_path'] ) || empty( $file_data['file_name'] ) ) {
101 self::download_error( __( 'No file defined', 'storeengine' ) );
102 }
103
104 self::download( $file_data['file_path'], $file_data['file_name'], $product->get_id() );
105 }
106 }
107
108 /**
109 * Check if we need to download a file and check validity.
110 */
111 public static function download_product() {
112 global $wpdb;
113 // phpcs:disable WordPress.Security.NonceVerification.Recommended
114 $product_id = absint( $_GET['download_file'] ?? 0 ); // phpcs:ignore WordPress.VIP.SuperGlobalInputUsage.AccessDetected, WordPress.VIP.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
115 $product = Helper::get_product( $product_id );
116 $key = sanitize_text_field( wp_unslash( $_GET['key'] ?? '' ) );
117 $order_key = sanitize_text_field( wp_unslash( $_GET['order'] ?? '' ) );
118 $email = sanitize_email( wp_unslash( $_GET['email'] ?? '' ) );
119 $uid = sanitize_text_field( wp_unslash( $_GET['uid'] ?? '' ) );
120 // phpcs:enable WordPress.Security.NonceVerification.Recommended
121 $downloadable_file = ! $product ? [] : array_filter( $product->get_downloadable_files(), fn( $download ) => $download['id'] === $key );
122 $downloadable_file = reset( $downloadable_file );
123 $order = Helper::get_order_by_key( $order_key );
124
125 if ( ! $product || empty( $key ) || is_wp_error( $order ) || empty( $downloadable_file ) || empty( $downloadable_file['enabled'] ) ) {
126 self::download_error( __( 'Invalid download link.', 'storeengine' ), '', rest_authorization_required_code() );
127 }
128
129 // Fallback, accept email address if it's passed.
130 if ( empty( $email ) && empty( $uid ) ) {
131 self::download_error( __( 'Invalid download link.', 'storeengine' ), '', rest_authorization_required_code() );
132 }
133
134 if ( ! $email && $uid ) {
135 $email = $order->get_billing_email();
136
137 if ( ! hash_equals( $uid, hash( 'sha256', $email ) ) ) {
138 self::download_error( __( 'Invalid download link.', 'storeengine' ), '', rest_authorization_required_code() );
139 }
140 }
141
142 // get_user_by-email would fail if a customer usage different billing email.
143 // Also permission table doesn't have email column.
144 // $user = get_user_by( 'email', $email );
145 //
146 // if ( ! $user ) {
147 // self::download_error( __( 'Invalid download link.', 'storeengine' ), '', rest_authorization_required_code() );
148 // }
149
150 // Don't check user id here. As you might want to share the link with others.
151 // And, settings might allow non-logged-in user's to download.
152 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
153 $download_permission = $wpdb->get_row(
154 $wpdb->prepare( "
155 SELECT *
156 FROM {$wpdb->prefix}storeengine_downloadable_product_permissions
157 WHERE
158 order_id = %d
159 AND download_id = %s
160 AND product_id = %d
161 ORDER BY id DESC
162 LIMIT 1;",
163 $order->get_id(),
164 $key,
165 $product->get_id()
166 )
167 );
168 // phpcs:enable WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
169
170 if (
171 (
172 $order->is_type( 'order' )
173 && ! ( $order->has_status( Helper::get_order_paid_statuses() ) || $order->is_paid() )
174 )
175 ||
176 ( $order->is_type( 'subscription' ) && ! $order->has_status( 'active' ) )
177 ) {
178 // @TODO use $order->set_download_permission_granted() in download-permission hook
179 // @TODO use $order->get_download_permission_granted() here to reduce condition.
180 self::download_error( __( 'Invalid download link.', 'storeengine' ), '', rest_authorization_required_code() );
181 }
182
183 /**
184 * Filter download filepath.
185 *
186 * @param string $file_path File path.
187 * @param string $email_address Email address.
188 * @param Order|bool $order Order object or false.
189 * @param AbstractProduct $product Product object.
190 * @param stdClass $download Download data.
191 */
192 $file_path = apply_filters( 'storeengine/downloads/product_filepath', $downloadable_file['file'], $email, $order, $product, $download_permission );
193
194 if ( ! $file_path ) {
195 self::download_error( __( 'No file defined', 'storeengine' ) );
196 }
197
198 $parsed_file_path = self::parse_file_path( $file_path );
199 //$download_range = self::get_download_range( @filesize( $parsed_file_path['file_path'] ) );
200
201 //if ( ! $download_range['is_range_request'] ) {
202 // @TODO If the remaining download count goes to 0, allow range requests to be able to finish streaming from iOS devices.
203 //self::check_downloads_remaining( $download );
204 //}
205
206 self::check_download_expiry( $download_permission );
207 self::check_download_login_required( $download_permission );
208
209 // Track the download in logs and change remaining/counts.
210 //$current_user_id = get_current_user_id();
211 //$ip_address = Helper::get_user_ip();
212
213 // @TODO: track download.
214 // self::track_download(
215 // $download_permission,
216 // $current_user_id > 0 ? $current_user_id : null,
217 // ! empty( $ip_address ) ? $ip_address : null,
218 // $download_range['is_range_request']
219 // );
220
221 self::download( $file_path, $downloadable_file['name'] ?? basename( $file_path ), $product->get_id() );
222 }
223
224 /**
225 * Check if the download has expired.
226 *
227 * @param stdClass $download_permission Download permission instance.
228 */
229 private static function check_download_expiry( stdClass $download_permission ) {
230 if ( ! is_null( $download_permission->access_expires ) && strtotime( $download_permission->access_expires ) < strtotime( 'midnight', time() ) ) {
231 self::download_error( __( 'Sorry, this download has expired', 'storeengine' ), '', 403 );
232 }
233 }
234
235 /**
236 * Check if a download requires the user to login first.
237 *
238 * @param stdClass $download_permission Download instance.
239 */
240 private static function check_download_login_required( stdClass $download_permission ) {
241 $user_id = (int) $download_permission->user_id;
242 if ( $user_id && Helper::get_settings( 'downloads_require_login', false ) ) {
243 if ( ! is_user_logged_in() ) {
244 if ( Helper::get_settings( 'dashboard_page' ) ) {
245 wp_safe_redirect( add_query_arg( 'storeengine_error', rawurlencode( __( 'You must be logged in to download files.', 'storeengine' ) ), Helper::get_dashboard_url() ) );
246 exit;
247 } else {
248 self::download_error( __( 'You must be logged in to download files.', 'storeengine' ) . ' <a href="' . esc_url( storeengine_login_url( Helper::get_dashboard_url() ) ) . '">' . __( 'Login', 'storeengine' ) . '</a>', __( 'Log in to Download Files', 'storeengine' ), 403 );
249 }
250 } elseif ( get_current_user_id() !== $user_id ) {
251 self::download_error( __( 'This is not your download link.', 'storeengine' ), '', 403 );
252 }
253 }
254 }
255
256 /**
257 * Download a file - hook into init function.
258 *
259 * @param string $file_path URL to file.
260 * @param ?string $filename Name of the file.
261 * @param ?int $product_id Product ID of the product being downloaded.
262 */
263 public static function download( string $file_path, ?string $filename = null, ?int $product_id = null ) {
264 if ( ! $filename ) {
265 $filename = basename( $file_path );
266 }
267
268 if ( strstr( $filename, '?' ) ) {
269 $filename = current( explode( '?', $filename ) );
270 }
271
272 $filename = apply_filters( 'storeengine/downloads/attachment_filename', $filename, $file_path, $product_id );
273
274 // Add action to prevent issues in IE.
275 add_action( 'nocache_headers', [ __CLASS__, 'ie_nocache_headers_fix' ] );
276
277 // Trigger download via one of the methods.
278 $file_download_method = apply_filters(
279 'storeengine/downloads/file_download_method',
280 Helper::get_settings( 'file_download_method', 'force' ),
281 $file_path,
282 $product_id
283 );
284
285 if ( 'xsendfile' !== $file_download_method ) {
286 self::download_file_force( $file_path, $filename );
287 return;
288 }
289
290 self::download_file_xsendfile( $file_path, $filename );
291 }
292
293 /**
294 * Download a file using X-Sendfile, X-Lighttpd-Sendfile, or X-Accel-Redirect if available.
295 *
296 * @param string $file_path File path.
297 * @param string $filename File name.
298 */
299 public static function download_file_xsendfile( string $file_path, string $filename ) {
300 $parsed_file_path = self::parse_file_path( $file_path );
301
302 /**
303 * Fallback on force download method for remote files. This is because:
304 * 1. xsendfile needs proxy configuration to work for remote files, which cannot be assumed to be available on most hosts.
305 * 2. Force download method is more secure than redirect method if `allow_url_fopen` is enabled in `php.ini`.
306 */
307 if ( $parsed_file_path['remote_file'] ) {
308 self::download_file_force( $file_path, $filename );
309 return;
310 }
311
312 // If file not inside secure-directory then X-Sendfile/X-Accel-Redirect will not work.
313 if ( ! str_starts_with( $parsed_file_path['file_path'], STOREENGINE_SECURE_UPLOADS_DIR ) ) {
314 // @XXX SE downloads should not be in regular uploads (e.g. uploads/yyyy/mm/file.ext),
315 // it should be moved to secure directory upon upload.
316 self::download_file_force( $file_path, $filename );
317 return;
318 }
319
320
321 if ( function_exists( 'apache_get_modules' ) && in_array( 'mod_xsendfile', apache_get_modules(), true ) ) {
322 self::download_headers( $parsed_file_path['file_path'], $filename );
323 header( 'X-Sendfile: ' . $parsed_file_path['file_path'] );
324 exit;
325 } elseif ( stristr( getenv( 'SERVER_SOFTWARE' ), 'lighttpd' ) ) {
326 self::download_headers( $parsed_file_path['file_path'], $filename );
327 header( 'X-Lighttpd-Sendfile: ' . $parsed_file_path['file_path'] );
328 exit;
329 } elseif ( stristr( getenv( 'SERVER_SOFTWARE' ), 'nginx' ) || stristr( getenv( 'SERVER_SOFTWARE' ), 'cherokee' ) ) {
330 self::download_headers( $parsed_file_path['file_path'], $filename );
331 $filepath = trim( preg_replace( '`^' . str_replace( '\\', '/', getcwd() ) . '`', '', $parsed_file_path['file_path'] ), '/' );
332 header( "X-Accel-Redirect: /$filepath" );
333 exit;
334 }
335
336 // Fallback.
337 Helper::log_error(
338 sprintf(
339 /* translators: %1$s contains the filepath of the digital asset. */
340 __( '%1$s could not be served using the X-Accel-Redirect/X-Sendfile method. A Force Download will be used instead.', 'storeengine' ),
341 $file_path
342 ),
343 false
344 );
345
346 self::download_file_force( $file_path, $filename );
347 }
348
349 /**
350 * Redirect to a file to start the download.
351 *
352 * @param string $file_path File path.
353 * @param string $filename File name.
354 */
355 public static function download_file_redirect( $file_path, $filename = '' ) {
356 header( 'Location: ' . $file_path );
357 exit;
358 }
359
360 /**
361 * Parse the HTTP_RANGE request from iOS devices.
362 * Does not support multi-range requests.
363 *
364 * @param int $file_size Size of file in bytes.
365 *
366 * @return array {
367 * Information about range download request: beginning and length of
368 * file chunk, whether the range is valid/supported and whether the request is a range request.
369 *
370 * @type int $start Byte offset of the beginning of the range. Default 0.
371 * @type int $length Length of the requested file chunk in bytes. Optional.
372 * @type bool $is_range_valid Whether the requested range is a valid and supported range.
373 * @type bool $is_range_request Whether the request is a range request.
374 * }
375 */
376 protected static function get_download_range( int $file_size ): array {
377 $start = 0;
378 $download_range = array(
379 'start' => $start,
380 'is_range_valid' => false,
381 'is_range_request' => false,
382 );
383
384 if ( ! $file_size ) {
385 return $download_range;
386 }
387
388 $end = $file_size - 1;
389 $download_range['length'] = $file_size;
390
391 if ( isset( $_SERVER['HTTP_RANGE'] ) ) {
392 $http_range = sanitize_text_field( wp_unslash( $_SERVER['HTTP_RANGE'] ) );
393 $download_range['is_range_request'] = true;
394
395 $c_start = $start;
396 $c_end = $end;
397 // Extract the range string.
398 list( , $range ) = explode( '=', $http_range, 2 );
399 // Make sure the client hasn't sent us a multibyte range.
400 if ( strpos( $range, ',' ) !== false ) {
401 return $download_range;
402 }
403
404 /*
405 * If the range starts with an '-' we start from the beginning.
406 * If not, we forward the file pointer
407 * and make sure to get the end byte if specified.
408 */
409 if ( '-' === $range[0] ) {
410 // The n-number of the last bytes is requested.
411 $c_start = $file_size - substr( $range, 1 );
412 } else {
413 $range = explode( '-', $range );
414 $c_start = ( isset( $range[0] ) && is_numeric( $range[0] ) ) ? (int) $range[0] : 0;
415 $c_end = ( isset( $range[1] ) && is_numeric( $range[1] ) ) ? (int) $range[1] : $file_size;
416 }
417
418 /*
419 * Check the range and make sure it's treated according to the specs: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html.
420 * End bytes can not be larger than $end.
421 */
422 $c_end = ( $c_end > $end ) ? $end : $c_end;
423 // Validate the requested range and return an error if it's not correct.
424 if ( $c_start > $c_end || $c_start > $file_size - 1 || $c_end >= $file_size ) {
425 return $download_range;
426 }
427 $start = $c_start;
428 $end = $c_end;
429 $length = $end - $start + 1;
430
431 $download_range['start'] = $start;
432 $download_range['length'] = $length;
433 $download_range['is_range_valid'] = true;
434 }
435
436 return $download_range;
437 }
438
439 /**
440 * Force download - this is the default method.
441 *
442 * @param string $file_path File path.
443 * @param string $filename File name.
444 */
445 public static function download_file_force( string $file_path, string $filename ) {
446 $parsed_file_path = self::parse_file_path( $file_path );
447 $download_range = self::get_download_range( @filesize( $file_path ) );
448
449 self::download_headers( $parsed_file_path['file_path'], $filename, $download_range );
450
451 $start = $download_range['start'] ?? 0;
452 $length = $download_range['length'] ?? 0;
453
454 if ( ! self::readfile_chunked( $parsed_file_path['file_path'], $start, $length ) ) {
455 if ( $parsed_file_path['remote_file'] ) {
456 self::download_file_redirect( $file_path );
457 } else {
458 self::download_error( __( 'File not found', 'storeengine' ) );
459 }
460 }
461
462 exit;
463 }
464
465 /**
466 * Parse file path and see if its remote or local.
467 *
468 * @param string $file_path File path.
469 *
470 * @return array
471 */
472 public static function parse_file_path( string $file_path ): array {
473 $wp_uploads = wp_upload_dir();
474 $wp_uploads_dir = $wp_uploads['basedir'];
475 $wp_uploads_url = $wp_uploads['baseurl'];
476
477 /**
478 * Replace uploads dir, site url etc with absolute counterparts if we can.
479 * Note the str_replace on site_url is on purpose, so if https is forced
480 * via filters we can still do the string replacement on a HTTP file.
481 */
482 $replacements = [
483 $wp_uploads_url => $wp_uploads_dir,
484 network_site_url( '/', 'https' ) => ABSPATH,
485 str_replace( 'https:', 'http:', network_site_url( '/', 'http' ) ) => ABSPATH,
486 site_url( '/', 'https' ) => ABSPATH,
487 str_replace( 'https:', 'http:', site_url( '/', 'http' ) ) => ABSPATH,
488 ];
489
490 $count = 0;
491 $file_path = str_replace( array_keys( $replacements ), array_values( $replacements ), $file_path, $count );
492 $parsed_file_path = wp_parse_url( $file_path );
493 $remote_file = null === $count || 0 === $count; // Remote file only if there were no replacements.
494
495 // Paths that begin with '//' are always remote URLs.
496 if ( '//' === substr( $file_path, 0, 2 ) ) {
497 $file_path = ( is_ssl() ? 'https:' : 'http:' ) . $file_path;
498
499 /**
500 * Filter the remote filepath for download.
501 *
502 * @param string $file_path File path.
503 *
504 * @since 6.5.0
505 */
506 return array(
507 'remote_file' => true,
508 'file_path' => apply_filters( 'storeengine/downloads/parse_remote_file_path', $file_path ),
509 );
510 }
511
512 // See if path needs an abspath prepended to work.
513 if ( file_exists( ABSPATH . $file_path ) ) {
514 $remote_file = false;
515 $file_path = ABSPATH . $file_path;
516 } elseif ( '/wp-content' === substr( $file_path, 0, 11 ) ) {
517 $remote_file = false;
518 $file_path = realpath( WP_CONTENT_DIR . substr( $file_path, 11 ) );
519
520 // Check if we have an absolute path.
521 } elseif ( ( ! isset( $parsed_file_path['scheme'] ) || ! in_array( $parsed_file_path['scheme'], [ 'http', 'https', 'ftp' ], true ) ) && isset( $parsed_file_path['path'] ) ) {
522 $remote_file = false;
523 $file_path = $parsed_file_path['path'];
524 }
525
526 /**
527 * Filter the filepath for download.
528 *
529 * @param string $file_path File path.
530 * @param bool $remote_file Remote File Indicator.
531 *
532 * @since 6.5.0
533 */
534 return [
535 'remote_file' => $remote_file,
536 'file_path' => apply_filters( 'storeengine/downloads/parse_file_path', $file_path, $remote_file ),
537 ];
538 }
539
540 /**
541 * Read file chunked.
542 *
543 * Reads file in chunks so big downloads are possible without changing PHP.INI - http://codeigniter.com/wiki/Download_helper_for_large_files/.
544 *
545 * @param string $file File.
546 * @param int $start Byte offset/position of the beginning from which to read from the file.
547 * @param int $length Length of the chunk to be read from the file in bytes, 0 means full file.
548 *
549 * @return bool Success or fail
550 */
551 protected static function readfile_chunked( string $file, $start = 0, $length = 0 ): bool {
552 // Never stream a local file that resolves outside the allowed roots.
553 // Blocks path-traversal (../../) or absolute-path downloadable "URLs"
554 // (e.g. /etc/passwd) from exfiltrating arbitrary server files.
555 if ( ! self::is_allowed_local_path( $file ) ) {
556 return false;
557 }
558
559 if ( ! defined( 'STOREENGINE_CHUNK_SIZE' ) ) {
560 define( 'STOREENGINE_CHUNK_SIZE', 1024 * 1024 );
561 }
562
563 // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged
564 $handle = @fopen( $file, 'r' ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_read_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fopen
565
566 if ( false === $handle ) {
567 return false;
568 }
569
570 if ( ! $length ) {
571 $length = @filesize( $file ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
572 }
573
574 $read_length = STOREENGINE_CHUNK_SIZE;
575
576 if ( $length ) {
577 $end = $start + $length - 1;
578
579 @fseek( $handle, $start ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
580 $p = @ftell( $handle ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
581
582 while ( ! @feof( $handle ) && $p <= $end ) { // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
583 // Don't run past the end of file.
584 if ( $p + $read_length > $end ) {
585 $read_length = $end - $p + 1;
586 }
587
588 echo @fread( $handle, $read_length ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.XSS.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_read_fread, WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread
589 $p = @ftell( $handle ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
590
591 if ( ob_get_length() ) {
592 ob_flush();
593 flush();
594 }
595 }
596 } else {
597 while ( ! @feof( $handle ) ) {
598 echo @fread( $handle, $read_length ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_fread
599 if ( ob_get_length() ) {
600 ob_flush();
601 flush();
602 }
603 }
604 }
605
606 return @fclose( $handle ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_read_fclose, WordPress.WP.AlternativeFunctions.file_system_operations_fclose
607 // phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged
608 }
609
610 /**
611 * Whether a resolved LOCAL file path is safe to serve.
612 *
613 * Canonicalises the path with realpath() and confirms it lives inside one of
614 * the allowed roots (wp-content, the uploads dir, or StoreEngine's secure
615 * uploads dir). Rejects traversal ("../") and absolute paths pointing anywhere
616 * else on the filesystem, closing arbitrary-file-read via a crafted
617 * downloadable file URL.
618 *
619 * ABSPATH is deliberately NOT an allowed root: legitimate product downloads
620 * live under uploads / wp-content, never in the WP install root. Whitelisting
621 * ABSPATH would let a resolved path point straight at core files such as
622 * wp-config.php (which sits inside ABSPATH by default). A known-sensitive
623 * denylist is applied on top as defence in depth, in case a host filters an
624 * over-broad root back in via `storeengine/downloads/allowed_roots`.
625 *
626 * @param string $file Absolute local file path (already parsed).
627 * @return bool True if the file may be served.
628 */
629 protected static function is_allowed_local_path( string $file ): bool {
630 if ( '' === $file ) {
631 return false;
632 }
633
634 $real = realpath( $file );
635 if ( false === $real ) {
636 return false; // Non-existent / unresolvable path.
637 }
638
639 // Deny known-sensitive files outright, even if they sit inside an allowed
640 // root — blocks config/secret files and dotfiles (.htaccess, .htpasswd, …).
641 $basename = strtolower( basename( $real ) );
642 $denied = [ 'wp-config.php', 'wp-config-sample.php', '.htaccess', '.htpasswd', '.user.ini', 'php.ini' ];
643 if ( in_array( $basename, $denied, true ) || str_starts_with( $basename, '.' ) ) {
644 return false;
645 }
646
647 $roots = [ WP_CONTENT_DIR ];
648 $uploads = wp_upload_dir();
649 if ( ! empty( $uploads['basedir'] ) ) {
650 $roots[] = $uploads['basedir'];
651 }
652 if ( defined( 'STOREENGINE_SECURE_UPLOADS_DIR' ) && STOREENGINE_SECURE_UPLOADS_DIR ) {
653 $roots[] = STOREENGINE_SECURE_UPLOADS_DIR;
654 }
655
656 /**
657 * Allow hosts to whitelist additional download roots (absolute paths).
658 *
659 * @param string[] $roots Allowed root directories.
660 */
661 $roots = (array) apply_filters( 'storeengine/downloads/allowed_roots', $roots );
662
663 foreach ( $roots as $root ) {
664 $root_real = realpath( $root );
665 if ( $root_real && str_starts_with( $real, rtrim( $root_real, '/\\' ) . DIRECTORY_SEPARATOR ) ) {
666 return true;
667 }
668 }
669
670 return false;
671 }
672
673 /**
674 * Filter headers for IE to fix issues over SSL.
675 *
676 * IE bug prevents download via SSL when Cache Control and Pragma no-cache headers set.
677 *
678 * @param array $headers HTTP headers.
679 *
680 * @return array
681 */
682 public static function ie_nocache_headers_fix( array $headers ): array {
683 if ( is_ssl() && ! empty( $GLOBALS['is_IE'] ) ) {
684 $headers['Cache-Control'] = 'private';
685 unset( $headers['Pragma'] );
686 }
687 return $headers;
688 }
689
690 /**
691 * Set headers for the download.
692 *
693 * @param string $file_path File path.
694 * @param string $filename File name.
695 * @param array $download_range Array containing info about range download request (see {@see get_download_range} for structure).
696 */
697 private static function download_headers( string $file_path, string $filename, array $download_range = [] ) {
698 // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky
699 if ( function_exists( 'set_time_limit' ) && false === strpos( ini_get( 'disable_functions' ), 'set_time_limit' ) && ! ini_get( 'safe_mode' ) ) { // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.safe_modeDeprecatedRemoved
700 @set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
701 }
702
703 if ( function_exists( 'apache_setenv' ) ) {
704 @apache_setenv( 'no-gzip', 1 ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_apache_setenv
705 }
706
707 @ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_ini_set, Squiz.PHP.DiscouragedFunctions.Discouraged
708 @session_write_close(); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, WordPress.VIP.SessionFunctionsUsage.session_session_write_close, Squiz.PHP.DiscouragedFunctions.Discouraged
709
710 if ( ob_get_level() ) {
711 $levels = ob_get_level();
712 for ( $i = 0; $i < $levels; $i ++ ) {
713 @ob_end_clean();
714 }
715 } else {
716 @ob_end_clean();
717 }
718
719 header( 'X-Robots-Tag: noindex, nofollow', true );
720 header( 'Content-Type: ' . self::get_download_content_type( $file_path ) );
721 header( 'Content-Description: File Transfer' );
722 header( 'Content-Disposition: attachment; filename="' . $filename . '";' );
723 header( 'Content-Transfer-Encoding: binary' );
724
725 $file_size = @filesize( $file_path );
726 if ( ! $file_size ) {
727 return;
728 }
729
730 if ( isset( $download_range['is_range_request'] ) && true === $download_range['is_range_request'] ) {
731 if ( false === $download_range['is_range_valid'] ) {
732 header( 'HTTP/1.1 416 Requested Range Not Satisfiable' );
733 header( 'Content-Range: bytes 0-' . ( $file_size - 1 ) . '/' . $file_size );
734 exit;
735 }
736
737 $start = $download_range['start'];
738 $end = $download_range['start'] + $download_range['length'] - 1;
739 $length = $download_range['length'];
740
741 header( 'HTTP/1.1 206 Partial Content' );
742 header( "Accept-Ranges: 0-$file_size" );
743 header( "Content-Range: bytes $start-$end/$file_size" );
744 header( "Content-Length: $length" );
745 } else {
746 header( 'Content-Length: ' . $file_size );
747 }
748 // phpcs:enable WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky
749 }
750
751 /**
752 * Get content type of a download.
753 *
754 * @param string $file_path File path.
755 *
756 * @return string
757 */
758 private static function get_download_content_type( $file_path ) {
759 $file_extension = strtolower( substr( strrchr( $file_path, '.' ), 1 ) );
760 $ctype = 'application/force-download';
761
762 foreach ( get_allowed_mime_types() as $mime => $type ) {
763 $mimes = explode( '|', $mime );
764 if ( in_array( $file_extension, $mimes, true ) ) {
765 $ctype = $type;
766 break;
767 }
768 }
769
770 return $ctype;
771 }
772
773 /**
774 * Die with an error message if the download fails.
775 *
776 * @param string $message Error message.
777 * @param string $title Error title.
778 * @param integer $status Error status.
779 */
780 public static function download_error( string $message, string $title = '', int $status = 404 ) {
781 /*
782 * Since we will now render a message instead of serving a download, we should unwind some of the previously set
783 * headers.
784 * Also if headers are already sent, log the issue.
785 */
786 $header_sent = headers_sent();
787 if ( ! $header_sent ) {
788 header( 'Content-Type: ' . get_option( 'html_type' ) . '; charset=' . get_option( 'blog_charset' ) );
789 header_remove( 'Content-Description;' );
790 header_remove( 'Content-Disposition' );
791 header_remove( 'Content-Transfer-Encoding' );
792 }
793
794 if ( ! $title ) {
795 $title = __( 'Error while processing file', 'storeengine' );
796 }
797
798 if ( 400 > $status ) {
799 $status = 500;
800 }
801
802 // translators: %s. Full url of the download request.
803 $req_uri = sprintf( __( 'Requested URL: %s', 'storeengine' ), sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated
804
805 $referrer_uri = null;
806 if ( ! empty( $_SERVER['HTTP_REFERER'] ) ) {
807 // translators: %s. HTTP Referrer.
808 $referrer_uri = sprintf( __( 'HTTP Referrer: %s', 'storeengine' ), sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) );
809 }
810
811 Logger::log(
812 'Download Failed: ' . $title,
813 [
814 'message' => $message,
815 'header_sent' => $header_sent,
816 'request' => $req_uri,
817 'referrer' => $referrer_uri,
818 'customer' => get_current_user_id(),
819 ],
820 Logger::INFO,
821 'system'
822 );
823
824 if ( ! strstr( $message, '<a ' ) ) {
825 $message .= ' <a href="' . esc_url( Helper::get_page_permalink( 'shop_page' ) ) . '">' . esc_html__( 'Go to shop', 'storeengine' ) . '</a>';
826 }
827
828 wp_die( wp_kses_post( $message ), esc_html( $title ), [ 'response' => $status ] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
829 }
830 }
831
832 // End of file download-handler.php.
833