PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.1.0-beta.2
WooCommerce v11.1.0-beta.2
11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / TransientFiles / TransientFilesEngine.php
woocommerce / src / Internal / TransientFiles Last commit date
TransientFilesEngine.php 2 weeks ago
TransientFilesEngine.php
606 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\Internal\Utilities\FilesystemUtil;
10 use Automattic\WooCommerce\Proxies\LegacyProxy;
11 use Automattic\WooCommerce\Utilities\TimeUtil;
12
13 /**
14 * Transient files engine class.
15 *
16 * This class contains methods that allow creating files that have an expiration date.
17 *
18 * A transient file is created by invoking the create_transient_file method, which accepts the file contents
19 * and the expiration date as arguments. Transient file names are composed by concatenating the expiration date
20 * encoded in hexadecimal (3 digits for the year, 1 for the month and 2 for the day) and a random string
21 * of hexadecimal digits.
22 *
23 * Transient files are stored in a directory whose default route is
24 * wp-content/uploads/woocommerce_transient_files/yyyy-mm-dd, where "yyyy-mm-dd" is the expiration date
25 * (year, month and day). The base route (minus the expiration date part) can be changed via a dedicated hook.
26 *
27 * Transient files that haven't expired (the expiration date is today or in the future) can be obtained remotely
28 * via a dedicated URL, <server root>/wc/file/transient/<file name>. This URL is public (no authentication is required).
29 * The content type of the response will always be "text/html".
30 *
31 * Cleanup of expired files is handled by the delete_expired_files method, which can be invoked manually
32 * but there's a dedicated scheduled action that will invoke it that can be started and stopped via a dedicated tool
33 * available in the WooCommerce tools page. The action runs once per day but this can be customized
34 * via a dedicated hook.
35 */
36 class TransientFilesEngine implements RegisterHooksInterface {
37
38 private const CLEANUP_ACTION_NAME = 'woocommerce_expired_transient_files_cleanup';
39 private const CLEANUP_ACTION_GROUP = 'wc_batch_processes';
40
41 /**
42 * Regular expression matching the name of a directory that holds the files expiring on a given date.
43 * Equivalent to the "[2-9][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9]" glob pattern used previously.
44 */
45 private const EXPIRATION_DATE_DIRECTORY_REGEX = '/^[2-9]\d{3}-[01]\d-[0-3]\d$/';
46
47 /**
48 * The instance of LegacyProxy to use.
49 *
50 * @var LegacyProxy
51 */
52 private $legacy_proxy;
53
54 /**
55 * Register hooks.
56 */
57 public function register() {
58 add_action( self::CLEANUP_ACTION_NAME, array( $this, 'handle_expired_files_cleanup_action' ) );
59 add_filter( 'woocommerce_debug_tools', array( $this, 'add_debug_tools_entries' ), 999, 1 );
60
61 add_action( 'init', array( $this, 'add_endpoint' ), 0 );
62 add_filter( 'query_vars', array( $this, 'handle_query_vars' ), 0 );
63 add_action( 'parse_request', array( $this, 'handle_parse_request' ), 0 );
64 }
65
66 /**
67 * Class initialization, to be executed when the class is resolved by the container.
68 *
69 * @internal
70 *
71 * @param LegacyProxy $legacy_proxy The instance of LegacyProxy to use.
72 */
73 final public function init( LegacyProxy $legacy_proxy ) {
74 $this->legacy_proxy = $legacy_proxy;
75 }
76
77 /**
78 * Get the base directory where transient files are stored.
79 *
80 * The default base directory is the WordPress uploads directory plus "woocommerce_transient_files". This can
81 * be changed by using the woocommerce_transient_files_directory filter.
82 *
83 * If the woocommerce_transient_files_directory filter is not used and the default base directory
84 * doesn't exist, it will be created. If the filter is used it's the responsibility of the caller
85 * to ensure that the custom directory exists, otherwise an exception will be thrown.
86 *
87 * The actual directory for each existing file will be the base directory plus the expiration date
88 * of the file formatted as 'yyyy-mm-dd'.
89 *
90 * @return string Effective base directory where transient files are stored.
91 * @throws Exception The custom base directory (as specified via filter) doesn't exist, or the default base directory can't be created.
92 */
93 public function get_transient_files_directory(): string {
94 $upload_dir_info = $this->legacy_proxy->call_function( 'wp_upload_dir' );
95 $default_transient_files_directory = untrailingslashit( $upload_dir_info['basedir'] ) . '/woocommerce_transient_files';
96
97 /**
98 * Filters the directory where transient files are stored.
99 *
100 * Note that this is used for both creating new files (with create_file_by_rendering_template)
101 * and retrieving existing files (with get_file_by_*).
102 *
103 * @param string $transient_files_directory The default directory for transient files.
104 * @return string The actual directory to use for storing transient files.
105 *
106 * @since 8.5.0
107 */
108 $transient_files_directory = apply_filters( 'woocommerce_transient_files_directory', $default_transient_files_directory );
109
110 $resolved_transient_files_directory = $this->resolve_directory_if_it_exists( $transient_files_directory );
111 if ( false === $resolved_transient_files_directory ) {
112 if ( $transient_files_directory === $default_transient_files_directory ) {
113 if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
114 throw new Exception( "Can't create directory: $transient_files_directory" );
115 }
116
117 // Prevent listing the directory contents. Use a direct filesystem: this dir is web-server-writable
118 // under wp-content/uploads, so honoring FS_METHOD is unnecessary and breaks FTP-without-creds setups.
119 $wp_filesystem = FilesystemUtil::get_wp_filesystem_direct();
120 $wp_filesystem->put_contents( $transient_files_directory . '/.htaccess', 'deny from all' );
121 $wp_filesystem->put_contents( $transient_files_directory . '/index.html', '' );
122
123 $resolved_transient_files_directory = $this->resolve_directory_if_it_exists( $transient_files_directory );
124 if ( false === $resolved_transient_files_directory ) {
125 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not rendered as output, and consistent with the other throws in this method.
126 throw new Exception( "The directory was created but can't be resolved: $transient_files_directory" );
127 }
128 } else {
129 throw new Exception( "The base transient files directory doesn't exist: $transient_files_directory" );
130 }
131 }
132
133 return untrailingslashit( $resolved_transient_files_directory );
134 }
135
136 /**
137 * Get the canonical path of a directory, if the directory exists.
138 *
139 * Paths handled by a stream wrapper can't be resolved with realpath, which returns false for them and would
140 * turn a perfectly valid directory into an empty string. Sites that store uploads through a stream wrapper
141 * (the S3-Uploads plugin and WordPress VIP, where wp_upload_dir returns something like "s3://bucket/uploads")
142 * hit that case, so for those the path is kept verbatim and only its existence is verified.
143 *
144 * wp_is_stream only recognizes schemes that have a wrapper actually registered, so a path that merely looks
145 * like a URL still goes through realpath, as it did before.
146 *
147 * @param string $directory The directory to resolve.
148 * @return string|false The canonical path of the directory, or false if the directory doesn't exist.
149 */
150 private function resolve_directory_if_it_exists( string $directory ) {
151 if ( wp_is_stream( $directory ) ) {
152 return $this->legacy_proxy->call_function( 'is_dir', $directory ) ? $directory : false;
153 }
154
155 return $this->legacy_proxy->call_function( 'realpath', $directory );
156 }
157
158 /**
159 * Create a transient file.
160 *
161 * @param string $file_contents The contents of the file.
162 * @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).
163 * @return string The name of the transient file created (without path information).
164 * @throws \InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past).
165 * @throws \Exception The directory to store the file doesn't exist and can't be created.
166 */
167 public function create_transient_file( string $file_contents, $expiration_date ): string {
168 if ( is_numeric( $expiration_date ) ) {
169 $expiration_date = gmdate( 'Y-m-d', $expiration_date );
170 } elseif ( ! is_string( $expiration_date ) || ! TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ) {
171 $expiration_date = is_scalar( $expiration_date ) ? $expiration_date : gettype( $expiration_date );
172 throw new InvalidArgumentException( "$expiration_date is not a valid date, expected format: YYYY-MM-DD" );
173 }
174
175 $expiration_date_object = DateTime::createFromFormat( 'Y-m-d', $expiration_date, TimeUtil::get_utc_date_time_zone() );
176 $today_date_object = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() );
177
178 if ( $expiration_date_object < $today_date_object ) {
179 throw new InvalidArgumentException( "The supplied expiration date, $expiration_date, is in the past" );
180 }
181
182 $filename = bin2hex( $this->legacy_proxy->call_function( 'random_bytes', 16 ) );
183
184 $transient_files_directory = $this->get_transient_files_directory();
185 $transient_files_directory .= '/' . $expiration_date_object->format( 'Y-m-d' );
186 if ( ! $this->legacy_proxy->call_function( 'is_dir', $transient_files_directory ) ) {
187 if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
188 throw new Exception( "Can't create directory: $transient_files_directory" );
189 }
190 }
191 $filepath = $transient_files_directory . '/' . $filename;
192
193 // Use a direct filesystem because the transient files directory is inside
194 // wp-content/uploads and is web-server writable. See get_transient_files_directory().
195 $wp_filesystem = FilesystemUtil::get_wp_filesystem_direct();
196 if ( false === $wp_filesystem->put_contents( $filepath, $file_contents ) ) {
197 throw new Exception( "Can't create file: $filepath" );
198 }
199
200 return sprintf(
201 '%03x%01x%02x%s',
202 $expiration_date_object->format( 'Y' ),
203 $expiration_date_object->format( 'm' ),
204 $expiration_date_object->format( 'd' ),
205 $filename
206 );
207 }
208
209 /**
210 * Get the full physical path of a transient file given its name.
211 *
212 * @param string $filename The name of the transient file to locate.
213 * @return string|null The full physical path of the file, or null if the files doesn't exist.
214 */
215 public function get_transient_file_path( string $filename ): ?string {
216 $expiration_date = self::get_expiration_date( $filename );
217 if ( is_null( $expiration_date ) ) {
218 return null;
219 }
220
221 $file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 );
222
223 return is_file( $file_path ) ? $file_path : null;
224 }
225
226 /**
227 * Get the expiration date of a transient file based on its file name. The actual existence of the file is NOT checked.
228 *
229 * @param string $filename The name of the transient file to get the expiration date for.
230 * @return string|null Expiration date formatted as Y-m-d, null if the file name isn't encoding a proper date.
231 */
232 public static function get_expiration_date( string $filename ) : ?string {
233 if ( strlen( $filename ) < 7 || ! ctype_xdigit( $filename ) ) {
234 return null;
235 }
236
237 $expiration_date = sprintf(
238 '%04d-%02d-%02d',
239 hexdec( substr( $filename, 0, 3 ) ),
240 hexdec( substr( $filename, 3, 1 ) ),
241 hexdec( substr( $filename, 4, 2 ) )
242 );
243
244 return TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ? $expiration_date : null;
245 }
246
247 /**
248 * Get the public URL of a transient file. The file name is NOT checked for validity or actual existence.
249 *
250 * @param string $filename The name of the transient file to get the public URL for.
251 * @return string The public URL of the file.
252 */
253 public function get_public_url( string $filename ) {
254 return $this->legacy_proxy->call_function( 'home_url', '/wc/file/transient/' . $filename );
255 }
256
257 /**
258 * Verify if a file has expired, given its full physical file path.
259 *
260 * Given a file name returned by 'create_transient_file', the procedure to check if it has expired is as follows:
261 *
262 * 1. Use 'get_transient_file_path' to obtain the full file path.
263 * 2. If the above returns null, the file doesn't exist anymore (likely it expired and was deleted by the cleanup process).
264 * 3. Otherwise, use 'file_has_expired' passing the obtained full file path.
265 *
266 * @param string $file_path The full file path to check.
267 * @return bool True if the file has expired, false otherwise.
268 * @throws \Exception Thrown by DateTime if a wrong file path is passed.
269 */
270 public function file_has_expired( string $file_path ): bool {
271 $dirname = dirname( $file_path );
272 $expiration_date = basename( $dirname );
273 $expiration_date_object = new DateTime( $expiration_date, TimeUtil::get_utc_date_time_zone() );
274 $today_date_object = new DateTime( $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' ), TimeUtil::get_utc_date_time_zone() );
275 return $expiration_date_object < $today_date_object;
276 }
277
278 /**
279 * Delete an existing transient file.
280 *
281 * @param string $filename The name of the file to delete.
282 * @return bool True if the file has been deleted, false otherwise (the file didn't exist).
283 */
284 public function delete_transient_file( string $filename ): bool {
285 $file_path = $this->get_transient_file_path( $filename );
286 if ( is_null( $file_path ) ) {
287 return false;
288 }
289
290 $dirname = dirname( $file_path );
291 wp_delete_file( $file_path );
292 $this->delete_directory_if_not_empty( $dirname );
293
294 return true;
295 }
296
297 /**
298 * Delete expired transient files from the filesystem.
299 *
300 * @param int $limit Maximum number of files to delete.
301 * @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.
302 * @throws Exception The base directory for transient files (possibly changed via filter) doesn't exist, or its contents can't be listed.
303 */
304 public function delete_expired_files( int $limit = 1000 ): array {
305 $expiration_date_gmt = $this->legacy_proxy->call_function( 'gmdate', 'Y-m-d' );
306 $base_dir = $this->get_transient_files_directory();
307
308 /*
309 * scandir, not glob: glob doesn't support stream wrappers (it always goes to the local filesystem)
310 * and returns an empty array for paths like "s3://bucket/uploads", which would silently turn the
311 * cleanup into a no-op on those sites. scandir returns bare names rather than full paths.
312 */
313 $entries = scandir( $base_dir );
314 if ( false === $entries ) {
315 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not rendered as output, and consistent with the other throws in this class.
316 throw new Exception( "Error when getting the list of subdirectories of $base_dir" );
317 }
318
319 $subdirs = array_values(
320 array_filter(
321 $entries,
322 fn( $name ) => 1 === preg_match( self::EXPIRATION_DATE_DIRECTORY_REGEX, $name ) && is_dir( $base_dir . '/' . $name )
323 )
324 );
325
326 $expired_subdirs = array_filter( $subdirs, fn( $name ) => $name < $expiration_date_gmt );
327 asort( $subdirs ); // We want to delete files starting with the oldest expiration month.
328
329 $remaining_limit = $limit;
330 $limit_reached = false;
331 foreach ( $expired_subdirs as $subdir ) {
332 $full_dir_path = $base_dir . '/' . $subdir;
333
334 $dir_entries = scandir( $full_dir_path );
335 if ( false === $dir_entries ) {
336 // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not rendered as output, and consistent with the other throws in this class.
337 throw new Exception( "Error when getting the list of files in $full_dir_path" );
338 }
339
340 $files_to_delete = array_values(
341 array_map(
342 fn( $name ) => $full_dir_path . '/' . $name,
343 // Skip dot files, matching what the "*" glob pattern used to do. This also drops "." and "..".
344 array_filter( $dir_entries, fn( $name ) => '.' !== $name[0] )
345 )
346 );
347
348 if ( count( $files_to_delete ) > $remaining_limit ) {
349 $limit_reached = true;
350 $files_to_delete = array_slice( $files_to_delete, 0, $remaining_limit );
351 }
352 array_map( 'wp_delete_file', $files_to_delete );
353 $remaining_limit -= count( $files_to_delete );
354 $this->delete_directory_if_not_empty( $full_dir_path );
355
356 if ( $limit_reached ) {
357 break;
358 }
359 }
360
361 return array(
362 'deleted_count' => $limit - $remaining_limit,
363 'files_remain' => $limit_reached,
364 );
365 }
366
367 /**
368 * Is the expired files cleanup action currently scheduled?
369 *
370 * @return bool True if the expired files cleanup action is currently scheduled, false otherwise.
371 */
372 public function expired_files_cleanup_is_scheduled(): bool {
373 return as_has_scheduled_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
374 }
375
376 /**
377 * Schedule an action that will do one round of expired files cleanup.
378 * The action is scheduled to run immediately. If a previous pending action exists, it's unscheduled first.
379 */
380 public function schedule_expired_files_cleanup(): void {
381 $this->unschedule_expired_files_cleanup();
382 as_schedule_single_action( time() + 1, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
383 }
384
385 /**
386 * Remove the scheduled action that does the expired files cleanup, if it's scheduled.
387 */
388 public function unschedule_expired_files_cleanup(): void {
389 if ( $this->expired_files_cleanup_is_scheduled() ) {
390 as_unschedule_action( self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
391 }
392 }
393
394 /**
395 * Run the expired files cleanup action and schedule a new one.
396 *
397 * If files are actually deleted then we assume that more files are pending deletion and schedule the next
398 * action to run immediately. Otherwise (nothing was deleted) we schedule the next action for one day later
399 * (but this can be changed via the 'woocommerce_delete_expired_transient_files_interval' filter).
400 *
401 * If the actual deletion process fails the next action is scheduled anyway for one day later
402 * or for the interval given by the filter.
403 *
404 * NOTE: If the default interval is changed to something different from DAY_IN_SECONDS, please adjust the
405 * "every 24h" text in add_debug_tools_entries too.
406 *
407 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
408 */
409 public function handle_expired_files_cleanup_action(): void {
410 $new_interval = null;
411
412 try {
413 $result = $this->delete_expired_files();
414 if ( $result['deleted_count'] > 0 ) {
415 $new_interval = 1;
416 }
417 } finally {
418 if ( is_null( $new_interval ) ) {
419
420 /**
421 * Filter to alter the interval between the actions that delete expired transient files.
422 *
423 * @param int $interval The default time before the next action run, in seconds.
424 * @return int The time to actually wait before the next action run, in seconds.
425 *
426 * @since 8.5.0
427 */
428 $new_interval = apply_filters( 'woocommerce_delete_expired_transient_files_interval', DAY_IN_SECONDS );
429 }
430
431 $next_time = $this->legacy_proxy->call_function( 'time' ) + $new_interval;
432 $this->legacy_proxy->call_function( 'as_schedule_single_action', $next_time, self::CLEANUP_ACTION_NAME, array(), self::CLEANUP_ACTION_GROUP );
433 }
434 }
435
436 /**
437 * Add the tools to (re)schedule and un-schedule the expired files cleanup actions in the WooCommerce debug tools page.
438 *
439 * @param array $tools_array Original debug tools array.
440 * @return array Updated debug tools array
441 *
442 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
443 */
444 public function add_debug_tools_entries( array $tools_array ): array {
445 $cleanup_is_scheduled = $this->expired_files_cleanup_is_scheduled();
446
447 $tools_array['schedule_expired_transient_files_cleanup'] = array(
448 'name' => $cleanup_is_scheduled ?
449 __( 'Re-schedule expired transient files cleanup', 'woocommerce' ) :
450 __( 'Schedule expired transient files cleanup', 'woocommerce' ),
451 'desc' => $cleanup_is_scheduled ?
452 __( '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' ) :
453 __( 'Schedule the action to delete expired transient files for running immediately. Subsequent actions will run once every 24h.', 'woocommerce' ),
454 'button' => $cleanup_is_scheduled ?
455 __( 'Re-schedule', 'woocommerce' ) :
456 __( 'Schedule', 'woocommerce' ),
457 'requires_refresh' => true,
458 'callback' => array( $this, 'schedule_expired_files_cleanup' ),
459 );
460
461 if ( $cleanup_is_scheduled ) {
462 $tools_array['unschedule_expired_transient_files_cleanup'] = array(
463 'name' => __( 'Un-schedule expired transient files cleanup', 'woocommerce' ),
464 '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' ),
465 'button' => __( 'Un-schedule', 'woocommerce' ),
466 'requires_refresh' => true,
467 'callback' => array( $this, 'unschedule_expired_files_cleanup' ),
468 );
469 }
470
471 return $tools_array;
472 }
473
474 /**
475 * Delete a directory if it isn't empty.
476 *
477 * @param string $directory Full directory path.
478 */
479 private function delete_directory_if_not_empty( string $directory ) {
480 if ( ! ( new \FilesystemIterator( $directory ) )->valid() ) {
481 rmdir( $directory );
482 }
483 }
484
485 /**
486 * Handle the "init" action, add rewrite rules for the "wc/file" endpoint.
487 *
488 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
489 */
490 public static function add_endpoint() {
491 add_rewrite_rule( '^wc/file/transient/?$', 'index.php?wc-transient-file-name=', 'top' );
492 add_rewrite_rule( '^wc/file/transient/(.+)$', 'index.php?wc-transient-file-name=$matches[1]', 'top' );
493 add_rewrite_endpoint( 'wc/file/transient', EP_ALL );
494 }
495
496 /**
497 * Handle the "query_vars" action, add the "wc-transient-file-name" variable for the "wc/file/transient" endpoint.
498 *
499 * @param array $vars The original query variables.
500 * @return array The updated query variables.
501 *
502 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
503 */
504 public function handle_query_vars( $vars ) {
505 $vars[] = 'wc-transient-file-name';
506 return $vars;
507 }
508
509 // phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing, WordPress.WP.AlternativeFunctions
510
511 /**
512 * Handle the "parse_request" action for the "wc/file/transient" endpoint.
513 *
514 * If the request is not for "/wc/file/transient/<filename>" or "index.php?wc-transient-file-name=filename",
515 * it returns without doing anything. Otherwise, it will serve the contents of the file with the provided name
516 * if it exists, is public and has not expired; or will return a "Not found" status otherwise.
517 *
518 * The file will be served with a content type header of "text/html".
519 *
520 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
521 */
522 public function handle_parse_request() {
523 global $wp;
524
525 // phpcs:ignore WordPress.Security
526 $query_arg = wp_unslash( $_GET['wc-transient-file-name'] ?? null );
527 if ( ! is_null( $query_arg ) ) {
528 $wp->query_vars['wc-transient-file-name'] = $query_arg;
529 }
530
531 if ( is_null( $wp->query_vars['wc-transient-file-name'] ?? null ) ) {
532 return;
533 }
534
535 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
536 if ( 'GET' !== ( $_SERVER['REQUEST_METHOD'] ?? null ) ) {
537 status_header( 405 );
538 exit();
539 }
540
541 $this->serve_file_contents( $wp->query_vars['wc-transient-file-name'] );
542 }
543
544 /**
545 * Core method to serve the contents of a transient file.
546 *
547 * @param string $file_name Transient file id or filename.
548 */
549 private function serve_file_contents( string $file_name ) {
550 $legacy_proxy = wc_get_container()->get( LegacyProxy::class );
551
552 try {
553 $file_path = $this->get_transient_file_path( $file_name );
554 if ( is_null( $file_path ) ) {
555 $legacy_proxy->call_function( 'status_header', 404 );
556 $legacy_proxy->exit();
557 }
558
559 if ( $this->file_has_expired( $file_path ) ) {
560 $legacy_proxy->call_function( 'status_header', 404 );
561 $legacy_proxy->exit();
562 }
563
564 $file_length = filesize( $file_path );
565 if ( false === $file_length ) {
566 throw new Exception( "Can't retrieve file size: $file_path" );
567 }
568
569 $file_handle = fopen( $file_path, 'r' );
570 } catch ( Exception $ex ) {
571 $error_message = "Error serving transient file $file_name: {$ex->getMessage()}";
572 wc_get_logger()->error( $error_message );
573
574 $legacy_proxy->call_function( 'status_header', 500 );
575 $legacy_proxy->exit();
576 }
577
578 $legacy_proxy->call_function( 'status_header', 200 );
579 $legacy_proxy->call_function( 'header', 'Content-Type: text/html' );
580 $legacy_proxy->call_function( 'header', "Content-Length: $file_length" );
581
582 try {
583 while ( ! feof( $file_handle ) ) {
584 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
585 echo fread( $file_handle, 1024 );
586 }
587
588 /**
589 * Action that fires after a transient file has been successfully served, right before terminating the request.
590 *
591 * @param array $transient_file_info Information about the served file, as returned by get_file_by_name.
592 * @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.
593 *
594 * @since 8.5.0
595 */
596 do_action( 'woocommerce_transient_file_contents_served', $file_name );
597 } catch ( Exception $e ) {
598 wc_get_logger()->error( "Error serving transient file $file_name: {$e->getMessage()}" );
599 // We can't change the response status code at this point.
600 } finally {
601 fclose( $file_handle );
602 $legacy_proxy->exit();
603 }
604 }
605 }
606