| @@ -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 | } |