PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / wp-cache-constant.php

wp-cache-constant.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.2, at includes/wp-cache-constant.php

545 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The WP_CACHE define, and how to strip it out of wp-config.php.
4 *
5 * Deliberately a plain function in its own file rather than a method on
6 * Cache: `uninstall.php` runs standalone — WordPress loads it without
7 * bootstrapping the plugin, so no class of ours exists there. Keeping the
8 * pattern in one requirable file is what stops the two call sites drifting.
9 * They already had, which is the bug this fixes: the removal regex was
10 * copy-pasted, and hardening one copy would have left the other broken.
11 *
12 * @package XSpeed
13 */
14
15 defined( 'ABSPATH' ) || exit;
16
17 if ( ! function_exists( 'xspeed_has_canonical_dropin_signature' ) ) {
18 /**
19 * Prove xSpeed ownership from an exact standalone PHP comment marker.
20 *
21 * A loose substring is unsafe: foreign code or data can mention the token.
22 * Tokenization excludes strings, while the anchored pattern excludes prose.
23 *
24 * @param string $source advanced-cache.php contents.
25 * @return bool
26 */
27 function xspeed_has_canonical_dropin_signature( $source ) {
28 if ( ! is_string( $source ) || '' === $source || ! function_exists( 'token_get_all' ) ) {
29 return false;
30 }
31 /*
32 * ONE channel: an anchored token comment. That is what every xSpeed
33 * drop-in ever shipped carries (verified across this file's whole
34 * history).
35 *
36 * There used to be a second channel that accepted a PHP identifier
37 * named XSPEED_DROPIN, and it leaked three times: first any mention
38 * of the identifier anywhere in the file, then — once narrowed to a
39 * `const` declaration — `use const XSPEED_DROPIN` and
40 * `class Foo { const XSPEED_DROPIN = 1; }`. Each fix narrowed the
41 * shape and the next review found another one, because "a token
42 * arrangement only we would write" is not a property you can pin
43 * down by enumeration.
44 *
45 * It is gone rather than narrowed a fourth time. remove_dropin() and
46 * uninstall.php DELETE on this verdict, so a false positive destroys
47 * another plugin's live page cache; the channel bought nothing our
48 * own files ever needed.
49 */
50 $comments = array();
51 foreach ( token_get_all( $source ) as $token ) {
52 $id = is_array( $token ) ? $token[0] : null;
53 if ( T_OPEN_TAG === $id || T_WHITESPACE === $id ) {
54 continue;
55 }
56 // A BOM, or blank bytes, before `<?php` arrive as inline HTML. An
57 // editor or FTP client re-saving our own drop-in that way must not
58 // turn it foreign — we would then refuse to update or remove our
59 // own file, and uninstall would leave it behind.
60 if ( T_INLINE_HTML === $id && '' === trim( $token[1], " \t\r\n\0\x0B\xEF\xBB\xBF" ) ) {
61 continue;
62 }
63 if ( ! in_array( $id, array( T_COMMENT, T_DOC_COMMENT ), true ) ) {
64 break; // Real code before any comment: there is no header.
65 }
66 foreach ( preg_split( '/\R/', $token[1] ) ?: array() as $line ) {
67 $line = preg_replace( '/^\s*(?:\/\*+|\*|\/\/|#)\s*/', '', $line );
68 $line = preg_replace( '/\s*\*\/\s*$/', '', (string) $line );
69 if ( '' !== trim( (string) $line ) ) {
70 $comments[] = trim( (string) $line );
71 }
72 }
73 break;
74 }
75 // The marker must OPEN the header. A competitor that documents rival
76 // markers line by line, or carries an interop note, has not handed us
77 // its file — and this verdict authorizes deleting their live drop-in.
78 $first = isset( $comments[0] ) ? $comments[0] : '';
79 if ( '' !== $first && preg_match( '/^XSPEED_DROPIN(?:\s+(?:v(?:ersion)?\s*)?\d[A-Za-z0-9._-]*)?\s*$/i', $first ) ) {
80 return true;
81 }
82 return false;
83 }
84 }
85
86 if ( ! function_exists( 'xspeed_wp_cache_receipt_matches' ) ) {
87 /**
88 * Current-source proof that the stored xSpeed receipt still owns the line.
89 *
90 * @param string $source wp-config.php contents.
91 * @param string $receipt Stored ownership receipt.
92 * @return bool
93 */
94 function xspeed_wp_cache_receipt_matches( $source, $receipt ) {
95 return is_string( $source ) && is_string( $receipt )
96 && 1 === preg_match( '/^[a-f0-9]{32}$/', $receipt )
97 && false !== strpos( $source, 'xSpeed owner:' . $receipt );
98 }
99 }
100
101 if ( ! function_exists( 'xspeed_alternative_block_openers' ) ) {
102 /**
103 * Token indexes of the `:` that opens an alternative-syntax block.
104 *
105 * Resolved by walking each control structure's condition to its matching
106 * `)` and asking what follows, because the condition can contain anything
107 * — a `for` header always contains semicolons, a closure or `match` in an
108 * `if` contains braces, a named argument contains its own colon. An
109 * earlier version watched for a `:` after an opener keyword and let any
110 * `;` or `{` cancel it, which meant `for ( $i = 0; $i < $n; $i++ ):`
111 * never registered at all and a define inside it read as top level —
112 * licensing a rewrite of somebody's conditional configuration.
113 *
114 * `else` and `elseif` are deliberately absent: they CONTINUE the block
115 * their `if` opened, and counting them left the depth permanently
116 * inflated so every later top-level define looked conditional.
117 *
118 * @param array<int,mixed> $raw Output of token_get_all().
119 * @return array<int,bool> Indexes, as keys.
120 */
121 function xspeed_alternative_block_openers( array $raw ) {
122 $trivia = array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT );
123 $openers = array( T_IF, T_WHILE, T_FOR, T_FOREACH, T_SWITCH );
124 if ( defined( 'T_DECLARE' ) ) {
125 $openers[] = T_DECLARE;
126 }
127 $next = static function ( $from ) use ( $raw, $trivia ) {
128 $count = count( $raw );
129 for ( $i = $from; $i < $count; $i++ ) {
130 if ( ! is_array( $raw[ $i ] ) || ! in_array( $raw[ $i ][0], $trivia, true ) ) {
131 return $i;
132 }
133 }
134 return null;
135 };
136
137 $opens = array();
138 $count = count( $raw );
139 for ( $i = 0; $i < $count; $i++ ) {
140 if ( ! is_array( $raw[ $i ] ) || ! in_array( $raw[ $i ][0], $openers, true ) ) {
141 continue;
142 }
143 $open = $next( $i + 1 );
144 if ( null === $open || '(' !== $raw[ $open ] ) {
145 continue;
146 }
147 $depth = 0;
148 $close = null;
149 for ( $j = $open; $j < $count; $j++ ) {
150 if ( '(' === $raw[ $j ] ) {
151 ++$depth;
152 } elseif ( ')' === $raw[ $j ] ) {
153 --$depth;
154 if ( 0 === $depth ) {
155 $close = $j;
156 break;
157 }
158 }
159 }
160 if ( null === $close ) {
161 continue;
162 }
163 $after = $next( $close + 1 );
164 if ( null !== $after && ':' === $raw[ $after ] ) {
165 $opens[ $after ] = true;
166 }
167 }
168 return $opens;
169 }
170 }
171
172 if ( ! function_exists( 'xspeed_parse_wp_cache_defines' ) ) {
173 /**
174 * Token-aware WP_CACHE parser.
175 *
176 * Comments and strings are tokens, so text that merely contains a fake
177 * define can never become configuration. Offsets are byte offsets into the
178 * original source and are used by the rewriter below.
179 *
180 * @param string $source wp-config.php contents.
181 * @return array{state:string,defines:array<int,array{start:int,end:int,value:string}>}
182 */
183 function xspeed_parse_wp_cache_defines( $source ) {
184 if ( ! is_string( $source ) || '' === $source || ! function_exists( 'token_get_all' ) ) {
185 return array(
186 'state' => 'undefined',
187 'defines' => array(),
188 );
189 }
190
191 $raw = token_get_all( $source );
192 $tokens = array();
193 $offset = 0;
194 $depth = 0;
195 $alt = 0;
196 $alt_opens = xspeed_alternative_block_openers( $raw );
197 $alt_close = array();
198 foreach ( array( 'T_ENDIF', 'T_ENDWHILE', 'T_ENDFOR', 'T_ENDFOREACH', 'T_ENDSWITCH', 'T_ENDDECLARE' ) as $name ) {
199 if ( defined( $name ) ) {
200 $alt_close[] = constant( $name );
201 }
202 }
203 foreach ( $raw as $index => $token ) {
204 $id = is_array( $token ) ? $token[0] : null;
205 $text = is_array( $token ) ? $token[1] : $token;
206 /*
207 * Brace depth at the START of each token. A define nested inside
208 * anything — a host's `if ( ! defined( 'WP_CACHE' ) ) { … }`, an
209 * environment switch — is not a statement we may rewrite: the
210 * literal we read is not necessarily what runs, and replacing it
211 * edits someone's conditional configuration.
212 *
213 * Only RAW `{` / `}` count. A `}` inside an interpolated string
214 * ("}{$a}", or the heredoc equivalent) arrives as a typed
215 * T_ENCAPSED_AND_WHITESPACE token whose text happens to be `}`;
216 * counting it desynced the depth and unmasked a define that
217 * really was inside someone's block. The interpolation OPENERS
218 * are typed (T_CURLY_OPEN / T_DOLLAR_OPEN_CURLY_BRACES) and are
219 * closed by a raw `}`, so they are counted to keep the pair
220 * balanced.
221 */
222 /*
223 * Alternative syntax opens a block with `:` and closes it with an
224 * `endif`/`endwhile`/... keyword, no braces at all. Tracking only
225 * braces meant a define that was not the FIRST statement of such
226 * a block read as top level — the boundary token in front of it
227 * is the previous statement's `;` — so an enable rewrote a host's
228 * staging-only define in place and a disable cut it out of the
229 * block.
230 */
231 if ( in_array( $id, $alt_close, true ) && $alt > 0 ) {
232 --$alt;
233 }
234 // A BOM or stray blank bytes before `<?php` arrive as inline HTML
235 // and must not be mistaken for code.
236 if ( T_INLINE_HTML === $id && '' === trim( $text, " \t\r\n\0\x0B\xEF\xBB\xBF" ) ) {
237 $offset += strlen( $text );
238 continue;
239 }
240 $raw_punct = ( null === $id );
241 if ( $raw_punct && '}' === $text && $depth > 0 ) {
242 --$depth;
243 }
244 $tokens[] = array(
245 'id' => $id,
246 'text' => $text,
247 'start' => $offset,
248 'end' => $offset + strlen( $text ),
249 'depth' => $depth + $alt,
250 );
251 if ( ( $raw_punct && '{' === $text )
252 || ( defined( 'T_CURLY_OPEN' ) && T_CURLY_OPEN === $id )
253 || ( defined( 'T_DOLLAR_OPEN_CURLY_BRACES' ) && T_DOLLAR_OPEN_CURLY_BRACES === $id ) ) {
254 ++$depth;
255 } elseif ( isset( $alt_opens[ $index ] ) ) {
256 ++$alt;
257 }
258 $offset += strlen( $text );
259 }
260
261 $is_trivia = static function ( $token ) {
262 return in_array( $token['id'], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true );
263 };
264 $next = static function ( $index ) use ( $tokens, $is_trivia ) {
265 for ( $index++; isset( $tokens[ $index ] ); $index++ ) {
266 if ( ! $is_trivia( $tokens[ $index ] ) ) {
267 return $index;
268 }
269 }
270 return null;
271 };
272 $previous = static function ( $index ) use ( $tokens, $is_trivia ) {
273 for ( $index--; 0 <= $index; $index-- ) {
274 if ( ! $is_trivia( $tokens[ $index ] ) ) {
275 return $index;
276 }
277 }
278 return null;
279 };
280
281 $defines = array();
282 $count = count( $tokens );
283 for ( $i = 0; $i < $count; $i++ ) {
284 /*
285 * `define` and `\define` are the same call, and wp-config.php files
286 * in the wild are written both ways. PHP 8 tokenises the qualified
287 * form as ONE T_NAME_FULLY_QUALIFIED token (`\define`), so matching
288 * T_STRING alone made the define invisible: the detector then read
289 * a site with WP_CACHE on as unclaimed and let an acquisition
290 * through, and the rewriter inserted a SECOND define instead of
291 * replacing the one that was there.
292 *
293 * PHP 7.4 splits it into T_NS_SEPARATOR + T_STRING, which did
294 * match — so the behaviour differed by PHP version, and there the
295 * recorded start missed the leading `\`, leaving a bare backslash
296 * in wp-config.php after a strip. `$start` covers the separator
297 * for that reason.
298 */
299 $start = $tokens[ $i ]['start'];
300 if ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $tokens[ $i ]['id'] ) {
301 if ( '\\define' !== strtolower( $tokens[ $i ]['text'] ) ) {
302 continue;
303 }
304 } elseif ( T_STRING === $tokens[ $i ]['id'] && 'define' === strtolower( $tokens[ $i ]['text'] ) ) {
305 $prior = $previous( $i );
306 if ( null !== $prior && in_array( $tokens[ $prior ]['id'], array( T_OBJECT_OPERATOR, T_DOUBLE_COLON ), true ) ) {
307 continue;
308 }
309 if ( null !== $prior && T_NS_SEPARATOR === $tokens[ $prior ]['id'] ) {
310 // PHP 7.4 splits `\define` into T_NS_SEPARATOR + T_STRING.
311 // `Foo\define` splits the same way, so check what sits in
312 // front of the separator: a name there means someone
313 // else's function, not core's, and stepping $start back
314 // over the separator would leave `Foo` dangling.
315 $before = $previous( $prior );
316 if ( null !== $before && in_array( $tokens[ $before ]['id'], array( T_STRING, T_NS_SEPARATOR ), true ) ) {
317 continue;
318 }
319 $start = $tokens[ $prior ]['start'];
320 }
321 } else {
322 continue;
323 }
324 $open = $next( $i );
325 $name = null !== $open ? $next( $open ) : null;
326 if ( null === $name || '(' !== $tokens[ $open ]['text'] || T_CONSTANT_ENCAPSED_STRING !== $tokens[ $name ]['id'] ) {
327 continue;
328 }
329 $constant = trim( $tokens[ $name ]['text'], "'\"" );
330 $comma = $next( $name );
331 if ( 'WP_CACHE' !== strtoupper( $constant ) || null === $comma || ',' !== $tokens[ $comma ]['text'] ) {
332 continue;
333 }
334
335 $depth = 1;
336 $close = null;
337 for ( $j = $comma + 1; $j < $count; $j++ ) {
338 if ( '(' === $tokens[ $j ]['text'] ) {
339 ++$depth;
340 } elseif ( ')' === $tokens[ $j ]['text'] && 0 === --$depth ) {
341 $close = $j;
342 break;
343 }
344 }
345 if ( null === $close ) {
346 continue;
347 }
348 $end_token = $next( $close );
349 $end = ( null !== $end_token && ';' === $tokens[ $end_token ]['text'] ) ? $tokens[ $end_token ]['end'] : $tokens[ $close ]['end'];
350 $value = '';
351 for ( $j = $comma + 1; $j < $close; $j++ ) {
352 if ( ! $is_trivia( $tokens[ $j ] ) ) {
353 $value .= $tokens[ $j ]['text'];
354 }
355 }
356 /*
357 * Is this define a statement of its own, or the body of something?
358 *
359 * Brace depth alone missed the braceless forms, which are the
360 * common ones in a wp-config.php:
361 *
362 * if ( ! defined( 'WP_CACHE' ) ) define( 'WP_CACHE', true );
363 * if ( $host === 'staging' )
364 * define( 'WP_CACHE', false );
365 *
366 * Both read as top level, so an enable rewrote a host's
367 * conditional in place and a disable cut the statement out —
368 * leaving a dangling `if` that silently captures whatever
369 * statement follows it. A define whose `DB_NAME` neighbour became
370 * conditional is a far worse outcome than a refusal.
371 *
372 * A real statement can only follow `<?php`, `;`, `{`, `}`, or
373 * nothing at all. Anything else — `)` closing a control
374 * condition, `else`, `do`, the `:` of alternative syntax — means
375 * we are the controlled statement. That closes the `if ( … ):
376 * … endif;` gap too.
377 */
378 $before_statement = $previous( $i );
379 if ( null !== $before_statement && $start !== $tokens[ $i ]['start'] ) {
380 // The qualified form: step back past the separator we adopted.
381 $before_statement = $previous( $before_statement );
382 }
383 $boundary = null === $before_statement
384 || T_OPEN_TAG === $tokens[ $before_statement ]['id']
385 || in_array( $tokens[ $before_statement ]['text'], array( ';', '{', '}' ), true );
386
387 $defines[] = array(
388 'start' => $start,
389 'end' => $end,
390 'value' => $value,
391 'conditional' => $tokens[ $i ]['depth'] > 0 || ! $boundary,
392 );
393 $i = $close;
394 }
395
396 if ( 0 === count( $defines ) ) {
397 $state = 'undefined';
398 } elseif ( 1 < count( $defines ) ) {
399 $state = 'duplicate';
400 } elseif ( ! empty( $defines[0]['conditional'] ) ) {
401 $state = 'conditional';
402 } else {
403 $value = strtolower( trim( $defines[0]['value'] ) );
404 if ( preg_match( '/^(?:true|1|[\'\"]1[\'\"])$/i', $value ) ) {
405 $state = 'true';
406 } elseif ( preg_match( '/^(?:false|0|null|[\'\"](?:0)?[\'\"])$/i', $value ) ) {
407 $state = 'false';
408 } else {
409 $state = 'dynamic';
410 }
411 }
412 return array(
413 'state' => $state,
414 'defines' => $defines,
415 );
416 }
417 }
418
419 if ( ! function_exists( 'xspeed_drop_receipt_comment' ) ) {
420 /**
421 * Remove the `// xSpeed owner:<hex>` receipt that trails a WP_CACHE
422 * define, taking the whole comment with it.
423 *
424 * The parser's offsets stop at the `;`, so the receipt we wrote sits
425 * outside every rewrite and every removal. Consuming only as far as the
426 * hex left anything a user had appended to OUR comment standing as bare
427 * code:
428 *
429 * define( 'WP_CACHE', true ); // xSpeed owner:abc123 do not remove
430 *
431 * became `do not remove` at top level — a parse error in wp-config.php,
432 * so the site white-screens. A `//` comment runs to end of line by
433 * definition, so consuming to end of line removes exactly the comment
434 * token and nothing else. It also clears the stacked receipts left by
435 * the historic append bug, which all share that one line.
436 *
437 * @param string $tail Source immediately after the define's `;`.
438 * @return string
439 */
440 function xspeed_drop_receipt_comment( $tail ) {
441 if ( ! is_string( $tail ) ) {
442 return $tail;
443 }
444 return (string) preg_replace( '#^[ \t]*//[ \t]*xSpeed owner:[a-f0-9]*[^\r\n]*#i', '', $tail, 1 );
445 }
446 }
447
448 if ( ! function_exists( 'xspeed_rewrite_wp_cache_define' ) ) {
449 /**
450 * Return rewritten source, or null when the source is ambiguous.
451 *
452 * @param string $source wp-config.php contents.
453 * @param bool $enable Desired literal state.
454 * @param string $marker Optional xSpeed ownership receipt.
455 * @return string|null
456 */
457 function xspeed_rewrite_wp_cache_define( $source, $enable, $marker = '' ) {
458 $parsed = xspeed_parse_wp_cache_defines( $source );
459 if ( in_array( $parsed['state'], array( 'duplicate', 'dynamic', 'conditional' ), true ) ) {
460 return null;
461 }
462 $suffix = '' !== $marker ? ' // xSpeed owner:' . preg_replace( '/[^a-f0-9]/i', '', $marker ) : '';
463 $replacement = $enable ? "define( 'WP_CACHE', true );" . $suffix : '';
464 if ( ! empty( $parsed['defines'] ) ) {
465 $define = $parsed['defines'][0];
466 /*
467 * The parser's `end` is the `;` of the define, so the ownership
468 * receipt WE wrote last time sits just past it and is not part of
469 * what gets replaced. Rewriting therefore used to append a second
470 * receipt to the same line, and a third, and a fourth: the enable
471 * transaction runs on every admin_init, so the line grew by 26
472 * bytes per wp-admin request. Disabling had the mirror bug — the
473 * define went, the receipt comment stayed behind forever.
474 *
475 * Consume it here so a rewrite is idempotent and a removal is
476 * complete. Only our own comment, only when it directly follows
477 * the statement.
478 */
479 $tail = substr( $source, $define['end'] );
480 $tail = xspeed_drop_receipt_comment( $tail );
481 return substr( $source, 0, $define['start'] ) . $replacement . $tail;
482 }
483 if ( ! $enable ) {
484 return $source;
485 }
486 $position = strpos( $source, '<?php' );
487 if ( false === $position ) {
488 return null;
489 }
490 $position += 5;
491 return substr( $source, 0, $position ) . "\n" . $replacement . substr( $source, $position );
492 }
493 }
494
495 if ( ! function_exists( 'xspeed_strip_wp_cache_define' ) ) {
496 /**
497 * Remove every `define( 'WP_CACHE', … );` statement from wp-config source.
498 *
499 * The old pattern hardcoded a lowercase `true` with no `/i`, so
500 * `TRUE`, `True`, `1`, `'1'` and `"1"` never matched — disabling the
501 * cache (or uninstalling) silently left the constant behind. Those
502 * spellings are common: several hosts' one-click stacks and older
503 * tutorials write `TRUE` or `1`, and any plugin that previously owned
504 * the constant may have written it in its own style. The orphan then
505 * confuses the next caching plugin the site installs — W3 Total Cache
506 * and WP Rocket both branch on it — and makes a "clean uninstall" not
507 * clean. (#9)
508 *
509 * Uses the token parser above, so comments and string contents are ignored
510 * while any real spelling is removed:
511 * true / TRUE / 1 / '1' / false / 0, with or without inner spaces.
512 * `false` is removed too — leaving `define( 'WP_CACHE', false );`
513 * behind is the same orphan problem, just quieter.
514 *
515 * Only our own statement shape is targeted. A commented-out line keeps
516 * its `#`/`//` prefix and is left alone by the leading-boundary match;
517 * a define built dynamically (concatenation, a variable) is not a
518 * literal statement and is out of scope for a regex — those are
519 * vanishingly rare in wp-config.php and unsafe to rewrite blind.
520 *
521 * @param string $config Raw wp-config.php contents.
522 * @return string Contents with the define(s) removed.
523 */
524 function xspeed_strip_wp_cache_define( $config ) {
525 if ( ! is_string( $config ) || '' === $config ) {
526 return $config;
527 }
528
529 $parsed = xspeed_parse_wp_cache_defines( $config );
530 foreach ( array_reverse( $parsed['defines'] ) as $define ) {
531 // A define inside someone's conditional is theirs, and cutting the
532 // statement out of the block is how a `{ … }` loses its body.
533 if ( ! empty( $define['conditional'] ) ) {
534 continue;
535 }
536 // Take our ownership receipt comment with the statement it
537 // annotates; leaving `// xSpeed owner:…` in an uninstalled site's
538 // wp-config.php is the same orphan this function exists to stop.
539 $tail = xspeed_drop_receipt_comment( substr( $config, $define['end'] ) );
540 $config = substr( $config, 0, $define['start'] ) . $tail;
541 }
542 return $config;
543 }
544 }
545