PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
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 4 weeks ago
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