PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 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 All 29 releases
← All changes | includes/class-activity-log.php +90 -4 1.0.21.3.3 View file →
@@ -30,12 +30,25 @@
30 30 defined( 'ABSPATH' ) || exit;
31 31
32 32 final class Activity_Log {
33 33
34 + /**
35 + * Storage key. An OPTION, not a transient — with a persistent object
36 + * cache a transient lives in Redis/Memcached, and `Cache::purge_all()`
37 + * calls `wp_cache_flush()` before it records the purge. The log was
38 + * therefore erased by the very event it exists to record: on any
39 + * Redis/Memcached site "Last purge" could never show more than the one
40 + * row written after the flush. A history a flush can evaporate is not a
41 + * history. Autoload is off — the log is read in admin contexts only.
42 + */
43 + public const OPTION_KEY = 'xspeed_activity_log';
44 +
45 + /** Legacy transient the log used to live in; drained once on read. */
34 46 public const TRANSIENT_KEY = 'xspeed_activity_log';
35 - public const TTL = 2592000; // 30 days
36 - public const MAX_ENTRIES = 50;
37 47
48 + public const TTL = 2592000; // 30 days — legacy transient only.
49 + public const MAX_ENTRIES = 50;
50 +
38 51 public const INFO = 'info';
39 52 public const WARN = 'warn';
40 53 public const ERROR = 'error';
41 54 public const SUCCESS = 'success';
@@ -53,12 +66,26 @@
53 66 );
54 67 if ( count( $entries ) > self::MAX_ENTRIES ) {
55 68 $entries = array_slice( $entries, 0, self::MAX_ENTRIES );
56 69 }
57 - set_transient( self::TRANSIENT_KEY, $entries, self::TTL );
70 + self::store( $entries );
58 71 }
59 72
60 73 /**
74 + * Persist the log with autoload disabled, so it never joins
75 + * `wp_load_alloptions()` on frontend requests.
76 + *
77 + * @param array<int,array<string,mixed>> $entries Newest-first entries.
78 + */
79 + private static function store( array $entries ): void {
80 + if ( false === get_option( self::OPTION_KEY, false ) ) {
81 + add_option( self::OPTION_KEY, $entries, '', 'no' );
82 + return;
83 + }
84 + update_option( self::OPTION_KEY, $entries );
85 + }
86 +
87 + /**
61 88 * Newest-first entries (up to MAX_ENTRIES). Defensive shape coercion
62 89 * so a malformed transient never breaks the dashboard.
63 90 *
64 91 * @return array<int,array{ts:int,type:string,message:string,severity:string}>
@@ -63,10 +90,23 @@
63 90 *
64 91 * @return array<int,array{ts:int,type:string,message:string,severity:string}>
65 92 */
66 93 public static function entries(): array {
67 - $raw = get_transient( self::TRANSIENT_KEY );
94 + $raw = get_option( self::OPTION_KEY, null );
95 +
96 + // One-time migration off the old transient. Done lazily on read so
97 + // no upgrade routine has to run first, and so history written by a
98 + // previous version isn't thrown away.
68 99 if ( ! is_array( $raw ) ) {
100 + $legacy = get_transient( self::TRANSIENT_KEY );
101 + if ( is_array( $legacy ) ) {
102 + self::store( $legacy );
103 + delete_transient( self::TRANSIENT_KEY );
104 + $raw = $legacy;
105 + }
106 + }
107 +
108 + if ( ! is_array( $raw ) ) {
69 109 return array();
70 110 }
71 111 $out = array();
72 112 foreach ( $raw as $e ) {
@@ -82,7 +122,53 @@
82 122 return $out;
83 123 }
84 124
85 125 public static function clear(): void {
126 + delete_option( self::OPTION_KEY );
86 127 delete_transient( self::TRANSIENT_KEY );
128 + }
129 +
130 + /**
131 + * One-time scrub of secret values recorded by earlier versions.
132 + *
133 + * Settings change annotations used to include the raw value of every
134 + * changed field, secrets included, and the dashboard trend endpoints
135 + * serve those annotations. Redacting new writes isn't enough — the log
136 + * is a 30-day transient, so entries written before the upgrade would
137 + * keep exposing credentials until they aged out.
138 + *
139 + * Rewrites any `<secret_key> old→new` fragment to `<secret_key> changed`,
140 + * preserving the rest of the entry so the causal history survives.
141 + */
142 + public static function redact_legacy_secrets(): void {
143 + $entries = self::entries();
144 + if ( empty( $entries ) ) {
145 + return;
146 + }
147 +
148 + $changed = false;
149 + foreach ( $entries as $i => $entry ) {
150 + if ( ! is_array( $entry ) || ! isset( $entry['message'] ) ) {
151 + continue;
152 + }
153 + $message = (string) $entry['message'];
154 + // `key value→value` where key is secret-ish. Values never contain
155 + // a comma (describe_value truncates at 40 chars), so the fragment
156 + // ends at the next comma or the trailing " (via <channel>)".
157 + $scrubbed = preg_replace_callback(
158 + '/([a-z0-9_]*(?:token|password|secret|api_key|passwd|private_key|credential)[a-z0-9_]*)\s+[^,]*?→[^,]*?(?=,|\s+\(via|$)/i',
159 + static function ( $m ) {
160 + return $m[1] . ' changed';
161 + },
162 + $message
163 + );
164 + if ( null !== $scrubbed && $scrubbed !== $message ) {
165 + $entries[ $i ]['message'] = $scrubbed;
166 + $changed = true;
167 + }
168 + }
169 +
170 + if ( $changed ) {
171 + self::store( $entries );
172 + }
87 173 }
88 174 }