PluginProbe
Blocks – Reusable Content, Shortcodes & Site Variables / trunk
Blocks – Reusable Content, Shortcodes & Site Variables vtrunk
26.09.03.15 26.08.31.21 26.08.22.20 26.08.22.22 26.08.22.17 26.08.22.13 26.08.21.19 26.08.07.23 26.07.19.14 26.07.13.21 26.07.13.17 26.07.12.13 026.07.07.21 026.07.05.18 026.06.26.20 026.06.26.21 026.06.08.20 026.05.13.14 026.04.29.10 trunk 026.02.22.22 026.03.16.23 026.04.23.13
blocks / src / Assets / JavaScriptAssetOptimizer.php

JavaScriptAssetOptimizer.php in Blocks – Reusable Content, Shortcodes & Site Variables trunk, at src/Assets/JavaScriptAssetOptimizer.php

356 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace RenzoJohnson\Blocks\Assets;
6
7 use RenzoJohnson\Blocks\Vendor\JShrink\Minifier;
8
9 \defined( 'ABSPATH' ) || exit;
10
11 // phpcs:disable WordPress.WP.AlternativeFunctions -- Atomic local cache creation requires same-filesystem locks and renames; WP_Filesystem may request credentials and has no flock primitive.
12 final class JavaScriptAssetOptimizer {
13 private const CACHE_DIRECTORY = 'blocks-js-cache';
14 private const COMPILER_ID = 'jshrink-1.8.1-blocks-prefix-2';
15 private const ENABLED_OPTION = 'blocks_perf_minify_js';
16 private const EXTRA_OPTION = 'blocks_perf_minify_js_handles';
17 private const RETENTION_SECONDS = 2592000;
18 private const DEFAULT_HANDLES = array(
19 'blocks-frontend',
20 'blocks-analytics',
21 'blocks-livechat',
22 'blocks-geo-init',
23 );
24
25 public function __construct( private readonly \Blocks_Settings_Repository $settings ) {
26 }
27
28 public function register_hooks(): void {
29 if ( $this->settings->get_bool( self::ENABLED_OPTION ) ) {
30 \add_filter( 'script_loader_src', $this->filter_source( ... ), 9000, 2 );
31 }
32 }
33
34 public function filter_source( string $src, string $handle ): string {
35 if (
36 \is_admin()
37 || ! $this->settings->get_bool( self::ENABLED_OPTION )
38 || '' === $src
39 || ! \in_array( $handle, $this->selected_handles(), true )
40 ) {
41 return $src;
42 }
43
44 $resolved = $this->resolve_source( $src );
45 if ( null === $resolved ) {
46 return $src;
47 }
48
49 $source = $this->read_file( $resolved['path'] );
50 if ( null === $source || '' === $source ) {
51 return $src;
52 }
53
54 $location = self::cache_location( true );
55 if ( null === $location ) {
56 return $src;
57 }
58
59 $cached_url = $this->cache_source( $resolved['relative'], $source, $location );
60
61 return '' !== $cached_url ? $cached_url : $src;
62 }
63
64 public static function uninstall(): void {
65 $location = self::cache_location( false );
66 if ( null === $location ) {
67 return;
68 }
69
70 $entries = \array_merge(
71 self::glob_paths( $location['directory'] . DIRECTORY_SEPARATOR . '*.min.js' ),
72 self::glob_paths( $location['directory'] . DIRECTORY_SEPARATOR . '.lock-*' ),
73 self::glob_paths( $location['directory'] . DIRECTORY_SEPARATOR . '.tmp-*' )
74 );
75
76 foreach ( $entries as $entry ) {
77 $name = \basename( $entry );
78 if (
79 \is_link( $entry )
80 || ! \is_file( $entry )
81 || (
82 1 !== \preg_match( '/\A[A-Za-z0-9._-]+-[a-f0-9]{16}-[a-f0-9]{16}\.min\.js\z/', $name )
83 && 1 !== \preg_match( '/\A\.lock-[a-f0-9]{16}\z/', $name )
84 && 1 !== \preg_match( '/\A\.tmp-[a-f0-9]{16}-[A-Za-z0-9]+\z/', $name )
85 )
86 ) {
87 continue;
88 }
89 self::quiet_filesystem( static fn (): bool => \unlink( $entry ) );
90 }
91
92 self::quiet_filesystem( static fn (): bool => \rmdir( $location['directory'] ) );
93 }
94
95 /** @return array<int, string> */
96 private function selected_handles(): array {
97 $extra = \preg_split( '/\R/', $this->settings->get_string( self::EXTRA_OPTION ) );
98 $extra = false === $extra ? array() : \array_filter( \array_map( 'trim', $extra ) );
99
100 return \array_values( \array_unique( \array_merge( self::DEFAULT_HANDLES, $extra ) ) );
101 }
102
103 /** @return array{path:string,relative:string}|null */
104 private function resolve_source( string $src ): ?array {
105 $source_parts = \wp_parse_url( \html_entity_decode( $src, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
106 $content_parts = \wp_parse_url( \content_url( '/' ) );
107 if ( ! \is_array( $source_parts ) || ! \is_array( $content_parts ) || ! $this->same_authority( $source_parts, $content_parts ) ) {
108 return null;
109 }
110
111 $source_path = isset( $source_parts['path'] ) && \is_string( $source_parts['path'] ) ? \rawurldecode( $source_parts['path'] ) : '';
112 $content_path = isset( $content_parts['path'] ) && \is_string( $content_parts['path'] ) ? \rawurldecode( $content_parts['path'] ) : '';
113 if ( '' === $source_path || '' === $content_path || \str_contains( $source_path, "\0" ) ) {
114 return null;
115 }
116
117 $prefix = \trailingslashit( $content_path );
118 if ( ! \str_starts_with( $source_path, $prefix ) ) {
119 return null;
120 }
121
122 $relative = \ltrim( \substr( $source_path, \strlen( $prefix ) ), '/' );
123 if (
124 '' === $relative
125 || 1 !== \preg_match( '/\.js\z/i', $relative )
126 || 1 === \preg_match( '/\.min\.js\z/i', $relative )
127 ) {
128 return null;
129 }
130
131 $content_root = \realpath( WP_CONTENT_DIR );
132 $file = \realpath( WP_CONTENT_DIR . DIRECTORY_SEPARATOR . \str_replace( '/', DIRECTORY_SEPARATOR, $relative ) );
133 if (
134 ! \is_string( $content_root )
135 || ! \is_string( $file )
136 || ! \str_starts_with( $file, $content_root . DIRECTORY_SEPARATOR )
137 || ! \is_file( $file )
138 || ! \is_readable( $file )
139 ) {
140 return null;
141 }
142
143 return array(
144 'path' => $file,
145 'relative' => \str_replace( DIRECTORY_SEPARATOR, '/', \substr( $file, \strlen( $content_root ) + 1 ) ),
146 );
147 }
148
149 /**
150 * @param array<string, mixed> $source Source URL parts.
151 * @param array<string, mixed> $content Content URL parts.
152 */
153 private function same_authority( array $source, array $content ): bool {
154 if ( isset( $source['user'] ) || isset( $source['pass'] ) ) {
155 return false;
156 }
157 $source_scheme = isset( $source['scheme'] ) && \is_string( $source['scheme'] ) ? \strtolower( $source['scheme'] ) : null;
158 $content_scheme = isset( $content['scheme'] ) && \is_string( $content['scheme'] ) ? \strtolower( $content['scheme'] ) : null;
159 if ( ! isset( $source['host'] ) ) {
160 return null === $source_scheme;
161 }
162 if (
163 null === $source_scheme
164 || null === $content_scheme
165 || ! \in_array( $source_scheme, array( 'http', 'https' ), true )
166 || ! \in_array( $content_scheme, array( 'http', 'https' ), true )
167 || $source_scheme !== $content_scheme
168 || ! isset( $content['host'] )
169 || ! \is_string( $source['host'] )
170 || ! \is_string( $content['host'] )
171 ) {
172 return false;
173 }
174 if ( \strtolower( $source['host'] ) !== \strtolower( $content['host'] ) ) {
175 return false;
176 }
177
178 if ( ( isset( $source['port'] ) && ! \is_int( $source['port'] ) ) || ( isset( $content['port'] ) && ! \is_int( $content['port'] ) ) ) {
179 return false;
180 }
181 $source_port = isset( $source['port'] ) && \is_int( $source['port'] ) ? $source['port'] : null;
182 $content_port = isset( $content['port'] ) && \is_int( $content['port'] ) ? $content['port'] : null;
183 if ( null === $source_port && null === $content_port ) {
184 return true;
185 }
186
187 $default_port = 'http' === $content_scheme ? 80 : 443;
188
189 return ( $source_port ?? $default_port ) === ( $content_port ?? $default_port );
190 }
191
192 private function read_file( string $path ): ?string {
193 $content = self::quiet_filesystem( static fn (): string|false => \file_get_contents( $path ) );
194
195 return \is_string( $content ) ? $content : null;
196 }
197
198 /** @return array{directory:string,url:string}|null */
199 private static function cache_location( bool $create ): ?array {
200 $uploads = \wp_upload_dir( null, false );
201 if (
202 ! empty( $uploads['error'] )
203 || ! isset( $uploads['basedir'], $uploads['baseurl'] )
204 || ! \is_string( $uploads['basedir'] )
205 || ! \is_string( $uploads['baseurl'] )
206 ) {
207 return null;
208 }
209
210 $base = \realpath( $uploads['basedir'] );
211 if ( ! \is_string( $base ) ) {
212 return null;
213 }
214
215 $expected = $base . DIRECTORY_SEPARATOR . self::CACHE_DIRECTORY;
216 if ( $create && ! \is_dir( $expected ) && ! \wp_mkdir_p( $expected ) ) {
217 return null;
218 }
219
220 $directory = \realpath( $expected );
221 if ( ! \is_string( $directory ) || $directory !== $expected || ! \is_dir( $directory ) ) {
222 return null;
223 }
224
225 return array(
226 'directory' => $directory,
227 'url' => \trailingslashit( $uploads['baseurl'] ) . self::CACHE_DIRECTORY . '/',
228 );
229 }
230
231 /** @param array{directory:string,url:string} $location */
232 private function cache_source( string $relative, string $source, array $location ): string {
233 $path_hash = \substr( \hash( 'sha256', $relative ), 0, 16 );
234 $content_hash = \substr( \hash( 'sha256', self::COMPILER_ID . "\0" . $relative . "\0" . $source ), 0, 16 );
235 $stem = \sanitize_file_name( \pathinfo( $relative, PATHINFO_FILENAME ) );
236 $stem = '' !== $stem ? $stem : 'script';
237 $filename = $stem . '-' . $path_hash . '-' . $content_hash . '.min.js';
238 $destination = $location['directory'] . DIRECTORY_SEPARATOR . $filename;
239 $lock_path = $location['directory'] . DIRECTORY_SEPARATOR . '.lock-' . $path_hash;
240
241 if ( $this->valid_cache_file( $destination ) ) {
242 return $location['url'] . \rawurlencode( $filename );
243 }
244 if ( \is_link( $destination ) || ( \file_exists( $destination ) && ! \is_file( $destination ) ) || \is_link( $lock_path ) ) {
245 return '';
246 }
247
248 $lock = self::quiet_filesystem( static fn () => \fopen( $lock_path, 'c' ) );
249 if ( ! \is_resource( $lock ) ) {
250 return '';
251 }
252
253 $temporary = null;
254 try {
255 if ( ! \flock( $lock, LOCK_EX ) ) {
256 return '';
257 }
258 if ( $this->valid_cache_file( $destination ) ) { // @phpstan-ignore if.alwaysFalse (Another process may fill the cache while this request waits for the lock.)
259 return $location['url'] . \rawurlencode( $filename );
260 }
261 if ( \is_file( $destination ) && ! \is_link( $destination ) ) { // @phpstan-ignore booleanNot.alwaysTrue (Reject a path swapped to a symlink while this request waited.)
262 self::quiet_filesystem( static fn (): bool => \unlink( $destination ) );
263 }
264
265 $vendor = BLOCKS_PLUGIN_DIR . '/includes/jshrink/src/Minifier.php';
266 if ( ! \is_file( $vendor ) ) {
267 return '';
268 }
269 require_once $vendor;
270
271 try {
272 $minified = Minifier::minify( $source, array( 'flaggedComments' => true ) );
273 } catch ( \Throwable ) {
274 return '';
275 }
276 if ( ! \is_string( $minified ) || '' === $minified || \strlen( $minified ) >= \strlen( $source ) ) {
277 return '';
278 }
279
280 $temporary = self::quiet_filesystem( static fn (): string|false => \tempnam( $location['directory'], '.tmp-' . $path_hash . '-' ) );
281 if ( ! \is_string( $temporary ) ) {
282 return '';
283 }
284 $written = self::quiet_filesystem( static fn (): int|false => \file_put_contents( $temporary, $minified, LOCK_EX ) );
285 $mode_set = self::quiet_filesystem( static fn (): bool => \chmod( $temporary, 0644 ) );
286 if ( \strlen( $minified ) !== $written || true !== $mode_set ) {
287 return '';
288 }
289 if ( true !== self::quiet_filesystem( static fn (): bool => \rename( $temporary, $destination ) ) ) {
290 return '';
291 }
292 $temporary = null;
293 $this->prune_old_cache_files( $location['directory'], $filename );
294
295 return $location['url'] . \rawurlencode( $filename );
296 } finally {
297 \flock( $lock, LOCK_UN );
298 self::quiet_filesystem( static fn (): bool => \fclose( $lock ) );
299 if ( \is_string( $temporary ) && \is_file( $temporary ) && ! \is_link( $temporary ) ) {
300 self::quiet_filesystem( static fn (): bool => \unlink( $temporary ) );
301 }
302 }
303 }
304
305 private function valid_cache_file( string $path ): bool {
306 if ( \is_link( $path ) || ! \is_file( $path ) ) {
307 return false;
308 }
309 $size = self::quiet_filesystem( static fn (): int|false => \filesize( $path ) );
310
311 return \is_int( $size ) && $size > 0;
312 }
313
314 private function prune_old_cache_files( string $directory, string $current ): void {
315 $entries = self::glob_paths( $directory . DIRECTORY_SEPARATOR . '*.min.js' );
316 $cutoff = \time() - self::RETENTION_SECONDS;
317 foreach ( $entries as $entry ) {
318 $name = \basename( $entry );
319 if (
320 $current === $name
321 || \is_link( $entry )
322 || ! \is_file( $entry )
323 || 1 !== \preg_match( '/\A[A-Za-z0-9._-]+-[a-f0-9]{16}-[a-f0-9]{16}\.min\.js\z/', $name )
324 || $this->is_recent( $entry, $cutoff )
325 ) {
326 continue;
327 }
328 self::quiet_filesystem( static fn (): bool => \unlink( $entry ) );
329 }
330 }
331
332 private function is_recent( string $path, int $cutoff ): bool {
333 $modified = self::quiet_filesystem( static fn (): int|false => \filemtime( $path ) );
334
335 return false === $modified || $modified >= $cutoff;
336 }
337
338 /** @return array<int, string> */
339 private static function glob_paths( string $pattern ): array {
340 $paths = self::quiet_filesystem( static fn (): array|false => \glob( $pattern ) );
341
342 return \is_array( $paths ) ? $paths : array();
343 }
344
345 private static function quiet_filesystem( callable $operation ): mixed {
346 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Expected file failures must fail open without leaking warnings into frontend output.
347 \set_error_handler( static fn (): bool => true );
348 try {
349 return $operation();
350 } finally {
351 \restore_error_handler();
352 }
353 }
354 }
355 // phpcs:enable WordPress.WP.AlternativeFunctions
356