DownloadHandler.php
5 months ago
DownloadService.php
3 years ago
Init.php
3 years ago
UploadHandler.php
11 hours ago
index.php
3 years ago
DownloadHandler.php
504 lines
| 1 | <?php |
| 2 | |
| 3 | namespace ProfilePress\Core\Membership\DigitalProducts; |
| 4 | |
| 5 | use ProfilePress\Core\Membership\Models\Customer\CustomerFactory; |
| 6 | use ProfilePress\Core\Membership\Models\Order\OrderFactory; |
| 7 | |
| 8 | class DownloadHandler |
| 9 | { |
| 10 | public function __construct() |
| 11 | { |
| 12 | add_action('init', [$this, 'process_download'], 100); |
| 13 | } |
| 14 | |
| 15 | /** |
| 16 | * //[0=> 'order ID', 1=> 'plan ID', 2 => 'file index'] |
| 17 | * @return array|false |
| 18 | */ |
| 19 | private function get_download_url_parts() |
| 20 | { |
| 21 | $parts = parse_url(add_query_arg([])); |
| 22 | wp_parse_str($parts['query'], $query_args); |
| 23 | $url = add_query_arg($query_args, site_url()); |
| 24 | |
| 25 | $valid_token = DownloadService::init()->validate_url_token($url); |
| 26 | |
| 27 | if ( ! $valid_token) return false; |
| 28 | |
| 29 | //[0=> 'order ID', 1=> 'plan ID', 2 => 'file index'] |
| 30 | return explode(':', rawurldecode($_GET['ppress_file'])); |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * Used to process a signed URL for processing downloads |
| 35 | * |
| 36 | * @return array|false |
| 37 | */ |
| 38 | private function process_signed_download_url() |
| 39 | { |
| 40 | $order_parts = $this->get_download_url_parts(); |
| 41 | |
| 42 | if ( ! $order_parts) return false; |
| 43 | |
| 44 | $order_id = isset($order_parts[0]) ? (int)$order_parts[0] : null; |
| 45 | $plan_id = isset($order_parts[1]) ? (int)$order_parts[1] : null; |
| 46 | $file_index = isset($order_parts[2]) ? (int)$order_parts[2] : null; |
| 47 | |
| 48 | $downloads = ppress_get_plan($plan_id)->get_downloads(); |
| 49 | |
| 50 | $order = OrderFactory::fromId($order_id); |
| 51 | |
| 52 | $file_url = ''; |
| 53 | |
| 54 | if (isset($downloads['files']) && is_array($downloads['files'])) { |
| 55 | $file_url = key(array_slice($downloads['files'], intval($file_index), 1)); |
| 56 | } |
| 57 | |
| 58 | $has_access = false; |
| 59 | |
| 60 | if ($this->is_admin_initiated_downloads($order_id, $file_url)) { |
| 61 | $has_access = true; |
| 62 | } else { |
| 63 | |
| 64 | // Check to make sure not at download limit |
| 65 | if (DownloadService::init()->is_file_at_download_limit($plan_id, $order_id, $file_url)) { |
| 66 | wp_die(apply_filters('ppress_download_limit_reached_text', __('Sorry but you have hit your download limit for this file.', 'wp-user-avatar')), __('Error', 'wp-user-avatar'), array('response' => 403)); |
| 67 | } |
| 68 | |
| 69 | if ( |
| 70 | apply_filters('ppress_file_downloads_subscription_check', true) && |
| 71 | ! CustomerFactory::fromId($order->customer_id)->has_active_subscription($plan_id) |
| 72 | ) { |
| 73 | wp_die( |
| 74 | sprintf( |
| 75 | __('You must have an active subscription to %s in order to download this file.', 'wp-user-avatar'), |
| 76 | ppress_get_plan($plan_id)->get_name() |
| 77 | ), |
| 78 | __('Access Denied', 'wp-user-avatar') |
| 79 | ); |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | if ($plan_id == $order->plan_id && $order->is_completed()) { |
| 84 | |
| 85 | $get_downloads = ppress_get_plan($plan_id)->get_downloads(); |
| 86 | |
| 87 | if ( |
| 88 | isset($get_downloads['files']) && |
| 89 | ! is_null($file_index) && |
| 90 | ! empty(array_slice($get_downloads['files'], intval($file_index), 1)) |
| 91 | ) { |
| 92 | $has_access = true; |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | return [ |
| 97 | 'has_access' => $has_access, |
| 98 | 'order_id' => $order_id, |
| 99 | 'plan_id' => $plan_id, |
| 100 | 'file_index' => $file_index, |
| 101 | 'requested_file' => $file_url |
| 102 | ]; |
| 103 | } |
| 104 | |
| 105 | private function get_download_content_type($file_path) |
| 106 | { |
| 107 | $file_extension = strtolower(substr(strrchr($file_path, '.'), 1)); |
| 108 | $ctype = 'application/force-download'; |
| 109 | |
| 110 | foreach (get_allowed_mime_types() as $mime => $type) { |
| 111 | $mimes = explode('|', $mime); |
| 112 | if (in_array($file_extension, $mimes, true)) { |
| 113 | $ctype = $type; |
| 114 | break; |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | return $ctype; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Determines if a file should be allowed to be downloaded by making sure it's within the wp-content directory. |
| 123 | * |
| 124 | * @param $requested_file |
| 125 | * |
| 126 | * @return boolean |
| 127 | */ |
| 128 | private function local_file_location_is_allowed($requested_file) |
| 129 | { |
| 130 | $file_details = parse_url($requested_file); |
| 131 | |
| 132 | $should_allow = true; |
| 133 | |
| 134 | // If the file is an absolute path, make sure it's in the wp-content directory, to prevent store owners from accidentally allowing privileged files from being downloaded. |
| 135 | if (( ! isset($file_details['scheme']) || ! in_array($file_details['scheme'], ['http', 'https'])) && isset($file_details['path'])) { |
| 136 | |
| 137 | /** This is an absolute path */ |
| 138 | $requested_file = wp_normalize_path(realpath($requested_file)); |
| 139 | $normalized_abspath = wp_normalize_path(ABSPATH); |
| 140 | $normalized_content_dir = wp_normalize_path(WP_CONTENT_DIR); |
| 141 | |
| 142 | if (0 !== strpos($requested_file, $normalized_abspath) || false === strpos($requested_file, $normalized_content_dir)) { |
| 143 | // If the file is not within the WP_CONTENT_DIR, it should not be able to be downloaded. |
| 144 | $should_allow = false; |
| 145 | } |
| 146 | |
| 147 | } |
| 148 | |
| 149 | return $should_allow; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Determine if the file being requested is hosted locally or not |
| 154 | * |
| 155 | * @param string $requested_file The file being requested |
| 156 | * |
| 157 | * @return bool If the file is hosted locally or not |
| 158 | */ |
| 159 | private function is_local_file($requested_file) |
| 160 | { |
| 161 | $site_url = preg_replace('#^https?://#', '', site_url()); |
| 162 | $requested_file = preg_replace('#^(https?|file)://#', '', $requested_file); |
| 163 | |
| 164 | $is_local_url = strpos($requested_file, $site_url) === 0; |
| 165 | $is_local_path = strpos($requested_file, '/') === 0; |
| 166 | |
| 167 | return ($is_local_url || $is_local_path); |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Reads file in chunks so big downloads are possible without changing PHP.INI |
| 172 | * |
| 173 | * See https://github.com/bcit-ci/CodeIgniter/wiki/Download-helper-for-large-files |
| 174 | * |
| 175 | * @param string $file The file |
| 176 | * @param boolean $retbytes Return the bytes of file |
| 177 | * |
| 178 | * @return bool|string If string, $status || $cnt |
| 179 | */ |
| 180 | private function readfile_chunked($file, $retbytes = true) |
| 181 | { |
| 182 | while (ob_get_level() > 0) { |
| 183 | ob_end_clean(); |
| 184 | } |
| 185 | |
| 186 | ob_start(); |
| 187 | |
| 188 | // If output buffers exist, make sure they are closed. |
| 189 | if (ob_get_length()) ob_clean(); |
| 190 | |
| 191 | $chunksize = 1024 * 1024; |
| 192 | $cnt = 0; |
| 193 | $handle = @fopen($file, 'r'); |
| 194 | |
| 195 | if ($size = @filesize($file)) { |
| 196 | header("Content-Length: " . $size); |
| 197 | } |
| 198 | |
| 199 | if (false === $handle) { |
| 200 | return false; |
| 201 | } |
| 202 | |
| 203 | if (isset($_SERVER['HTTP_RANGE'])) { |
| 204 | list($size_unit, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2); |
| 205 | if ('bytes' === $size_unit) { |
| 206 | if (strpos(',', $range)) { |
| 207 | list($range) = explode(',', $range, 1); |
| 208 | } |
| 209 | } else { |
| 210 | header('HTTP/1.1 416 Requested Range Not Satisfiable'); |
| 211 | exit; |
| 212 | } |
| 213 | } else { |
| 214 | $range = ''; |
| 215 | } |
| 216 | |
| 217 | if (empty($range)) { |
| 218 | $seek_start = null; |
| 219 | $seek_end = null; |
| 220 | } else { |
| 221 | list($seek_start, $seek_end) = explode('-', $range, 2); |
| 222 | } |
| 223 | |
| 224 | $seek_end = (empty($seek_end)) ? ($size - 1) : min(abs(intval($seek_end)), ($size - 1)); |
| 225 | $seek_start = (empty($seek_start) || $seek_end < abs(intval($seek_start))) ? 0 : max(abs(intval($seek_start)), 0); |
| 226 | |
| 227 | // Only send partial content header if downloading a piece of the file (IE workaround) |
| 228 | if ($seek_start > 0 || $seek_end < ($size - 1)) { |
| 229 | header(sprintf('%s 206 Partial Content', ppress_var($_SERVER, 'SERVER_PROTOCOL', 'HTTP/1.1', true))); |
| 230 | header('Content-Range: bytes ' . $seek_start . '-' . $seek_end . '/' . $size); |
| 231 | header('Content-Length: ' . ($seek_end - $seek_start + 1)); |
| 232 | } else { |
| 233 | header("Content-Length: $size"); |
| 234 | } |
| 235 | |
| 236 | header('Accept-Ranges: bytes'); |
| 237 | |
| 238 | ppress_set_time_limit(false); |
| 239 | |
| 240 | fseek($handle, $seek_start); |
| 241 | |
| 242 | while ( ! @feof($handle)) { |
| 243 | $buffer = @fread($handle, $chunksize); |
| 244 | echo $buffer; |
| 245 | ob_flush(); |
| 246 | |
| 247 | if (ob_get_length()) { |
| 248 | ob_flush(); |
| 249 | flush(); |
| 250 | } |
| 251 | |
| 252 | if ($retbytes) { |
| 253 | $cnt += strlen($buffer); |
| 254 | } |
| 255 | |
| 256 | if (connection_status() != 0) { |
| 257 | @fclose($handle); |
| 258 | exit; |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | ob_flush(); |
| 263 | |
| 264 | $status = @fclose($handle); |
| 265 | |
| 266 | if ($retbytes && $status) return $cnt; |
| 267 | |
| 268 | return $status; |
| 269 | } |
| 270 | |
| 271 | /** |
| 272 | * Deliver the download file |
| 273 | * |
| 274 | * If enabled, the file is symlinked to better support large file downloads |
| 275 | * |
| 276 | * @param string $file |
| 277 | * @param bool $redirect True if we should perform a header redirect |
| 278 | * |
| 279 | * @return void |
| 280 | */ |
| 281 | private function deliver_download($file = '', $redirect = false) |
| 282 | { |
| 283 | if ($redirect) { |
| 284 | header('Location: ' . $file); |
| 285 | } else { |
| 286 | $this->readfile_chunked($file); |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | /** |
| 291 | * |
| 292 | * Important so admins can always download files |
| 293 | * |
| 294 | * @param $order_id |
| 295 | * @param $file_url |
| 296 | * |
| 297 | * @return bool |
| 298 | */ |
| 299 | private function is_admin_initiated_downloads($order_id, $file_url = '') |
| 300 | { |
| 301 | if (is_user_logged_in() && current_user_can('manage_options')) { |
| 302 | |
| 303 | $order = OrderFactory::fromId($order_id); |
| 304 | |
| 305 | $user_id = $order->get_customer()->get_user_id(); |
| 306 | |
| 307 | // order was made by admin, log the download |
| 308 | if ($user_id > 0 && get_current_user_id() === $user_id) { |
| 309 | if ( ! empty($file_url)) { |
| 310 | DownloadService::init()->add_download_log([ |
| 311 | 'plan_id' => $order->get_plan_id(), |
| 312 | 'file_url' => $file_url, |
| 313 | 'order_id' => $order_id |
| 314 | ]); |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | return true; |
| 319 | } |
| 320 | |
| 321 | return false; |
| 322 | } |
| 323 | |
| 324 | public function process_download() |
| 325 | { |
| 326 | if (empty($_GET['ppress_file']) || empty($_GET['ttl']) || empty($_GET['token'])) return; |
| 327 | |
| 328 | if ('true' === ppress_get_file_downloads_setting('access_restriction')) { |
| 329 | if ( ! is_user_logged_in()) { |
| 330 | wp_die(__('You must be logged in to download files.', 'wp-user-avatar') . ' <a href="' . esc_url(wp_login_url(ppress_get_current_url_query_string())) . '">' . __('Login', 'wp-user-avatar') . '</a>', __('Log in to Download Files', 'wp-user-avatar'), 403); |
| 331 | } |
| 332 | |
| 333 | $order_parts = $this->get_download_url_parts(); |
| 334 | |
| 335 | $order = OrderFactory::fromId($order_parts[0]); |
| 336 | |
| 337 | if ($order->get_customer()->get_user_id() !== get_current_user_id()) { |
| 338 | wp_die(__('You are not allowed to access this file', 'wp-user-avatar'), __('File download error', 'wp-user-avatar'), 403); |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | $args = $this->process_signed_download_url(); |
| 343 | |
| 344 | if ( ! $args['has_access']) { |
| 345 | $error_message = __('You do not have permission to download this file', 'wp-user-avatar'); |
| 346 | wp_die(apply_filters('ppress_deny_download_message', $error_message, __('Order Verification Failed', 'wp-user-avatar')), __('Error', 'wp-user-avatar'), ['response' => 403]); |
| 347 | } |
| 348 | |
| 349 | $method = $raw_method = ppress_get_file_downloads_setting('download_method', 'direct', true); |
| 350 | |
| 351 | $requested_file = $args['requested_file']; |
| 352 | |
| 353 | $file_details = parse_url($requested_file); |
| 354 | $schemes = ['http', 'https']; // Direct URL schemes |
| 355 | |
| 356 | if (strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN' && isset($file_details['scheme']) && ! in_array($file_details['scheme'], stream_get_wrappers())) { |
| 357 | wp_die(__('Error 103: Error downloading file. Please contact support.', 'wp-user-avatar'), __('File download error', 'wp-user-avatar'), 501); |
| 358 | } |
| 359 | |
| 360 | if ( |
| 361 | ( ! isset($file_details['scheme']) || ! in_array($file_details['scheme'], $schemes)) && |
| 362 | isset($file_details['path']) && file_exists($requested_file) |
| 363 | ) { |
| 364 | /** |
| 365 | * Download method is set to Redirect in settings but an absolute path was provided |
| 366 | * We need to switch to a direct download in order for the file to download properly |
| 367 | */ |
| 368 | $method = 'direct'; |
| 369 | } |
| 370 | |
| 371 | $file_is_in_allowed_location = $this->local_file_location_is_allowed($requested_file); |
| 372 | |
| 373 | if (false === $file_is_in_allowed_location) { |
| 374 | wp_die(__('Sorry, this file could not be downloaded.', 'wp-user-avatar'), __('Error Downloading File', 'wp-user-avatar'), 403); |
| 375 | } |
| 376 | |
| 377 | if ( ! $this->is_admin_initiated_downloads($args['order_id'])) { |
| 378 | DownloadService::init()->add_download_log([ |
| 379 | 'plan_id' => $args['plan_id'], |
| 380 | 'file_url' => $requested_file, |
| 381 | 'order_id' => $args['order_id'] |
| 382 | ]); |
| 383 | } |
| 384 | |
| 385 | ppress_set_time_limit(false); |
| 386 | |
| 387 | if (function_exists('apache_setenv')) { |
| 388 | @apache_setenv('no-gzip', 1); |
| 389 | } |
| 390 | @ini_set('zlib.output_compression', 'Off'); |
| 391 | @session_write_close(); |
| 392 | |
| 393 | nocache_headers(); |
| 394 | header('Robots: none'); |
| 395 | header('X-Robots-Tag: noindex, nofollow', true); |
| 396 | header('Content-Type: ' . $this->get_download_content_type($requested_file)); |
| 397 | header('Content-Description: File Transfer'); |
| 398 | header('Content-Disposition: attachment; filename="' . basename($requested_file) . '"'); |
| 399 | header('Content-Transfer-Encoding: binary'); |
| 400 | |
| 401 | // If the file isn't locally hosted, process the redirect |
| 402 | if (filter_var($requested_file, FILTER_VALIDATE_URL) && ! $this->is_local_file($requested_file)) { |
| 403 | $this->deliver_download($requested_file, true); |
| 404 | exit; |
| 405 | } |
| 406 | |
| 407 | if ($method == 'redirect') { |
| 408 | // Redirect straight to the file |
| 409 | $this->deliver_download($requested_file, true); |
| 410 | exit; |
| 411 | } |
| 412 | |
| 413 | $direct = false; |
| 414 | $file_path = $requested_file; |
| 415 | |
| 416 | if (( ! isset($file_details['scheme']) || ! in_array($file_details['scheme'], $schemes)) && isset($file_details['path']) && file_exists($requested_file)) { |
| 417 | /** This is an absolute path */ |
| 418 | $direct = true; |
| 419 | } elseif (defined('UPLOADS') && strpos($requested_file, UPLOADS) !== false) { |
| 420 | /** |
| 421 | * This is a local file given by URL so we need to figure out the path |
| 422 | * UPLOADS is always relative to ABSPATH |
| 423 | * site_url() is the URL to where WordPress is installed |
| 424 | */ |
| 425 | $file_path = str_replace(site_url(), '', $requested_file); |
| 426 | $file_path = realpath(ABSPATH . $file_path); |
| 427 | $direct = true; |
| 428 | } elseif (strpos($requested_file, content_url()) !== false) { |
| 429 | /** This is a local file given by URL so we need to figure out the path */ |
| 430 | $file_path = str_replace(content_url(), WP_CONTENT_DIR, $requested_file); |
| 431 | $file_path = realpath($file_path); |
| 432 | $direct = true; |
| 433 | } elseif (strpos($requested_file, set_url_scheme(content_url(), 'https')) !== false) { |
| 434 | /** This is a local file given by an HTTPS URL so we need to figure out the path */ |
| 435 | $file_path = str_replace(set_url_scheme(content_url(), 'https'), WP_CONTENT_DIR, $requested_file); |
| 436 | $file_path = realpath($file_path); |
| 437 | $direct = true; |
| 438 | } |
| 439 | |
| 440 | // Set the file size header |
| 441 | header("Content-Length: " . @filesize($file_path)); |
| 442 | |
| 443 | $server_software = getenv('SERVER_SOFTWARE'); |
| 444 | |
| 445 | if ($raw_method == 'xsendfile') { |
| 446 | |
| 447 | if ( |
| 448 | function_exists('apache_get_modules') && in_array('mod_xsendfile', apache_get_modules(), true) || |
| 449 | stristr($server_software, 'apache') |
| 450 | ) { |
| 451 | // https://tn123.org/mod_xsendfile/ |
| 452 | header('X-Sendfile: ' . $file_path); |
| 453 | exit; |
| 454 | } |
| 455 | |
| 456 | if (stristr($server_software, 'litespeed')) { |
| 457 | // We need a path relative to the domain |
| 458 | $file_path = trim(str_ireplace(realpath($_SERVER['DOCUMENT_ROOT']), '', $file_path), '/'); |
| 459 | // https://www.litespeedtech.com/support/wiki/doku.php/litespeed_wiki:config:internal-redirect |
| 460 | header("X-LiteSpeed-Location: /$file_path"); |
| 461 | exit; |
| 462 | } |
| 463 | |
| 464 | if (stristr($server_software, 'lighttpd')) { |
| 465 | // apparently, X-Sendfile also work as X-LIGHTTPD-send-file is an alias that has been deprecated though still work |
| 466 | // see https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_ModFastCGI#X-Sendfile |
| 467 | header("X-LIGHTTPD-send-file: $file_path"); |
| 468 | exit; |
| 469 | } |
| 470 | |
| 471 | if ($direct && (stristr($server_software, 'nginx') || stristr($server_software, 'cherokee'))) { |
| 472 | // We need a path relative to the domain |
| 473 | $file_path = trim(str_ireplace(realpath($_SERVER['DOCUMENT_ROOT']), '', $file_path), '/'); |
| 474 | // https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile/ |
| 475 | // https://cherokee-project.com/doc/other_goodies.html#x-sendfile |
| 476 | header("X-Accel-Redirect: /$file_path"); |
| 477 | exit; |
| 478 | } |
| 479 | |
| 480 | ppress_log_error('X-Sendfile file download method is not working.'); |
| 481 | } |
| 482 | |
| 483 | if ($direct) { |
| 484 | $this->deliver_download($file_path); |
| 485 | } else { |
| 486 | |
| 487 | // The file supplied does not have a discoverable absolute path |
| 488 | $this->deliver_download($requested_file, true); |
| 489 | } |
| 490 | |
| 491 | exit; |
| 492 | } |
| 493 | |
| 494 | public static function get_instance() |
| 495 | { |
| 496 | static $instance = null; |
| 497 | |
| 498 | if (is_null($instance)) { |
| 499 | $instance = new self(); |
| 500 | } |
| 501 | |
| 502 | return $instance; |
| 503 | } |
| 504 | } |