Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.65% covered (danger)
0.65%
1 / 155
0.00% covered (danger)
0.00%
0 / 9
CRAP
n/a
0 / 0
jetpack_boost_page_optimize_types
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
jetpack_boost_page_optimize_service_request
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
272
jetpack_boost_strip_parent_path
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
jetpack_boost_page_optimize_build_output
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
870
jetpack_boost_page_optimize_get_file_paths
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
30
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
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
jetpack_boost_page_optimize_relative_path_replace
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
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 * Handle serving a minified / concatenated file from the virtual _jb_static dir.
18 *
19 * @return never
20 */
21function jetpack_boost_page_optimize_service_request() {
22    $use_wp = defined( 'JETPACK_BOOST_CONCAT_USE_WP' ) && JETPACK_BOOST_CONCAT_USE_WP;
23    $utils  = new Utils( $use_wp );
24
25    // We handle the cache here, tell other caches not to.
26    if ( ! defined( 'DONOTCACHEPAGE' ) ) {
27        define( 'DONOTCACHEPAGE', true );
28    }
29
30    $use_cache       = Config::can_use_cache();
31    $cache_file      = '';
32    $cache_file_meta = '';
33
34    if ( $use_cache ) {
35        $cache_dir = Config::get_legacy_cache_dir_path();
36        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
37        $request_uri      = isset( $_SERVER['REQUEST_URI'] ) ? $utils->unslash( $_SERVER['REQUEST_URI'] ) : '';
38        $request_uri_hash = md5( $request_uri );
39        $cache_file       = $cache_dir . "/page-optimize-cache-$request_uri_hash";
40        $cache_file_meta  = $cache_dir . "/page-optimize-cache-meta-$request_uri_hash";
41
42        // Serve an existing file.
43        if ( file_exists( $cache_file ) ) {
44            if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) {
45                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
46                if ( strtotime( $utils->unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) < filemtime( $cache_file ) ) {
47                    header( 'HTTP/1.1 304 Not Modified' );
48                    exit( 0 );
49                }
50            }
51
52            if ( file_exists( $cache_file_meta ) ) {
53                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
54                $meta = json_decode( file_get_contents( $cache_file_meta ), true );
55                if ( ! empty( $meta ) && ! empty( $meta['headers'] ) ) {
56                    foreach ( $meta['headers'] as $header ) {
57                        header( $header );
58                    }
59                }
60            }
61
62            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
63            $etag = '"' . md5( file_get_contents( $cache_file ) ) . '"';
64
65            // Check if we're on Atomic and take advantage of the Atomic Edge Cache.
66            if ( defined( 'ATOMIC_CLIENT_ID' ) ) {
67                header( 'A8c-Edge-Cache: cache' );
68            }
69            header( 'X-Page-Optimize: cached' );
70            header( 'Cache-Control: max-age=' . 31536000 );
71            header( 'ETag: ' . $etag );
72
73            echo file_get_contents( $cache_file ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- We need to trust this unfortunately.
74            die( 0 );
75        }
76    }
77
78    // Existing file not available; generate new content.
79    $output  = jetpack_boost_page_optimize_build_output();
80    $content = $output['content'];
81    $headers = $output['headers'];
82
83    foreach ( $headers as $header ) {
84        header( $header );
85    }
86    // Check if we're on Atomic and take advantage of the Atomic Edge Cache.
87    if ( defined( 'ATOMIC_CLIENT_ID' ) ) {
88        header( 'A8c-Edge-Cache: cache' );
89    }
90    header( 'X-Page-Optimize: uncached' );
91    header( 'Cache-Control: max-age=' . 31536000 );
92    header( 'ETag: "' . md5( $content ) . '"' );
93
94    echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- We need to trust this unfortunately.
95
96    // Cache the generated data, if available.
97    if ( $use_cache ) {
98        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
99        file_put_contents( $cache_file, $content );
100        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
101        file_put_contents( $cache_file_meta, wp_json_encode( array( 'headers' => $headers ), JSON_UNESCAPED_SLASHES ) );
102    }
103
104    die( 0 );
105}
106
107/**
108 * Strip matching parent paths off a string. Returns $path without $parent_path.
109 */
110function jetpack_boost_strip_parent_path( $parent_path, $path ) {
111    $trimmed_parent = ltrim( $parent_path, '/' );
112    $trimmed_path   = ltrim( $path, '/' );
113
114    if ( substr( $trimmed_path, 0, strlen( $trimmed_parent ) ) === $trimmed_parent ) {
115        $trimmed_path = substr( $trimmed_path, strlen( $trimmed_parent ) );
116    }
117
118    return str_starts_with( $trimmed_path, '/' ) ? $trimmed_path : '/' . $trimmed_path;
119}
120
121/**
122 * Generate a combined and minified output for the current request. This is run regardless of the
123 * type of content being fetched; JavaScript or CSS, so it must handle either.
124 */
125function jetpack_boost_page_optimize_build_output() {
126    $use_wp = defined( 'JETPACK_BOOST_CONCAT_USE_WP' ) && JETPACK_BOOST_CONCAT_USE_WP;
127    $utils  = new Utils( $use_wp );
128
129    $jetpack_boost_page_optimize_types = jetpack_boost_page_optimize_types();
130
131    // Config
132    $concat_max_files = jetpack_boost_minify_concat_max_files();
133    $concat_unique    = true;
134
135    // Main
136    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
137    $method = isset( $_SERVER['REQUEST_METHOD'] ) ? $utils->unslash( $_SERVER['REQUEST_METHOD'] ) : 'GET';
138    if ( ! in_array( $method, array( 'GET', 'HEAD' ), true ) ) {
139        jetpack_boost_page_optimize_status_exit( 400 );
140    }
141
142    // Ensure the path follows one of these forms:
143    // /_jb_static/??/foo/bar.css,/foo1/bar/baz.css?m=293847g
144    // -- or --
145    // /_jb_static/??-eJzTT8vP109KLNJLLi7W0QdyDEE8IK4CiVjn2hpZGluYmKcDABRMDPM=
146    // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
147    $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $utils->unslash( $_SERVER['REQUEST_URI'] ) : '';
148    $args        = $utils->parse_url( $request_uri, PHP_URL_QUERY );
149    if ( ! $args || ! str_contains( $args, '?' ) ) {
150        jetpack_boost_page_optimize_status_exit( 400 );
151    }
152
153    $args = substr( $args, strpos( $args, '?' ) + 1 );
154
155    $args = jetpack_boost_page_optimize_get_file_paths( $args );
156
157    // args contain something like array( '/foo/bar.css', '/foo1/bar/baz.css' )
158    if ( 0 === count( $args ) || count( $args ) > $concat_max_files ) {
159        jetpack_boost_page_optimize_status_exit( 400 );
160    }
161
162    // If we're in a subdirectory context, use that as the root.
163    // We can't assume that the root serves the same content as the subdir.
164    $subdir_path_prefix = '';
165    $request_path       = $utils->parse_url( $request_uri, PHP_URL_PATH );
166    $_static_index      = strpos( $request_path, jetpack_boost_get_static_prefix() );
167    if ( $_static_index > 0 ) {
168        $subdir_path_prefix = substr( $request_path, 0, $_static_index );
169    }
170    unset( $request_path, $_static_index );
171
172    $last_modified = 0;
173    $pre_output    = '';
174    $output        = '';
175
176    foreach ( $args as $uri ) {
177        $fullpath = jetpack_boost_page_optimize_get_path( $uri );
178
179        if ( ! file_exists( $fullpath ) ) {
180            jetpack_boost_page_optimize_status_exit( 404 );
181        }
182
183        $mime_type = jetpack_boost_page_optimize_get_mime_type( $fullpath );
184        if ( ! in_array( $mime_type, $jetpack_boost_page_optimize_types, true ) ) {
185            jetpack_boost_page_optimize_status_exit( 400 );
186        }
187
188        if ( $concat_unique ) {
189            if ( ! isset( $last_mime_type ) ) {
190                $last_mime_type = $mime_type;
191            }
192
193            if ( $last_mime_type !== $mime_type ) {
194                jetpack_boost_page_optimize_status_exit( 400 );
195            }
196        }
197
198        $stat = stat( $fullpath );
199        if ( false === $stat ) {
200            jetpack_boost_page_optimize_status_exit( 500 );
201        }
202
203        if ( $stat['mtime'] > $last_modified ) {
204            $last_modified = $stat['mtime'];
205        }
206
207        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
208        $buf = file_get_contents( $fullpath );
209        if ( false === $buf ) {
210            jetpack_boost_page_optimize_status_exit( 500 );
211        }
212
213        if ( 'text/css' === $mime_type ) {
214            $dirpath = jetpack_boost_strip_parent_path( $subdir_path_prefix, dirname( $uri ) );
215
216            // url(relative/path/to/file) -> url(/absolute/and/not/relative/path/to/file)
217            $buf = jetpack_boost_page_optimize_relative_path_replace( $buf, $dirpath );
218
219            // phpcs:ignore Squiz.PHP.CommentedOutCode.Found
220            // This regex changes things like AlphaImageLoader(...src='relative/path/to/file'...) to AlphaImageLoader(...src='/absolute/path/to/file'...)
221            $buf = preg_replace(
222                '/(Microsoft.AlphaImageLoader\s*\([^\)]*src=(?:\'|")?)([^\/\'"\s\)](?:(?<!http:|https:).)*)\)/isU',
223                '$1' . ( $dirpath === '/' ? '/' : $dirpath . '/' ) . '$2)',
224                $buf
225            );
226
227            // The @charset rules must be on top of the output
228            if ( str_starts_with( $buf, '@charset' ) ) {
229                $buf = preg_replace_callback(
230                    '/(?P<charset_rule>@charset\s+[\'"][^\'"]+[\'"];)/i',
231                    function ( $match ) use ( &$pre_output ) {
232                        if ( str_starts_with( $pre_output, '@charset' ) ) {
233                            return '';
234                        }
235
236                        $pre_output = $match[0] . "\n" . $pre_output;
237
238                        return '';
239                    },
240                    $buf
241                );
242            }
243
244            // Move the @import rules on top of the concatenated output.
245            // Only @charset rule are allowed before them.
246            if ( str_contains( $buf, '@import' ) ) {
247                $buf = preg_replace_callback(
248                    '/(?P<pre_path>@import\s+(?:url\s*\()?[\'"\s]*)(?P<path>[^\'"\s](?:https?:\/\/.+\/?)?.+?)(?P<post_path>[\'"\s\)]*;)/i',
249                    function ( $match ) use ( $dirpath, &$pre_output ) {
250                        if ( ! str_starts_with( $match['path'], 'http' ) && '/' !== $match['path'][0] ) {
251                            $pre_output .= $match['pre_path'] . ( $dirpath === '/' ? '/' : $dirpath . '/' ) .
252                                            $match['path'] . $match['post_path'] . "\n";
253                        } else {
254                            $pre_output .= $match[0] . "\n";
255                        }
256
257                        return '';
258                    },
259                    $buf
260                );
261            }
262
263            // If filename indicates it's already minified, don't minify it again.
264            if ( ! preg_match( '/\.min\.css$/', $fullpath ) ) {
265                // Minify CSS.
266                $buf = Minify::css( $buf );
267            }
268            $output .= "$buf";
269        } else {
270            // If filename indicates it's already minified, don't minify it again.
271            if ( ! preg_match( '/\.min\.js$/', $fullpath ) ) {
272                // Minify JS
273                $buf = Minify::js( $buf );
274            }
275
276            $output .= "$buf;\n";
277        }
278    }
279
280    // Don't let trailing whitespace ruin everyone's day. Seems to get stripped by batcache
281    // resulting in ns_error_net_partial_transfer errors.
282    $output = rtrim( $output );
283
284    $headers = array(
285        'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $last_modified ) . ' GMT',
286        "Content-Type: $mime_type",
287    );
288
289    return array(
290        'headers' => $headers,
291        'content' => $pre_output . $output,
292    );
293}
294
295function jetpack_boost_page_optimize_get_file_paths( $args ) {
296    $paths = File_Paths::get( $args );
297    if ( $paths ) {
298        $args = $paths->get_paths();
299    } else {
300        // Kept for backward compatibility in case cached page is still referring to old formal asset URLs.
301
302        // It's a base64 encoded list of file path.
303        // e.g.: /_jb_static/??-eJzTT8vP109KLNJLLi7W0QdyDEE8IK4CiVjn2hpZGluYmKcDABRMDPM=
304        if ( '-' === $args[0] ) {
305
306            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
307            $args = @gzuncompress( base64_decode( substr( $args, 1 ) ) );
308        }
309
310        // It's an unencoded comma-separated list of file paths.
311        // /foo/bar.css,/foo1/bar/baz.css?m=293847g
312        $version_string_pos = strpos( $args, '?' );
313        if ( false !== $version_string_pos ) {
314            $args = substr( $args, 0, $version_string_pos );
315        }
316        // /foo/bar.css,/foo1/bar/baz.css
317        $args = explode( ',', $args );
318    }
319
320    if ( ! is_array( $args ) ) {
321        // Invalid data, abort!
322        jetpack_boost_page_optimize_status_exit( 400 );
323    }
324
325    return $args;
326}
327
328/**
329 * Exit with a given HTTP status code.
330 *
331 * @param int $status HTTP status code.
332 *
333 * @return never
334 */
335function jetpack_boost_page_optimize_status_exit( $status ) {
336    http_response_code( $status );
337    exit( 0 ); // This is a workaround, until a bug in phan is fixed - https://github.com/phan/phan/issues/4888
338}
339
340function jetpack_boost_page_optimize_get_mime_type( $file ) {
341    $jetpack_boost_page_optimize_types = jetpack_boost_page_optimize_types();
342
343    $lastdot_pos = strrpos( $file, '.' );
344    if ( false === $lastdot_pos ) {
345        return false;
346    }
347
348    $ext = substr( $file, $lastdot_pos + 1 );
349    if ( ! isset( $jetpack_boost_page_optimize_types[ $ext ] ) ) {
350        return false;
351    }
352
353    return $jetpack_boost_page_optimize_types[ $ext ];
354}
355
356function jetpack_boost_page_optimize_relative_path_replace( $buf, $dirpath ) {
357    // url(relative/path/to/file) -> url(/absolute/and/not/relative/path/to/file)
358    $buf = preg_replace(
359        '/(:?\s*url\s*\()\s*(?:\'|")?\s*([^\/\'"\s\)](?:(?<!data:|http:|https:|[\(\'"]#|%23).)*)[\'"\s]*\)/isU',
360        '$1' . ( $dirpath === '/' ? '/' : $dirpath . '/' ) . '$2)',
361        $buf
362    );
363
364    return $buf;
365}
366
367function jetpack_boost_page_optimize_get_path( $uri ) {
368    static $dependency_path_mapping;
369
370    if ( ! strlen( $uri ) ) {
371        jetpack_boost_page_optimize_status_exit( 400 );
372    }
373
374    if ( str_contains( $uri, '..' ) || str_contains( $uri, "\0" ) ) {
375        jetpack_boost_page_optimize_status_exit( 400 );
376    }
377
378    if ( defined( 'PAGE_OPTIMIZE_CONCAT_BASE_DIR' ) ) {
379        $path = realpath( PAGE_OPTIMIZE_CONCAT_BASE_DIR . "/$uri" );
380
381        if ( false === $path ) {
382            $path = realpath( Config::get_abspath() . "/$uri" );
383        }
384    } else {
385        if ( empty( $dependency_path_mapping ) ) {
386            $dependency_path_mapping = new Dependency_Path_Mapping();
387        }
388        $path = $dependency_path_mapping->uri_path_to_fs_path( $uri );
389    }
390
391    if ( false === $path ) {
392        jetpack_boost_page_optimize_status_exit( 404 );
393    }
394
395    return $path;
396}