PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / PluginLogicOptionsResolver.php

PluginLogicOptionsResolver.php in 404 Solution trunk, at includes/core/PluginLogicOptionsResolver.php

392 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 require_once __DIR__ . '/PluginLogicDefaults.php';
8 require_once __DIR__ . '/../settings/StorageOptionContracts.php';
9
10 /**
11 * Owns the plugin's persistent settings option (`abj404_settings`):
12 * read, normalize-for-read, merge defaults, version-upgrade if the
13 * DB_VERSION stamp lags ABJ404_VERSION, normalize suggestion template
14 * tokens, cache by db-check mode, and write-back through
15 * StorageOptionContracts. Hosts the getOptions/updateOptions pair
16 * extracted from PluginLogic. Composed by PluginLogic and exposed via
17 * `$pluginLogic->optionsResolver()`; the service container key
18 * `options_repository` remains as the internal plumbing handle used by
19 * auth-time callers (PluginAdminAccessPolicy) that cannot route through
20 * PluginLogic without creating a resolution cycle.
21 */
22 class ABJ_404_Solution_PluginLogicOptionsResolver {
23
24 /** @var array<string, mixed>|null */
25 private $rawCache = null;
26
27 /** @var array<string, mixed>|null */
28 private $resolvedSkipDbCheck = null;
29
30 /** @var array<string, mixed>|null */
31 private $resolvedWithDbCheck = null;
32
33 /** @var self|null */
34 private static $instance = null;
35
36 /** @return self */
37 public static function getInstance(): self {
38 if (self::$instance !== null) {
39 return self::$instance;
40 }
41 self::$instance = new self();
42 return self::$instance;
43 }
44
45 /** Reset cached instance and in-process caches (test seam). @return void */
46 public static function reset(): void {
47 if (self::$instance !== null) {
48 self::$instance->clearCache();
49 }
50 self::$instance = null;
51 }
52
53 /** Clear in-process option caches. @return void */
54 public function clearCache(): void {
55 $this->rawCache = null;
56 $this->resolvedSkipDbCheck = null;
57 $this->resolvedWithDbCheck = null;
58 }
59
60 /**
61 * Return only the delegated plugin-admin option needed by the
62 * authorization policy. This intentionally avoids the full getOptions()
63 * read path because capability checks can run while plugin_logic is being
64 * resolved; the full path performs suggestion-template normalization via
65 * PluginLogicSettingsUpdate and would create an auth-time service cycle.
66 *
67 * @return mixed String, array, or default value accepted by the policy normalizer.
68 */
69 public function getPluginAdminUsersOption() {
70 $optionResult = get_option('abj404_settings');
71 if (!is_array($optionResult)) {
72 return ABJ_404_Solution_PluginLogicDefaults::defaults()['plugin_admin_users'];
73 }
74
75 $normalizedOptions = ABJ_404_Solution_StorageOptionContracts::normalizeForRead(
76 ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS,
77 $optionResult
78 );
79
80 if (array_key_exists('plugin_admin_users', $normalizedOptions)) {
81 return $normalizedOptions['plugin_admin_users'];
82 }
83
84 return ABJ_404_Solution_PluginLogicDefaults::defaults()['plugin_admin_users'];
85 }
86
87 /**
88 * Raw, side-effect-free read of a single stored setting, bypassing the
89 * entire getOptions() pipeline: no normalize-for-read, no schema-fallback
90 * logging, no version upgrade, no default merge, no service resolution, no
91 * cache mutation.
92 *
93 * This is the ONLY read path logging infrastructure may use to fetch the
94 * few scalars it needs (the debug-file key, the debug-mode flag). Routing
95 * those reads through getOptions() created an unbounded logging<->options
96 * recursion: a stored value that fails StorageOptionContracts validation
97 * logs a warning, and getDebugFilename()/isDebug() then re-read options to
98 * find the log file, re-entering normalizeForRead() and re-logging without
99 * bound -- the 4.3.0 "broken sites after the latest update" OOM at this
100 * file's line ~250. Reading raw keeps logging strictly downstream of the
101 * settings repository and can never re-enter it.
102 *
103 * @param string $key Setting key inside abj404_settings.
104 * @param mixed $default Returned when storage is unusable or the key is absent.
105 * @return mixed The raw stored value, or $default.
106 */
107 public function getRawSettingValue(string $key, $default = null) {
108 $raw = get_option('abj404_settings');
109 if (is_array($raw) && array_key_exists($key, $raw)) {
110 return $raw[$key];
111 }
112 return $default;
113 }
114
115 /**
116 * Raw, side-effect-free write of a single stored setting, bypassing the
117 * storage write contract (no prepareForWrite, no schema validation, no
118 * default merge) and, critically, the runtime logger. Used by logging
119 * infrastructure to persist its own metadata keys (debug-file key,
120 * last-sent-line counter) without re-entering the settings pipeline,
121 * and by any caller that needs to update a single bookkeeping key
122 * (e.g. EmailDigest's admin_notification_last_sent cadence timestamp).
123 * Other keys already in the row are preserved (read-modify-write of the
124 * single key), so it narrows the write to just this one key instead of
125 * writing back a whole options snapshot a caller may have read much
126 * earlier (e.g. at the top of a slow request). This is NOT atomic: the
127 * read and the write here are still two separate WordPress option
128 * calls, so a write from another request landing in between is still
129 * possible in principle, just a far smaller window than a caller doing
130 * its own getOptions()-then-updateOptions() round trip. Acceptable for
131 * low-stakes bookkeeping keys; do not use this for values a lost update
132 * would meaningfully harm.
133 *
134 * @param string $key Setting key inside abj404_settings.
135 * @param mixed $value Value to store.
136 * @return void
137 */
138 public function setRawSettingValue(string $key, $value): void {
139 $raw = get_option('abj404_settings');
140 if (!is_array($raw)) {
141 $raw = array();
142 }
143 $raw[$key] = $value;
144 update_option('abj404_settings', $raw);
145 // A later full getOptions() must re-read the row rather than serve a
146 // snapshot taken before this raw write.
147 $this->clearCache();
148 }
149
150 /**
151 * Resolve the current plugin options. With $skip_db_check=true, the
152 * DB_VERSION pipeline is skipped (used during the version-upgrade
153 * sequence itself and from contexts that must not trigger upgrades).
154 *
155 * @param bool $skip_db_check
156 * @return array<string, mixed>
157 */
158 public function getOptions(bool $skip_db_check = false): array {
159 if (!$skip_db_check && is_array($this->resolvedWithDbCheck)) {
160 return $this->resolvedWithDbCheck;
161 }
162 if ($skip_db_check) {
163 if (is_array($this->resolvedSkipDbCheck)) {
164 return $this->resolvedSkipDbCheck;
165 }
166 if (is_array($this->resolvedWithDbCheck)) {
167 return $this->resolvedWithDbCheck;
168 }
169 }
170
171 $legacyOptions = $this->legacyPluginLogicOptionsOverride();
172 if (is_array($legacyOptions)) {
173 return array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $legacyOptions);
174 }
175
176 if ($this->rawCache === null) {
177 $optionResult = get_option('abj404_settings');
178 if (is_array($optionResult)) {
179 $normalizedOptions = ABJ_404_Solution_StorageOptionContracts::normalizeForRead(
180 ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS,
181 $optionResult
182 );
183 $this->rawCache = $normalizedOptions;
184 if ($normalizedOptions !== $optionResult) {
185 $this->updateOptions($normalizedOptions);
186 }
187 } else {
188 $this->rawCache = null;
189 }
190 }
191 $options = $this->rawCache;
192
193 if (!is_array($options)) {
194 add_option('abj404_settings', '', '', false);
195 $options = array();
196 }
197
198 $defaults = ABJ_404_Solution_PluginLogicDefaults::defaults();
199 $missing = false;
200 foreach ($defaults as $key => $value) {
201 if (!isset($options[$key]) || $options[$key] === '') {
202 $options[$key] = $value;
203 $missing = true;
204 }
205 }
206
207 if ($missing) {
208 $this->updateOptions($options);
209 }
210
211 if ($skip_db_check == false) {
212 if (!array_key_exists('DB_VERSION', $options) || $options['DB_VERSION'] != ABJ404_VERSION) {
213 $versionUpgrade = abj_service('version_upgrade');
214 if (is_object($versionUpgrade) && method_exists($versionUpgrade, 'upgradeIfNeeded')) {
215 $options = $versionUpgrade->upgradeIfNeeded($options);
216 } else {
217 $this->warn('version_upgrade service unavailable while reading options; skipped upgrade check.');
218 }
219 }
220 }
221
222 $pluginLogic = abj_service('plugin_logic');
223 $pluginLogicClass = 'ABJ_404_Solution_PluginLogic';
224 $settingsUpdate = is_object($pluginLogic) && method_exists($pluginLogic, 'settingsUpdate')
225 && (!(class_exists($pluginLogicClass) && is_a($pluginLogic, $pluginLogicClass))
226 || get_class($pluginLogic) === $pluginLogicClass)
227 ? $pluginLogic->settingsUpdate()
228 : null;
229 if (is_object($settingsUpdate)
230 && method_exists($settingsUpdate, 'normalizeSuggestionTemplateOptions')
231 && $settingsUpdate->normalizeSuggestionTemplateOptions($options)) {
232 $this->updateOptions($options);
233 }
234
235 if ($skip_db_check) {
236 $this->resolvedSkipDbCheck = $options;
237 } else {
238 $this->resolvedWithDbCheck = $options;
239 }
240
241 return $options;
242 }
243
244 /**
245 * Persist plugin options. Merges defaults, runs the storage write
246 * contract, calls update_option, then invalidates the in-process cache.
247 *
248 * @param array<string, mixed> $options
249 * @return void
250 */
251 public function updateOptions(array $options): void {
252 $options = ABJ_404_Solution_OptionPersistenceTracer::traceCurrent(
253 'options_normalization',
254 static function () use ($options): array {
255 $merged = array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $options);
256 return ABJ_404_Solution_StorageOptionContracts::prepareForWrite(
257 ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS,
258 $merged
259 );
260 }
261 );
262 ABJ_404_Solution_OptionPersistenceTracer::traceCurrentStorageWrite(
263 static function () use ($options): void {
264 update_option('abj404_settings', $options);
265 }
266 );
267 ABJ_404_Solution_OptionPersistenceTracer::traceCurrent(
268 'repository_cache_refresh',
269 function () use ($options): void {
270 $this->rawCache = $options;
271 $this->resolvedSkipDbCheck = null;
272 $this->resolvedWithDbCheck = null;
273 }
274 );
275 }
276
277 /**
278 * Legacy test seam: returns options seeded by a test before the real
279 * WordPress option pipeline (and its DB_VERSION upgrade) is consulted.
280 * Production callers never trigger either branch.
281 *
282 * Two seam shapes are honored, both anchored on PluginLogic::$instance:
283 * 1. Reflection seam (older tests). ABJ_404_Solution_PluginLogic::$options
284 * is set to an array via ReflectionProperty + PluginLogicOptionsResolver::reset().
285 * Used by SpellChecker*Test, CodeReviewIssuesTest, LoggingTest, etc.
286 * 2. Subclass-getOptions seam (post-b2ab795d tests). A test subclass that
287 * extends ABJ_404_Solution_PluginLogic overrides getOptions() and is
288 * installed as the singleton. Real PluginLogic has no getOptions()
289 * method (deleted with the options migration), so method_exists() is a
290 * reliable test-subclass discriminator.
291 *
292 * @return array<string, mixed>|null
293 */
294 private function legacyPluginLogicOptionsOverride() {
295 if (!class_exists('ABJ_404_Solution_PluginLogic')) {
296 return null;
297 }
298 $pluginLogic = $this->readPluginLogicInstance();
299 if (!is_object($pluginLogic)) {
300 return null;
301 }
302
303 // Only the real PluginLogic class declares the private $options property; anonymous
304 // stubs (e.g. ShouldUpdatePluginTest::makeUpgrades) install a sibling class and would
305 // raise on the reflection. Guard so the subclass-getOptions branch below is reached.
306 if (is_a($pluginLogic, 'ABJ_404_Solution_PluginLogic')) {
307 $reflectedOptions = $this->readPluginLogicOptionsProperty($pluginLogic);
308 if (is_array($reflectedOptions)) {
309 return $reflectedOptions;
310 }
311 }
312
313 if (method_exists($pluginLogic, 'getOptions')) {
314 $maybeOptions = $this->callPluginLogicGetOptions($pluginLogic);
315 if (is_array($maybeOptions)) {
316 return $maybeOptions;
317 }
318 }
319
320 return null;
321 }
322
323 /** @return object|null PluginLogic singleton instance, or null when reflection fails. */
324 private function readPluginLogicInstance() {
325 try {
326 $instanceProperty = new ReflectionProperty('ABJ_404_Solution_PluginLogic', 'instance');
327 $value = $instanceProperty->getValue();
328 return is_object($value) ? $value : null;
329 } catch (Throwable $e) {
330 $this->warn('PluginLogicOptionsResolver could not read PluginLogic::$instance via reflection (' . $e->getMessage() . '); falling back to WordPress options.');
331 return null;
332 }
333 }
334
335 /**
336 * @param ABJ_404_Solution_PluginLogic $pluginLogic Real PluginLogic instance (caller verified).
337 * @return array<string, mixed>|null Seeded options array, or null when the property is absent / non-array.
338 */
339 private function readPluginLogicOptionsProperty($pluginLogic) {
340 try {
341 $optionsProperty = new ReflectionProperty('ABJ_404_Solution_PluginLogic', 'options');
342 $options = $optionsProperty->getValue($pluginLogic);
343 if (!is_array($options)) {
344 return null;
345 }
346 /** @var array<string, mixed> $typed */
347 $typed = $options;
348 return $typed;
349 } catch (Throwable $e) {
350 $this->warn('PluginLogicOptionsResolver could not read PluginLogic::$options via reflection (' . $e->getMessage() . '); falling through to subclass-getOptions seam.');
351 return null;
352 }
353 }
354
355 /**
356 * Invoke a test-installed PluginLogic singleton's getOptions(true) override.
357 * Real PluginLogic has no getOptions() method (removed in b2ab795d) so this
358 * is only reached when a test subclass installs one as the singleton.
359 *
360 * @param object $pluginLogic Test stub with a getOptions(bool) method.
361 * @return array<string, mixed>|null Subclass-provided options, or null when the override raised.
362 */
363 private function callPluginLogicGetOptions($pluginLogic) {
364 try {
365 /** @var callable $callable */
366 $callable = array($pluginLogic, 'getOptions');
367 $maybeOptions = call_user_func($callable, true);
368 if (!is_array($maybeOptions)) {
369 return null;
370 }
371 /** @var array<string, mixed> $typed */
372 $typed = $maybeOptions;
373 return $typed;
374 } catch (Throwable $e) {
375 $this->warn('PluginLogicOptionsResolver subclass-getOptions seam raised (' . $e->getMessage() . '); falling back to WordPress options.');
376 return null;
377 }
378 }
379
380 /**
381 * Record a resolver-internal warning. Logs ONLY to the inert PHP error-log
382 * sink, never the runtime logger: this class IS the settings-read path, and
383 * the runtime logger reads settings to locate its own log file, so warning
384 * through it during a read re-enters the read and recurses without bound
385 * (the 4.3.0 "broken sites after the latest update" OOM). Hard rule:
386 * normalized settings reads must never call runtime logging.
387 */
388 private function warn(string $message): void {
389 abj404_logPhpFallback('service-resolution-fallback', $message);
390 }
391 }
392