TransientFilesEngine.php
547 lines
| 1 | <?php |
| 2 | |
| 3 | namespace Automattic\WooCommerce\Internal\TransientFiles; |
| 4 | |
| 5 | use \DateTime; |
| 6 | use \Exception; |
| 7 | use \InvalidArgumentException; |
| 8 | use Automattic\WooCommerce\Internal\RegisterHooksInterface; |
| 9 | use Automattic\WooCommerce\Proxies\LegacyProxy; |
| 10 | use Automattic\WooCommerce\Utilities\TimeUtil; |
| 11 | |
| 12 | /** |
| 13 | * Transient files engine class. |
| 14 | * |
| 15 | * This class contains methods that allow creating files that have an expiration date. |
| 16 | * |
| 17 | * A transient file is created by invoking the create_transient_file method, which accepts the file contents |
| 18 | * and the expiration date as arguments. Transient file names are composed by concatenating the expiration date |
| 19 | * encoded in hexadecimal (3 digits for the year, 1 for the month and 2 for the day) and a random string |
| 20 | * of hexadecimal digits. |
| 21 | * |
| 22 | * Transient files are stored in a directory whose default route is |
| 23 | * wp-content/uploads/woocommerce_transient_files/yyyy-mm-dd, where "yyyy-mm-dd" is the expiration date |
| 24 | * (year, month and day). The base route (minus the expiration date part) can be changed via a dedicated hook. |
| 25 | * |
| 26 | * Transient files that haven't expired (the expiration date is today or in the future) can be obtained remotely |
| 27 | * via a dedicated URL, <server root>/wc/file/transient/<file name>. This URL is public (no authentication is required). |
| 28 | * The content type of the response will always be "text/html". |
| 29 | * |
| 30 | * Cleanup of expired files is handled by the delete_expired_files method, which can be invoked manually |
| 31 | * but there's a dedicated scheduled action that will invoke it that can be started and stopped via a dedicated tool |
| 32 | * available in the WooCommerce tools page. The action runs once per day but this can be customized |
| 33 | * via a dedicated hook. |
| 34 | */ |
| 35 | class TransientFilesEngine implements RegisterHooksInterface { |
| 36 | |
| 37 | private const CLEANUP_ACTION_NAME = 'woocommerce_expired_transient_files_cleanup'; |
| 38 | private const CLEANUP_ACTION_GROUP = 'wc_batch_processes'; |
| 39 | |
| 40 | /** |
| 41 | * The instance of LegacyProxy to use. |
| 42 | * |
| 43 | * @var LegacyProxy |
| 44 | */ |
| 45 | private $legacy_proxy; |
| 46 | |
| 47 | /** |
| 48 | * Register hooks. |
| 49 | */ |
| 50 | public function register() { |
| 51 | add_action( self::CLEANUP_ACTION_NAME, array( $this, 'handle_expired_files_cleanup_action' ) ); |
| 52 | add_filter( 'woocommerce_debug_tools', array( $this, 'add_debug_tools_entries' ), 999, 1 ); |
| 53 | |
| 54 | add_action( 'init', array( $this, 'add_endpoint' ), 0 ); |
| 55 | add_filter( 'query_vars', array( $this, 'handle_query_vars' ), 0 ); |
| 56 | add_action( 'parse_request', array( $this, 'handle_parse_request' ), 0 ); |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Class initialization, to be executed when the class is resolved by the container. |
| 61 | * |
| 62 | * @internal |
| 63 | * |
| 64 | * @param LegacyProxy $legacy_proxy The instance of LegacyProxy to use. |
| 65 | */ |
| 66 | final public function init( LegacyProxy $legacy_proxy ) { |
| 67 | $this->legacy_proxy = $legacy_proxy; |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Get the base directory where transient files are stored. |
| 72 | * |
| 73 | * The default base directory is the WordPress uploads directory plus "woocommerce_transient_files". This can |
| 74 | * be changed by using the woocommerce_transient_files_directory filter. |
| 75 | * |
| 76 | * If the woocommerce_transient_files_directory filter is not used and the default base directory |
| 77 | * doesn't exist, it will be created. If the filter is used it's the responsibility of the caller |
| 78 | * to ensure that the custom directory exists, otherwise an exception will be thrown. |
| 79 | * |
| 80 | * The actual directory for each existing file will be the base directory plus the expiration date |
| 81 | * of the file formatted as 'yyyy-mm-dd'. |
| 82 | * |
| 83 | * @return string Effective base directory where transient files are stored. |
| 84 | * @throws Exception The custom base directory (as specified via filter) doesn't exist, or the default base directory can't be created. |
| 85 | */ |
| 86 | public function get_transient_files_directory(): string { |
| 87 | $upload_dir_info = $this->legacy_proxy->call_function( 'wp_upload_dir' ); |
| 88 | $default_transient_files_directory = untrailingslashit( $upload_dir_info['basedir'] ) . '/woocommerce_transient_files'; |
| 89 | |
| 90 | /** |
| 91 | * Filters the directory where transient files are stored. |
| 92 | * |
| 93 | * Note that this is used for both creating new files (with create_file_by_rendering_template) |
| 94 | * and retrieving existing files (with get_file_by_*). |
| 95 | * |
| 96 | * @param string $transient_files_directory The default directory for transient files. |
| 97 | * @return string The actual directory to use for storing transient files. |
| 98 | * |
| 99 | * @since 8.5.0 |
| 100 | */ |
| 101 | $transient_files_directory = apply_filters( 'woocommerce_transient_files_directory', $default_transient_files_directory ); |
| 102 | |
| 103 | $realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory ); |
| 104 | if ( false === $realpathed_transient_files_directory ) { |
| 105 | if ( $transient_files_directory === $default_transient_files_directory ) { |
| 106 | if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) { |
| 107 | throw new Exception( "Can't create directory: $transient_files_directory" ); |
| 108 | } |
| 109 | |
| 110 | // Create infrastructure to prevent listing the contents of the transient files directory. |
| 111 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 112 | \WP_Filesystem(); |
| 113 | $wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' ); |
| 114 | $wp_filesystem->put_contents( $transient_files_directory . '/.htaccess', 'deny from all' ); |
| 115 | $wp_filesystem->put_contents( $transient_files_directory . '/index.html', '' ); |
| 116 | |
| 117 | $realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory ); |
| 118 | } else { |
| 119 | throw new Exception( "The base transient files directory doesn't exist: $transient_files_directory" ); |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | return untrailingslashit( $realpathed_transient_files_directory ); |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Create a transient file. |
| 128 | * |
| 129 | * @param string $file_contents The contents of the file. |
| 130 | * @param string|int $expiration_date A string representing the expiration date formatted as "yyyy-mm-dd", or a number representing the expiration date as a timestamp (the time of day part will be ignored). |
| 131 | * @return string The name of the transient file created (without path information). |
| 132 | * @throws \InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past). |
| 133 | * @throws \Exception The directory to store the file doesn't exist and can't be created. |
| 134 | */ |
| 135 | public function create_transient_file( string $file_contents, $expiration_date ): string { |
| 136 | if ( is_numeric( $expiration_date ) ) { |
| 137 | $expiration_date = gmdate( 'Y-m-d', $expiration_date ); |
| 138 | } elseif ( ! is_string( $expiration_date ) || ! TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ) { |
| 139 | $expiration_date = is_scalar( $expiration_date ) ? $expiration_date : gettype( $expiration_date ); |
| 140 | throw new InvalidArgumentException( "$expiration_date is not a valid date, expected format: YYYY-MM-DD" ); |
| 141 | } |
| 142 | |
| 143 | $expiration_date_object = DateTime::createFromFormat( 'Y-m-d', $expiration_date, TimeUtil::get_utc_date_time_zone() ); |
| 144 | $today_date_object = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() ); |
| 145 | |
| 146 | if ( $expiration_date_object < $today_date_object ) { |
| 147 | throw new InvalidArgumentException( "The supplied expiration date, $expiration_date, is in the past" ); |
| 148 | } |
| 149 | |
| 150 | $filename = bin2hex( $this->legacy_proxy->call_function( 'random_bytes', 16 ) ); |
| 151 | |
| 152 | $transient_files_directory = $this->get_transient_files_directory(); |
| 153 | $transient_files_directory .= '/' . $expiration_date_object->format( 'Y-m-d' ); |
| 154 | if ( ! $this->legacy_proxy->call_function( 'is_dir', $transient_files_directory ) ) { |
| 155 | if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) { |
| 156 | throw new Exception( "Can't create directory: $transient_files_directory" ); |
| 157 | } |
| 158 | } |
| 159 | $filepath = $transient_files_directory . '/' . $filename; |
| 160 | |
| 161 | require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 162 | \WP_Filesystem(); |
| 163 | $wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' ); |
| 164 | if ( false === $wp_filesystem->put_contents( $filepath, $file_contents ) ) { |
| 165 | throw new Exception( "Can't create file: $filepath" ); |
| 166 | } |
| 167 | |
| 168 | return sprintf( |
| 169 | '%03x%01x%02x%s', |
| 170 | $expiration_date_object->format( 'Y' ), |
| 171 | $expiration_date_object->format( 'm' ), |
| 172 | $expiration_date_object->format( 'd' ), |
| 173 | $filename |
| 174 | ); |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Get the full physical path of a transient file given its name. |
| 179 | * |
| 180 | * @param string $filename The name of the transient file to locate. |
| 181 | * @return string|null The full physical path of the file, or null if the files doesn't exist. |
| 182 | */ |
| 183 | public function get_transient_file_path( string $filename ): ?string { |
| 184 | $expiration_date = self::get_expiration_date( $filename ); |
| 185 | if ( is_null( $expiration_date ) ) { |
| 186 | return null; |
| 187 | } |
| 188 | |
| 189 | $file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 ); |
| 190 | |
| 191 | return is_file( $file_path ) ? $file_path : null; |
| 192 | } |
| 193 | |
| 194 | /** |
| 195 | * Get the expiration date of a transient file based on its file name. The actual existence of the file is NOT checked. |
| 196 | * |
| 197 | * @param string $filename The name of the transient file to get the expiration date for. |
| 198 | * @return string|null Expiration date formatted as Y-m-d, null if the file name isn't encoding a proper date. |
| 199 | */ |
| 200 | public static function get_expiration_date( string $filename ) : ?string { |
| 201 | if ( strlen( $filename ) < 7 || ! ctype_xdigit( $filename ) ) { |
| 202 | return null; |
| 203 | } |
| 204 | |
| 205 | $expiration_date = sprintf( |
| 206 | '%04d-%02d-%02d', |
| 207 | hexdec( substr( $filename, 0, 3 ) ), |
| 208 | hexdec( substr( $filename, 3, 1 ) ), |
| 209 | hexdec( substr( $filename, 4, 2 ) ) |
| 210 | ); |
| 211 | |
| 212 | return TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ? $expiration_date : null; |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Get the public URL of a transient file. The file name is NOT checked for validity or actual existence. |
| 217 | * |
| 218 | * @param string $filename The name of the transient file to get the public URL for. |
| 219 | * @return string The public URL of the file. |
| 220 | */ |
| 221 | public function get_public_url( string $filename ) { |
| 222 | return $this->legacy_proxy->call_function( 'home_url', '/wc/file/transient/' . $filename ); |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Verify if a file has expired, given its full physical file path. |
| 227 | * |
| 228 | * Given a file name returned by 'create_transient_file', the procedure to check if it has expired is as follows: |
| 229 | * |
| 230 | * 1. Use 'get_transient_file_path' to obtain the full file path. |
| 231 | * 2. If the above returns null, the file doesn't exist anymore (likely it expired and was deleted by the cleanup process). |
| 232 | * 3. Otherwise, use 'file_has_expired' passing the obtained full file path. |
| 233 | * |
| 234 | * @param string $file_path The full file path to check. |
| 235 | * @return bool True if the file has expired, false otherwise. |
| 236 | * @throws \Exception Thrown by DateTime if a wrong file path is passed. |
| 237 | */ |
| 238 | public function file_has_expired( string $file_path ): bool { |
| 239 | $dirname = dirname( $file_path ); |
| 240 | $expiration_date = basename( $dirname ); |
| 241 | $expiration_date_object = new DateTime( $expiration_date, TimeUtil::get_utc_date_time_zone() ); |
| 242 | $today_date_object = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() ); |
| 243 | return $expiration_date_object < $today_date_object; |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Delete an existing transient file. |
| 248 | * |
| 249 | * @param string $filename The name of the file to delete. |
| 250 | * @return bool True if the file has been deleted, false otherwise (the file didn't exist). |
| 251 | */ |
| 252 | public function delete_transient_file( string $filename ): bool { |
| 253 | $file_path = $this->get_transient_file_path( $filename ); |
| 254 | if ( is_null( $file_path ) ) { |
| 255 | return false; |
| 256 | } |
| 257 | |
| 258 | $dirname = dirname( $file_path ); |
| 259 | wp_delete_file( $file_path ); |
| 260 | $this->delete_directory_if_not_empty( $dirname ); |
| 261 | |
| 262 | return true; |
| 263 | } |
| 264 | |
| 265 | /** |
| 266 | * Delete expired transient files from the filesystem. |
| 267 | * |
| 268 | * @param int $limit Maximum number of files to delete. |
| 269 | * @return array "deleted_count" with the number of files actually deleted, "files_remain" that will be true if there are still files left to delete. |
| 270 | * @throws Exception The base directory for transient files (possibly changed via filter) doesn't exist. |
| 271 | */ |
| 272 | public function delete_expired_files( int $limit = 1000 ): array { |
| 273 | $expiration_date_gmt = $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ); |
| 274 | $base_dir = $this->get_transient_files_directory(); |
| 275 | $subdirs = glob( $base_dir . '/[2-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]', GLOB_ONLYDIR ); |
| 276 | if ( false === $subdirs ) { |
| 277 | throw new Exception( "Error when getting the list of subdirectories of $base_dir" ); |
| 278 | } |
| 279 | |
| 280 | $subdirs = array_map( fn( $name ) => substr( $name, strlen( $name ) - 10, 10 ), $subdirs ); |
| 281 | $expired_subdirs = array_filter( $subdirs, fn( $name ) => $name < $expiration_date_gmt ); |
| 282 | asort( $subdirs ); // We want to delete files starting with the oldest expiration month. |
| 283 | |
| 284 | $remaining_limit = $limit; |
| 285 | $limit_reached = false; |
| 286 | foreach ( $expired_subdirs as $subdir ) { |
| 287 | $full_dir_path = $base_dir . '/' . $subdir; |
| 288 | $files_to_delete = glob( $full_dir_path . '/*' ); |
| 289 | if ( count( $files_to_delete ) > $remaining_limit ) { |
| 290 | $limit_reached = true; |
| 291 | $files_to_delete = array_slice( $files_to_delete, 0, $remaining_limit ); |
| 292 | } |
| 293 | array_map( 'wp_delete_file', $files_to_delete ); |
| 294 | $remaining_limit -= count( $files_to_delete ); |
| 295 | $this->delete_directory_if_not_empty( $full_dir_path ); |
| 296 | |
| 297 | if ( $limit_reached ) { |
| 298 | break; |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | return array( |
| 303 | 'deleted_count' => $limit - $remaining_limit, |
| 304 | 'files_remain' => $limit_reached, |
| 305 | ); |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * Is the expired files cleanup action currently scheduled? |
| 310 | * |
| 311 | * @return bool True if the expired files cleanup action is currently scheduled, false otherwise. |
| 312 | */ |
| 313 | public function expired_files_cleanup_is_scheduled(): bool { |
| 314 | return as_has_scheduled_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP ); |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Schedule an action that will do one round of expired files cleanup. |
| 319 | * The action is scheduled to run immediately. If a previous pending action exists, it's unscheduled first. |
| 320 | */ |
| 321 | public function schedule_expired_files_cleanup(): void { |
| 322 | $this->unschedule_expired_files_cleanup(); |
| 323 | as_schedule_single_action( time() + 1, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP ); |
| 324 | } |
| 325 | |
| 326 | /** |
| 327 | * Remove the scheduled action that does the expired files cleanup, if it's scheduled. |
| 328 | */ |
| 329 | public function unschedule_expired_files_cleanup(): void { |
| 330 | if ( $this->expired_files_cleanup_is_scheduled() ) { |
| 331 | as_unschedule_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP ); |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Run the expired files cleanup action and schedule a new one. |
| 337 | * |
| 338 | * If files are actually deleted then we assume that more files are pending deletion and schedule the next |
| 339 | * action to run immediately. Otherwise (nothing was deleted) we schedule the next action for one day later |
| 340 | * (but this can be changed via the 'woocommerce_delete_expired_transient_files_interval' filter). |
| 341 | * |
| 342 | * If the actual deletion process fails the next action is scheduled anyway for one day later |
| 343 | * or for the interval given by the filter. |
| 344 | * |
| 345 | * NOTE: If the default interval is changed to something different from DAY_IN_SECONDS, please adjust the |
| 346 | * "every 24h" text in add_debug_tools_entries too. |
| 347 | * |
| 348 | * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. |
| 349 | */ |
| 350 | public function handle_expired_files_cleanup_action(): void { |
| 351 | $new_interval = null; |
| 352 | |
| 353 | try { |
| 354 | $result = $this->delete_expired_files(); |
| 355 | if ( $result['deleted_count'] > 0 ) { |
| 356 | $new_interval = 1; |
| 357 | } |
| 358 | } finally { |
| 359 | if ( is_null( $new_interval ) ) { |
| 360 | |
| 361 | /** |
| 362 | * Filter to alter the interval between the actions that delete expired transient files. |
| 363 | * |
| 364 | * @param int $interval The default time before the next action run, in seconds. |
| 365 | * @return int The time to actually wait before the next action run, in seconds. |
| 366 | * |
| 367 | * @since 8.5.0 |
| 368 | */ |
| 369 | $new_interval = apply_filters( 'woocommerce_delete_expired_transient_files_interval', DAY_IN_SECONDS ); |
| 370 | } |
| 371 | |
| 372 | $next_time = $this->legacy_proxy->call_function( 'time' ) + $new_interval; |
| 373 | $this->legacy_proxy->call_function( 'as_schedule_single_action', $next_time, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP ); |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * Add the tools to (re)schedule and un-schedule the expired files cleanup actions in the WooCommerce debug tools page. |
| 379 | * |
| 380 | * @param array $tools_array Original debug tools array. |
| 381 | * @return array Updated debug tools array |
| 382 | * |
| 383 | * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. |
| 384 | */ |
| 385 | public function add_debug_tools_entries( array $tools_array ): array { |
| 386 | $cleanup_is_scheduled = $this->expired_files_cleanup_is_scheduled(); |
| 387 | |
| 388 | $tools_array['schedule_expired_transient_files_cleanup'] = array( |
| 389 | 'name' => $cleanup_is_scheduled ? |
| 390 | __( 'Re-schedule expired transient files cleanup', 'woocommerce' ) : |
| 391 | __( 'Schedule expired transient files cleanup', 'woocommerce' ), |
| 392 | 'desc' => $cleanup_is_scheduled ? |
| 393 | __( 'Remove the currently scheduled action to delete expired transient files, then schedule it again for running immediately. Subsequent actions will run once every 24h.', 'woocommerce' ) : |
| 394 | __( 'Schedule the action to delete expired transient files for running immediately. Subsequent actions will run once every 24h.', 'woocommerce' ), |
| 395 | 'button' => $cleanup_is_scheduled ? |
| 396 | __( 'Re-schedule', 'woocommerce' ) : |
| 397 | __( 'Schedule', 'woocommerce' ), |
| 398 | 'requires_refresh' => true, |
| 399 | 'callback' => array( $this, 'schedule_expired_files_cleanup' ), |
| 400 | ); |
| 401 | |
| 402 | if ( $cleanup_is_scheduled ) { |
| 403 | $tools_array['unschedule_expired_transient_files_cleanup'] = array( |
| 404 | 'name' => __( 'Un-schedule expired transient files cleanup', 'woocommerce' ), |
| 405 | 'desc' => __( "Remove the currently scheduled action to delete expired transient files. Expired files won't be automatically deleted until the 'Schedule expired transient files cleanup' tool is run again.", 'woocommerce' ), |
| 406 | 'button' => __( 'Un-schedule', 'woocommerce' ), |
| 407 | 'requires_refresh' => true, |
| 408 | 'callback' => array( $this, 'unschedule_expired_files_cleanup' ), |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | return $tools_array; |
| 413 | } |
| 414 | |
| 415 | /** |
| 416 | * Delete a directory if it isn't empty. |
| 417 | * |
| 418 | * @param string $directory Full directory path. |
| 419 | */ |
| 420 | private function delete_directory_if_not_empty( string $directory ) { |
| 421 | if ( ! ( new \FilesystemIterator( $directory ) )->valid() ) { |
| 422 | rmdir( $directory ); |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | /** |
| 427 | * Handle the "init" action, add rewrite rules for the "wc/file" endpoint. |
| 428 | * |
| 429 | * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. |
| 430 | */ |
| 431 | public static function add_endpoint() { |
| 432 | add_rewrite_rule( '^wc/file/transient/?$', 'index.php?wc-transient-file-name=', 'top' ); |
| 433 | add_rewrite_rule( '^wc/file/transient/(.+)$', 'index.php?wc-transient-file-name=$matches[1]', 'top' ); |
| 434 | add_rewrite_endpoint( 'wc/file/transient', EP_ALL ); |
| 435 | } |
| 436 | |
| 437 | /** |
| 438 | * Handle the "query_vars" action, add the "wc-transient-file-name" variable for the "wc/file/transient" endpoint. |
| 439 | * |
| 440 | * @param array $vars The original query variables. |
| 441 | * @return array The updated query variables. |
| 442 | * |
| 443 | * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. |
| 444 | */ |
| 445 | public function handle_query_vars( $vars ) { |
| 446 | $vars[] = 'wc-transient-file-name'; |
| 447 | return $vars; |
| 448 | } |
| 449 | |
| 450 | // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing, WordPress.WP.AlternativeFunctions |
| 451 | |
| 452 | /** |
| 453 | * Handle the "parse_request" action for the "wc/file/transient" endpoint. |
| 454 | * |
| 455 | * If the request is not for "/wc/file/transient/<filename>" or "index.php?wc-transient-file-name=filename", |
| 456 | * it returns without doing anything. Otherwise, it will serve the contents of the file with the provided name |
| 457 | * if it exists, is public and has not expired; or will return a "Not found" status otherwise. |
| 458 | * |
| 459 | * The file will be served with a content type header of "text/html". |
| 460 | * |
| 461 | * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed. |
| 462 | */ |
| 463 | public function handle_parse_request() { |
| 464 | global $wp; |
| 465 | |
| 466 | // phpcs:ignore WordPress.Security |
| 467 | $query_arg = wp_unslash( $_GET['wc-transient-file-name'] ?? null ); |
| 468 | if ( ! is_null( $query_arg ) ) { |
| 469 | $wp->query_vars['wc-transient-file-name'] = $query_arg; |
| 470 | } |
| 471 | |
| 472 | if ( is_null( $wp->query_vars['wc-transient-file-name'] ?? null ) ) { |
| 473 | return; |
| 474 | } |
| 475 | |
| 476 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput |
| 477 | if ( 'GET' !== ( $_SERVER['REQUEST_METHOD'] ?? null ) ) { |
| 478 | status_header( 405 ); |
| 479 | exit(); |
| 480 | } |
| 481 | |
| 482 | $this->serve_file_contents( $wp->query_vars['wc-transient-file-name'] ); |
| 483 | } |
| 484 | |
| 485 | /** |
| 486 | * Core method to serve the contents of a transient file. |
| 487 | * |
| 488 | * @param string $file_name Transient file id or filename. |
| 489 | */ |
| 490 | private function serve_file_contents( string $file_name ) { |
| 491 | $legacy_proxy = wc_get_container()->get( LegacyProxy::class ); |
| 492 | |
| 493 | try { |
| 494 | $file_path = $this->get_transient_file_path( $file_name ); |
| 495 | if ( is_null( $file_path ) ) { |
| 496 | $legacy_proxy->call_function( 'status_header', 404 ); |
| 497 | $legacy_proxy->exit(); |
| 498 | } |
| 499 | |
| 500 | if ( $this->file_has_expired( $file_path ) ) { |
| 501 | $legacy_proxy->call_function( 'status_header', 404 ); |
| 502 | $legacy_proxy->exit(); |
| 503 | } |
| 504 | |
| 505 | $file_length = filesize( $file_path ); |
| 506 | if ( false === $file_length ) { |
| 507 | throw new Exception( "Can't retrieve file size: $file_path" ); |
| 508 | } |
| 509 | |
| 510 | $file_handle = fopen( $file_path, 'r' ); |
| 511 | } catch ( Exception $ex ) { |
| 512 | $error_message = "Error serving transient file $file_name: {$ex->getMessage()}"; |
| 513 | wc_get_logger()->error( $error_message ); |
| 514 | |
| 515 | $legacy_proxy->call_function( 'status_header', 500 ); |
| 516 | $legacy_proxy->exit(); |
| 517 | } |
| 518 | |
| 519 | $legacy_proxy->call_function( 'status_header', 200 ); |
| 520 | $legacy_proxy->call_function( 'header', 'Content-Type: text/html' ); |
| 521 | $legacy_proxy->call_function( 'header', "Content-Length: $file_length" ); |
| 522 | |
| 523 | try { |
| 524 | while ( ! feof( $file_handle ) ) { |
| 525 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| 526 | echo fread( $file_handle, 1024 ); |
| 527 | } |
| 528 | |
| 529 | /** |
| 530 | * Action that fires after a transient file has been successfully served, right before terminating the request. |
| 531 | * |
| 532 | * @param array $transient_file_info Information about the served file, as returned by get_file_by_name. |
| 533 | * @param bool $is_json_rest_api_request True if the request came from the JSON API endpoint, false if it came from the authenticated endpoint. |
| 534 | * |
| 535 | * @since 8.5.0 |
| 536 | */ |
| 537 | do_action( 'woocommerce_transient_file_contents_served', $file_name ); |
| 538 | } catch ( Exception $e ) { |
| 539 | wc_get_logger()->error( "Error serving transient file $file_name: {$e->getMessage()}" ); |
| 540 | // We can't change the response status code at this point. |
| 541 | } finally { |
| 542 | fclose( $file_handle ); |
| 543 | $legacy_proxy->exit(); |
| 544 | } |
| 545 | } |
| 546 | } |
| 547 |