Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.03% covered (warning)
63.03%
133 / 211
65.22% covered (warning)
65.22%
15 / 23
CRAP
n/a
0 / 0
jetpack_boost_page_optimize_types
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
jetpack_boost_page_optimize_limits
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
jetpack_boost_page_optimize_check_files_size
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
jetpack_boost_page_optimize_check_output_size
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
jetpack_boost_page_optimize_cache_max_bytes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
jetpack_boost_page_optimize_cache_has_room
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
jetpack_boost_page_optimize_write_cache_file
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
jetpack_boost_page_optimize_get_subdir_prefix
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
jetpack_boost_page_optimize_get_cache_key
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
jetpack_boost_page_optimize_get_request_cache_key
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
jetpack_boost_page_optimize_service_request
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
210
jetpack_boost_page_optimize_read_cached_response
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
jetpack_boost_page_optimize_store_cached_response
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
jetpack_boost_strip_parent_path
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
jetpack_boost_page_optimize_build_output
69.23% covered (warning)
69.23%
54 / 78
0.00% covered (danger)
0.00%
0 / 1
43.21
jetpack_boost_page_optimize_get_request_list
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
42
jetpack_boost_page_optimize_get_issued_list
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
6.56
jetpack_boost_page_optimize_decode_older_list
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
5
jetpack_boost_page_optimize_find_older_list
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
jetpack_boost_page_optimize_status_exit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
jetpack_boost_page_optimize_get_mime_type
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
jetpack_boost_page_optimize_relative_path_replace
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
jetpack_boost_page_optimize_get_path
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
35.00
1<?php
2
3use Automattic\Jetpack_Boost\Lib\Minify;
4use Automattic\Jetpack_Boost\Lib\Minify\Config;
5use Automattic\Jetpack_Boost\Lib\Minify\Dependency_Path_Mapping;
6use Automattic\Jetpack_Boost\Lib\Minify\File_Paths;
7use Automattic\Jetpack_Boost\Lib\Minify\Utils;
8
9function jetpack_boost_page_optimize_types() {
10    return array(
11        'css' => 'text/css',
12        'js'  => 'application/javascript',
13    );
14}
15
16/**
17 * Size limits in bytes for one concatenation request. A request over any of them is refused.
18 *
19 * @return array{encoded_list: int, decoded_list: int, files_read: int, output: int}
20 */
21function jetpack_boost_page_optimize_limits() {
22    return array(
23        'encoded_list' => 64 * 1024,
24        'decoded_list' => 256 * 1024,
25        'files_read'   => 16 * 1024 * 1024,
26        'output'       => 16 * 1024 * 1024,
27    );
28}
29
30/**
31 * Refuse the request if the listed files total more than the read limit, before any of them is read.
32 *
33 * @param string[] $uris The listed file paths.
34 *
35 * @return void
36 */
37function jetpack_boost_page_optimize_check_files_size( $uris ) {
38    $total_bytes = 0;
39
40    foreach ( $uris as $uri ) {
41        $fullpath = jetpack_boost_page_optimize_get_path( $uri );
42
43        if ( ! in_array( jetpack_boost_page_optimize_get_mime_type( $fullpath ), jetpack_boost_page_optimize_types(), true ) ) {
44            jetpack_boost_page_optimize_status_exit( 400 );
45        }
46
47        // get_path() has refused a missing file already, so this only guards a file removed since that call.
48        $total_bytes += file_exists( $fullpath ) ? (int) filesize( $fullpath ) : 0;
49    }
50
51    if ( $total_bytes > jetpack_boost_page_optimize_limits()['files_read'] ) {
52        jetpack_boost_page_optimize_status_exit( 400 );
53    }
54}
55
56/**
57 * Refuse the request if the output built so far is over its size limit.
58 *
59 * @param int $size Size of the output in bytes.
60 *
61 * @return void
62 */
63function jetpack_boost_page_optimize_check_output_size( $size ) {
64    if ( $size > jetpack_boost_page_optimize_limits()['output'] ) {
65        jetpack_boost_page_optimize_status_exit( 400 );
66    }
67}
68
69/**
70 * Get the size limit of one cache directory in bytes. A response that would take the directory over it is served but not stored.
71 *
72 * @return int
73 */
74function jetpack_boost_page_optimize_cache_max_bytes() {
75    /**
76     * Filter the most bytes a concatenate cache directory may hold.
77     *
78     * The _jb_static route runs while plugins are loading, so hook this from a must-use plugin.
79     *
80     * @since 4.8.2
81     *
82     * @param int $max_bytes The size limit in bytes.
83     */
84    return (int) apply_filters( 'jetpack_boost_minify_cache_max_bytes', 1024 * 1024 * 1024 );
85}
86
87/**
88 * Whether a cache directory has room for more data.
89 *
90 * @param string $pattern Glob pattern matching the directory's cache files.
91 * @param int    $bytes   Size of the data to add.
92 *
93 * @return bool
94 */
95function jetpack_boost_page_optimize_cache_has_room( $pattern, $bytes ) {
96    $total_bytes = $bytes;
97
98    foreach ( (array) glob( $pattern ) as $file ) {
99        $total_bytes += file_exists( (string) $file ) ? (int) filesize( $file ) : 0;
100    }
101
102    return $total_bytes <= jetpack_boost_page_optimize_cache_max_bytes();
103}
104
105/**
106 * Write a cache file so that a concurrent request sees either the whole file or none of it.
107 *
108 * @param string $path    Path of the cache file.
109 * @param string $content File content.
110 *
111 * @return bool Whether the file was written.
112 */
113function jetpack_boost_page_optimize_write_cache_file( $path, $content ) {
114    // Same directory, so the rename is atomic, and a name the cleanup globs match, so an orphan is removed.
115    $temp_path = $path . '.' . uniqid( 'tmp-', true );
116
117    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.WP.AlternativeFunctions.rename_rename
118    if ( strlen( $content ) === file_put_contents( $temp_path, $content ) && rename( $temp_path, $path ) ) {
119        return true;
120    }
121
122    if ( file_exists( $temp_path ) ) {
123        wp_delete_file( $temp_path );
124    }
125
126    return false;
127}
128
129/**
130 * Get the part of the request path before the static prefix, such as `/blog` on a site in a subdirectory.
131 *
132 * @param string $request_uri The request URI.
133 *
134 * @return string
135 */
136function jetpack_boost_page_optimize_get_subdir_prefix( $request_uri ) {
137    $use_wp        = defined( 'JETPACK_BOOST_CONCAT_USE_WP' ) && JETPACK_BOOST_CONCAT_USE_WP;
138    $request_path  = ( new Utils( $use_wp ) )->parse_url( $request_uri, PHP_URL_PATH );
139    $_static_index = strpos( $request_path, jetpack_boost_get_static_prefix() );
140
141    return $_static_index > 0 ? substr( $request_path, 0, $_static_index ) : '';
142}
143
144/**
145 * Get the cache key of a list, shared by every request for it.
146 *
147 * @param string $list_id     The list ID.
148 * @param string $request_uri The request URI.
149 *
150 * @return string|false The key, or false if the response must not be shared.
151 */
152function jetpack_boost_page_optimize_get_cache_key( $list_id, $request_uri ) {
153    $use_wp = defined( 'JETPACK_BOOST_CONCAT_USE_WP' ) && JETPACK_BOOST_CONCAT_USE_WP;
154    $utils  = new Utils( $use_wp );
155    $prefix = jetpack_boost_page_optimize_get_subdir_prefix( $request_uri );
156
157    /** This filter is documented in app/lib/minify/class-concatenate-css.php */
158    $site_url = apply_filters( 'page_optimize_site_url', site_url() );
159
160    // The prefix changes how relative CSS URLs are rewritten, so output built under any other path would break the site's pages.
161    // A request with no prefix is kept because a proxy can strip the site's path before it reaches WordPress.
162    if ( '' !== $prefix && rtrim( (string) $utils->parse_url( $site_url, PHP_URL_PATH ), '/' ) !== $prefix ) {
163        return false;
164    }
165
166    return md5( $prefix . '|' . $list_id );
167}
168
169/**
170 * Get the cache key of the list a _jb_static request names. Any list the site has not stored is refused.
171 *
172 * @param string $request_uri The request URI.
173 *
174 * @return string|false The key, or false if the response must not be shared.
175 */
176function jetpack_boost_page_optimize_get_request_cache_key( $request_uri ) {
177    $list    = jetpack_boost_page_optimize_get_request_list( $request_uri );
178    $list_id = File_Paths::is_id( $list ) ? $list : jetpack_boost_page_optimize_get_issued_list( $list )->get_cache_id();
179
180    return jetpack_boost_page_optimize_get_cache_key( $list_id, $request_uri );
181}
182
183/**
184 * Handle serving a minified / concatenated file from the virtual _jb_static dir.
185 *
186 * @return never
187 */
188function jetpack_boost_page_optimize_service_request() {
189    $use_wp = defined( 'JETPACK_BOOST_CONCAT_USE_WP' ) && JETPACK_BOOST_CONCAT_USE_WP;
190    $utils  = new Utils( $use_wp );
191
192    // We handle the cache here, tell other caches not to.
193    if ( ! defined( 'DONOTCACHEPAGE' ) ) {
194        define( 'DONOTCACHEPAGE', true );
195    }
196
197    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
198    $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $utils->unslash( $_SERVER['REQUEST_URI'] ) : '';
199    $cache_key   = jetpack_boost_page_optimize_get_request_cache_key( $request_uri );
200
201    $use_cache       = false !== $cache_key && Config::can_use_cache();
202    $cache_file      = '';
203    $cache_file_meta = '';
204
205    if ( $use_cache ) {
206        $cache_dir       = Config::get_legacy_cache_dir_path();
207        $cache_file      = $cache_dir . "/page-optimize-cache-$cache_key";
208        $cache_file_meta = $cache_dir . "/page-optimize-cache-meta-$cache_key";
209
210        // Serve an existing file.
211        $cached = jetpack_boost_page_optimize_read_cached_response( $cache_file, $cache_file_meta );
212        if ( false !== $cached ) {
213            if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
214                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
215                if ( strtotime( $utils->unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) < filemtime( $cache_file ) ) {
216                    header( 'HTTP/1.1 304 Not Modified' );
217                    exit( 0 );
218                }
219            }
220
221            foreach ( $cached['headers'] as $header ) {
222                header( $header );
223            }
224
225            $etag = '"' . md5( $cached['content'] ) . '"';
226
227            // Check if we're on Atomic and take advantage of the Atomic Edge Cache.
228            if ( defined( 'ATOMIC_CLIENT_ID' ) ) {
229                header( 'A8c-Edge-Cache: cache' );
230            }
231            header( 'X-Page-Optimize: cached' );
232            header( 'Cache-Control: max-age=' . 31536000 );
233            header( 'ETag: ' . $etag );
234
235            echo $cached['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
236            die( 0 );
237        }
238    }
239
240    // Existing file not available; generate new content.
241    $output  = jetpack_boost_page_optimize_build_output();
242    $content = $output['content'];
243    $headers = $output['headers'];
244
245    foreach ( $headers as $header ) {
246        header( $header );
247    }
248    // Check if we're on Atomic and take advantage of the Atomic Edge Cache.
249    if ( defined( 'ATOMIC_CLIENT_ID' ) ) {
250        header( 'A8c-Edge-Cache: cache' );
251    }
252    header( 'X-Page-Optimize: uncached' );
253    header( 'Cache-Control: max-age=' . 31536000 );
254    header( 'ETag: "' . md5( $content ) . '"' );
255
256    echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
257
258    // Cache the generated data, if available.
259    if ( $use_cache ) {
260        jetpack_boost_page_optimize_store_cached_response( $cache_file, $cache_file_meta, $output );
261    }
262
263    die( 0 );
264}
265
266/**
267 * Read a cached response. A body without its headers counts as a miss, because it would be served as text/html.
268 *
269 * @param string $cache_file      Path of the content file.
270 * @param string $cache_file_meta Path of the headers file.
271 *
272 * @return array{content: string, headers: string[]}|false The response, or false if it must be rebuilt.
273 */
274function jetpack_boost_page_optimize_read_cached_response( $cache_file, $cache_file_meta ) {
275    if ( ! file_exists( $cache_file ) || ! file_exists( $cache_file_meta ) ) {
276        return false;
277    }
278
279    // Silenced because the cache can be emptied between the check above and these reads.
280    // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
281    $content = @file_get_contents( $cache_file );
282    // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
283    $meta = json_decode( (string) @file_get_contents( $cache_file_meta ), true );
284
285    if ( false === $content || empty( $meta['headers'] ) ) {
286        return false;
287    }
288
289    return array(
290        'content' => $content,
291        'headers' => $meta['headers'],
292    );
293}
294
295/**
296 * Store a built response in the cache, if the cache has room for it.
297 *
298 * @param string $cache_file      Path of the content file.
299 * @param string $cache_file_meta Path of the headers file.
300 * @param array  $output          The built response, with 'content' and 'headers'.
301 *
302 * @return void
303 */
304function jetpack_boost_page_optimize_store_cached_response( $cache_file, $cache_file_meta, $output ) {
305    $meta = wp_json_encode( array( 'headers' => $output['headers'] ), JSON_UNESCAPED_SLASHES );
306
307    if ( ! jetpack_boost_page_optimize_cache_has_room( dirname( $cache_file ) . '/page-optimize-cache-*', strlen( $output['content'] ) + strlen( $meta ) ) ) {
308        return;
309    }
310
311    // Headers go first: a cached response served without them has no content type.
312    if ( jetpack_boost_page_optimize_write_cache_file( $cache_file_meta, $meta ) ) {
313        jetpack_boost_page_optimize_write_cache_file( $cache_file, $output['content'] );
314    }
315}
316
317/**
318 * Collapse repeated slashes and `/./` segments in $path, then strip $parent_path from its start.
319 */
320function jetpack_boost_strip_parent_path( $parent_path, $path ) {
321    $trimmed_parent = ltrim( $parent_path, '/' );
322    // realpath() accepts `/./` when PAGE_OPTIMIZE_CONCAT_BASE_DIR is defined, and this path is copied into every rewritten url().
323    $trimmed_path = ltrim( rtrim( preg_replace( '#/(?:\./|/)+#', '/', $path . '/' ), '/' ), '/' );
324
325    if ( substr( $trimmed_path, 0, strlen( $trimmed_parent ) ) === $trimmed_parent ) {
326        $trimmed_path = substr( $trimmed_path, strlen( $trimmed_parent ) );
327    }
328
329    return str_starts_with( $trimmed_path, '/' ) ? $trimmed_path : '/' . $trimmed_path;
330}
331
332/**
333 * Generate a combined and minified output for the current request. This is run regardless of the
334 * type of content being fetched; JavaScript or CSS, so it must handle either.
335 */
336function jetpack_boost_page_optimize_build_output() {
337    $use_wp = defined( 'JETPACK_BOOST_CONCAT_USE_WP' ) && JETPACK_BOOST_CONCAT_USE_WP;
338    $utils  = new Utils( $use_wp );
339
340    $jetpack_boost_page_optimize_types = jetpack_boost_page_optimize_types();
341
342    // Config
343    $concat_max_files = jetpack_boost_minify_concat_max_files();
344    $concat_unique    = true;
345
346    // Main
347    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
348    $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $utils->unslash( $_SERVER['REQUEST_URI'] ) : '';
349
350    $args = jetpack_boost_page_optimize_get_issued_list( jetpack_boost_page_optimize_get_request_list( $request_uri ) )->get_paths();
351
352    // args contain something like array( '/foo/bar.css', '/foo1/bar/baz.css' )
353    if ( 0 === count( $args ) || count( $args ) > $concat_max_files ) {
354        jetpack_boost_page_optimize_status_exit( 400 );
355    }
356
357    jetpack_boost_page_optimize_check_files_size( $args );
358
359    // If we're in a subdirectory context, use that as the root.
360    // We can't assume that the root serves the same content as the subdir.
361    $subdir_path_prefix = jetpack_boost_page_optimize_get_subdir_prefix( $request_uri );
362
363    $last_modified = 0;
364    $pre_output    = '';
365    $output        = '';
366    $total_bytes   = 0;
367    $limits        = jetpack_boost_page_optimize_limits();
368
369    foreach ( $args as $uri ) {
370        $fullpath = jetpack_boost_page_optimize_get_path( $uri );
371
372        if ( ! file_exists( $fullpath ) ) {
373            jetpack_boost_page_optimize_status_exit( 404 );
374        }
375
376        $mime_type = jetpack_boost_page_optimize_get_mime_type( $fullpath );
377        if ( ! in_array( $mime_type, $jetpack_boost_page_optimize_types, true ) ) {
378            jetpack_boost_page_optimize_status_exit( 400 );
379        }
380
381        if ( $concat_unique ) {
382            if ( ! isset( $last_mime_type ) ) {
383                $last_mime_type = $mime_type;
384            }
385
386            if ( $last_mime_type !== $mime_type ) {
387                jetpack_boost_page_optimize_status_exit( 400 );
388            }
389        }
390
391        $stat = stat( $fullpath );
392        if ( false === $stat ) {
393            jetpack_boost_page_optimize_status_exit( 500 );
394        }
395
396        if ( $stat['mtime'] > $last_modified ) {
397            $last_modified = $stat['mtime'];
398        }
399
400        $total_bytes += $stat['size'];
401        if ( $total_bytes > $limits['files_read'] ) {
402            jetpack_boost_page_optimize_status_exit( 400 );
403        }
404
405        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
406        $buf = file_get_contents( $fullpath );
407        if ( false === $buf ) {
408            jetpack_boost_page_optimize_status_exit( 500 );
409        }
410
411        if ( 'text/css' === $mime_type ) {
412            $dirpath = jetpack_boost_strip_parent_path( $subdir_path_prefix, dirname( $uri ) );
413
414            // url(relative/path/to/file) -> url(/absolute/and/not/relative/path/to/file)
415            $buf = jetpack_boost_page_optimize_relative_path_replace( $buf, $dirpath );
416
417            // phpcs:ignore Squiz.PHP.CommentedOutCode.Found
418            // This regex changes things like AlphaImageLoader(...src='relative/path/to/file'...) to AlphaImageLoader(...src='/absolute/path/to/file'...)
419            $buf = preg_replace(
420                '/(Microsoft.AlphaImageLoader\s*\([^\)]*src=(?:\'|")?)([^\/\'"\s\)](?:(?<!http:|https:).)*)\)/isU',
421                '$1' . ( $dirpath === '/' ? '/' : $dirpath . '/' ) . '$2)',
422                $buf
423            );
424
425            // The @charset rules must be on top of the output
426            if ( str_starts_with( $buf, '@charset' ) ) {
427                $buf = preg_replace_callback(
428                    '/(?P<charset_rule>@charset\s+[\'"][^\'"]+[\'"];)/i',
429                    function ( $match ) use ( &$pre_output ) {
430                        if ( str_starts_with( $pre_output, '@charset' ) ) {
431                            return '';
432                        }
433
434                        $pre_output = $match[0] . "\n" . $pre_output;
435
436                        return '';
437                    },
438                    $buf
439                );
440            }
441
442            // Move the @import rules on top of the concatenated output.
443            // Only @charset rule are allowed before them.
444            if ( str_contains( $buf, '@import' ) ) {
445                $buf = preg_replace_callback(
446                    '/(?P<pre_path>@import\s+(?:url\s*\()?[\'"\s]*)(?P<path>[^\'"\s](?:https?:\/\/.+\/?)?.+?)(?P<post_path>[\'"\s\)]*;)/i',
447                    function ( $match ) use ( $dirpath, &$pre_output ) {
448                        if ( ! str_starts_with( $match['path'], 'http' ) && '/' !== $match['path'][0] ) {
449                            $pre_output .= $match['pre_path'] . ( $dirpath === '/' ? '/' : $dirpath . '/' ) .
450                                            $match['path'] . $match['post_path'] . "\n";
451                        } else {
452                            $pre_output .= $match[0] . "\n";
453                        }
454
455                        return '';
456                    },
457                    $buf
458                );
459            }
460
461            // Rewriting URLs can grow a stylesheet past its file size, so check the output limit before minifying.
462            jetpack_boost_page_optimize_check_output_size( strlen( $pre_output ) + strlen( $output ) + strlen( $buf ) );
463
464            // If filename indicates it's already minified, don't minify it again.
465            if ( ! jetpack_boost_page_optimize_is_already_minified( $fullpath ) ) {
466                // Minify CSS.
467                $buf = Minify::css( $buf );
468            }
469            $output .= "$buf";
470        } else {
471            // If filename indicates it's already minified, don't minify it again.
472            if ( ! jetpack_boost_page_optimize_is_already_minified( $fullpath ) ) {
473                // Minify JS
474                $buf = Minify::js( $buf );
475            }
476
477            $output .= "$buf;\n";
478        }
479
480        jetpack_boost_page_optimize_check_output_size( strlen( $pre_output ) + strlen( $output ) );
481    }
482
483    // Don't let trailing whitespace ruin everyone's day. Seems to get stripped by batcache
484    // resulting in ns_error_net_partial_transfer errors.
485    $output = rtrim( $output );
486
487    $headers = array(
488        'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT',
489        "Content-Type: $mime_type",
490    );
491
492    return array(
493        'headers' => $headers,
494        'content' => $pre_output . $output,
495    );
496}
497
498/**
499 * Get the file list named by a _jb_static request.
500 *
501 * @param string $request_uri The request URI.
502 *
503 * @return string The list, as it appears after the two question marks.
504 */
505function jetpack_boost_page_optimize_get_request_list( $request_uri ) {
506    $use_wp = defined( 'JETPACK_BOOST_CONCAT_USE_WP' ) && JETPACK_BOOST_CONCAT_USE_WP;
507    $utils  = new Utils( $use_wp );
508
509    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
510    $method = isset( $_SERVER['REQUEST_METHOD'] ) ? $utils->unslash( $_SERVER['REQUEST_METHOD'] ) : 'GET';
511    if ( ! in_array( $method, array( 'GET', 'HEAD' ), true ) ) {
512        jetpack_boost_page_optimize_status_exit( 400 );
513    }
514
515    // Ensure the path follows one of these forms:
516    // /_jb_static/??777873a36e
517    // -- or, on a page cached before Boost stored its lists --
518    // /_jb_static/??/foo/bar.css,/foo1/bar/baz.css?m=293847g
519    // -- or --
520    // /_jb_static/??-eJzTT8vP109KLNJLLi7W0QdyDEE8IK4CiVjn2hpZGluYmKcDABRMDPM=
521    $query = $utils->parse_url( $request_uri, PHP_URL_QUERY );
522    if ( ! $query || ! str_contains( $query, '?' ) ) {
523        jetpack_boost_page_optimize_status_exit( 400 );
524    }
525
526    return substr( $query, strpos( $query, '?' ) + 1 );
527}
528
529/**
530 * Get the stored list a request names, either by its ID or by the older form of the same list.
531 * Any list the site has not stored is refused.
532 *
533 * @param string $list The list, as it appears in the request.
534 *
535 * @return File_Paths
536 */
537function jetpack_boost_page_optimize_get_issued_list( $list ) {
538    if ( strlen( $list ) > jetpack_boost_page_optimize_limits()['encoded_list'] ) {
539        jetpack_boost_page_optimize_status_exit( 400 );
540    }
541
542    if ( File_Paths::is_id( $list ) ) {
543        $issued = File_Paths::get_issued( $list );
544        if ( ! $issued ) {
545            jetpack_boost_page_optimize_status_exit( 404 );
546        }
547    } else {
548        $issued = jetpack_boost_page_optimize_find_older_list( jetpack_boost_page_optimize_decode_older_list( $list ) );
549        if ( ! $issued ) {
550            jetpack_boost_page_optimize_status_exit( 400 );
551        }
552    }
553
554    if ( ! is_array( $issued->get_paths() ) ) {
555        // Invalid data, abort!
556        jetpack_boost_page_optimize_status_exit( 400 );
557    }
558
559    return $issued;
560}
561
562/**
563 * Decode a list from the URL forms Boost issued before it stored its lists.
564 *
565 * @param string $list The list, as it appears in the request.
566 *
567 * @return string[] The listed file paths.
568 */
569function jetpack_boost_page_optimize_decode_older_list( $list ) {
570    $limits = jetpack_boost_page_optimize_limits();
571
572    // It's a base64 encoded list of file path.
573    // e.g.: /_jb_static/??-eJzTT8vP109KLNJLLi7W0QdyDEE8IK4CiVjn2hpZGluYmKcDABRMDPM=
574    if ( str_starts_with( $list, '-' ) ) {
575
576        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
577        $list = @gzuncompress( base64_decode( substr( $list, 1 ) ), $limits['decoded_list'] );
578        // gzuncompress() can return more than the length it is given, so check the result too.
579        if ( false === $list || strlen( $list ) > $limits['decoded_list'] ) {
580            jetpack_boost_page_optimize_status_exit( 400 );
581        }
582    }
583
584    // It's an unencoded comma-separated list of file paths.
585    // /foo/bar.css,/foo1/bar/baz.css?m=293847g
586    $version_string_pos = strpos( $list, '?' );
587    if ( false !== $version_string_pos ) {
588        $list = substr( $list, 0, $version_string_pos );
589    }
590    // /foo/bar.css,/foo1/bar/baz.css
591    // Split no further than one past the file limit: no stored list is that long, so a longer list is refused.
592    return explode( ',', $list, min( jetpack_boost_minify_concat_max_files(), PHP_INT_MAX - 1 ) + 1 );
593}
594
595/**
596 * Find the stored list that holds the same files as a list from an older URL.
597 *
598 * @param string[] $paths The listed file paths.
599 *
600 * @return File_Paths|null
601 */
602function jetpack_boost_page_optimize_find_older_list( $paths ) {
603    $mtime = 0;
604
605    foreach ( $paths as $uri ) {
606        $fullpath = jetpack_boost_page_optimize_get_path( $uri );
607        if ( ! file_exists( $fullpath ) ) {
608            jetpack_boost_page_optimize_status_exit( 404 );
609        }
610        $mtime = max( $mtime, (int) filemtime( $fullpath ) );
611    }
612
613    return File_Paths::find_issued( $paths, $mtime, jetpack_boost_minify_cache_buster() );
614}
615
616/**
617 * Exit with a given HTTP status code.
618 *
619 * @param int $status HTTP status code.
620 *
621 * @return never
622 */
623function jetpack_boost_page_optimize_status_exit( $status ) {
624    http_response_code( $status );
625    exit( 0 ); // This is a workaround, until a bug in phan is fixed - https://github.com/phan/phan/issues/4888
626}
627
628function jetpack_boost_page_optimize_get_mime_type( $file ) {
629    $jetpack_boost_page_optimize_types = jetpack_boost_page_optimize_types();
630
631    $lastdot_pos = strrpos( $file, '.' );
632    if ( false === $lastdot_pos ) {
633        return false;
634    }
635
636    $ext = substr( $file, $lastdot_pos + 1 );
637    if ( ! isset( $jetpack_boost_page_optimize_types[ $ext ] ) ) {
638        return false;
639    }
640
641    return $jetpack_boost_page_optimize_types[ $ext ];
642}
643
644function jetpack_boost_page_optimize_relative_path_replace( $buf, $dirpath ) {
645    // url(relative/path/to/file) -> url(/absolute/and/not/relative/path/to/file)
646    $buf = preg_replace(
647        '/(:?\s*url\s*\()\s*(?:\'|")?\s*([^\/\'"\s\)](?:(?<!data:|http:|https:|[\(\'"]#|%23).)*)[\'"\s]*\)/isU',
648        '$1' . ( $dirpath === '/' ? '/' : $dirpath . '/' ) . '$2)',
649        $buf
650    );
651
652    return $buf;
653}
654
655function jetpack_boost_page_optimize_get_path( $uri ) {
656    static $dependency_path_mapping;
657
658    if ( ! strlen( $uri ) ) {
659        jetpack_boost_page_optimize_status_exit( 400 );
660    }
661
662    if ( str_contains( $uri, '..' ) || str_contains( $uri, "\0" ) ) {
663        jetpack_boost_page_optimize_status_exit( 400 );
664    }
665
666    if ( defined( 'PAGE_OPTIMIZE_CONCAT_BASE_DIR' ) ) {
667        $path = realpath( PAGE_OPTIMIZE_CONCAT_BASE_DIR . "/$uri" );
668
669        if ( false === $path ) {
670            $path = realpath( Config::get_abspath() . "/$uri" );
671        }
672    } else {
673        if ( empty( $dependency_path_mapping ) ) {
674            $dependency_path_mapping = new Dependency_Path_Mapping();
675        }
676        $path = $dependency_path_mapping->uri_path_to_fs_path( $uri );
677    }
678
679    if ( false === $path ) {
680        jetpack_boost_page_optimize_status_exit( 404 );
681    }
682
683    return $path;
684}