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 / PluginLogicVersionUpgrader.php

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

464 lines 17.6 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 /**
8 * Owns the plugin version upgrade orchestration: detect a version mismatch,
9 * acquire the upgrade synchronizer lock, invalidate opcache for files whose
10 * APIs may have changed across versions, run DDL upgrades, run versioned
11 * data migrations, stamp DB_VERSION, and refresh the permalink cache.
12 *
13 * Extracted from PluginLogic so the upgrade pipeline can be exercised and
14 * reasoned about as a single service rather than tangled into the
15 * options/redirect orchestration class. Composed (not inherited).
16 */
17 class ABJ_404_Solution_PluginLogicVersionUpgrader {
18
19 /** @var ABJ_404_Solution_Functions */
20 private $f;
21
22 /** @var ABJ_404_Solution_Logging */
23 private $logger;
24
25 /** @var object */
26 private $dbCore;
27
28 /** @var string Log-context id for correlating upgrade messages. */
29 private $uniqID;
30
31 /** @var self|null */
32 private static $instance = null;
33 /**
34 * Test seam: install or clear the cached singleton instance without
35 * private-field reflection. Pass null to reset between tests; pass a
36 * configured instance (or double) to install it (M105 singleton-reset seam).
37 *
38 * @param self|null $instance
39 * @return void
40 */
41 public static function setInstance($instance) {
42 self::$instance = $instance;
43 }
44
45
46 /**
47 * @param ABJ_404_Solution_Functions $f
48 * @param ABJ_404_Solution_Logging $logger
49 * @param object $dbCore
50 */
51 public function __construct(
52 ABJ_404_Solution_Functions $f,
53 ABJ_404_Solution_Logging $logger,
54 object $dbCore
55 ) {
56 $this->f = $f;
57 $this->logger = $logger;
58 $this->dbCore = $dbCore;
59 $this->uniqID = uniqid('', true);
60 }
61
62 /** @return self */
63 public static function getInstance(): self {
64 if (self::$instance !== null) {
65 return self::$instance;
66 }
67 if (function_exists('abj_service')) {
68 $dao = abj_service('data_access');
69 self::$instance = new self(
70 self::functions(),
71 self::logging(),
72 self::dbCoreFromService($dao)
73 );
74 return self::$instance;
75 }
76 throw new \RuntimeException(
77 'PluginLogicVersionUpgrader::getInstance() requires the service container helper '
78 . 'abj_service() to be loaded.'
79 );
80 }
81
82 /**
83 * Synchronized entry point: acquire the lock, run the upgrade action, then
84 * refresh the permalink cache.
85 *
86 * Deliberately does NOT touch opcache. Stale post-upgrade bytecode has to
87 * be flushed before the autoloader links anything, which is strictly
88 * earlier than this method can ever run -- by the time the booted plugin
89 * calls it, a mismatched parent/subclass pair has already fataled. That
90 * flush now lives in includes/root-boot/OpcacheUpgradeGuard.php, invoked
91 * from 404-solution.php ahead of spl_autoload_register().
92 *
93 * @param array<string, mixed> $options
94 * @return array<string, mixed>
95 */
96 public function upgradeIfNeeded(array $options) {
97 $syncUtils = self::syncUtils();
98
99 $synchronizedKeyFromUser = 'update_db_version';
100 $uniqueID = $syncUtils->synchronizerAcquireLockTry($synchronizedKeyFromUser);
101
102 if ($uniqueID == '' || $uniqueID == null) {
103 $this->logger->debugMessage('Avoiding infinite loop on database update.');
104 return $options;
105 }
106
107 $returnValue = $options;
108
109 try {
110 $returnValue = $this->runUpgradeAction($options);
111 } catch (Throwable $e) {
112 $this->logger->errorMessage('Error updating to new version. ', $e instanceof \Exception ? $e : null);
113 throw $e;
114 } finally {
115 $syncUtils->synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser);
116 }
117
118 $permalinkCache = self::permalinkCache();
119 $permalinkCache->updatePermalinkCache(1);
120
121 return $returnValue;
122 }
123
124 /**
125 * The unsynchronized upgrade body: schema creation, cron re-registration,
126 * versioned migrations, DB_VERSION stamp. Public so integration tests can
127 * exercise migration branches without taking the synchronizer lock.
128 *
129 * @param array<string, mixed> $options
130 * @return array<string, mixed>
131 */
132 public function runUpgradeAction(array $options) {
133 $options = array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $options);
134
135 $currentDBVersion = self::currentDbVersionFromOptions($options);
136 $this->logger->infoMessage($this->uniqID . ': Updating database version from ' .
137 $currentDBVersion . ' to ' . ABJ404_VERSION . ' (begin).');
138
139 ABJ_404_Solution_FileSystemService::deleteDirectoryRecursively(ABJ404_PATH . 'temp/');
140 $this->createDatabaseTables();
141 $this->refreshUpgradeCrons();
142
143 $pluginLogic = self::pluginLogic();
144 $this->migrateIgnoredUserAgents($options, $currentDBVersion, $pluginLogic);
145 $this->migrateLegacyLogsTable($currentDBVersion);
146 $this->migrateIgnoredFolders($options, $currentDBVersion, $pluginLogic);
147 $this->normalizeDest404Page($options, $pluginLogic);
148 $this->markSetupCompletedForExistingInstall($currentDBVersion);
149 $this->migrateSuggestMinScoreEnabled($options, $pluginLogic);
150 $this->migrateDest404Behavior($options, $pluginLogic);
151
152 $options = $this->stampDbVersion($options);
153 $this->logger->infoMessage($this->uniqID . ': Updating database version to ' .
154 ABJ404_VERSION . ' (end).');
155
156 return $options;
157 }
158
159 /**
160 * Stamp DB_VERSION on the options array and persist it. Called both as the
161 * tail of runUpgradeAction() and directly from activation paths
162 * (PluginLogicLifecycle::activateSingleSite, DatabaseUpgradeMultiSite) so
163 * a freshly-activated site records the running version without going
164 * through the full upgrade action.
165 *
166 * @param array<string, mixed>|null $options
167 * @return array<string, mixed>
168 */
169 public function stampDbVersion($options = null): array {
170 $pluginLogic = self::pluginLogic();
171 if ($options == null) {
172 $options = abj_service('options_repository')->getOptions(true);
173 }
174
175 $options['DB_VERSION'] = ABJ404_VERSION;
176
177 abj_service('options_repository')->updateOptions($options);
178
179 return $options;
180 }
181
182 /** @param array<string, mixed> $options */
183 private static function currentDbVersionFromOptions(array $options): string {
184 if (array_key_exists('DB_VERSION', $options) && is_string($options['DB_VERSION'])) {
185 return $options['DB_VERSION'];
186 }
187 return '(unknown)';
188 }
189
190 /** @return void */
191 private function createDatabaseTables(): void {
192 $upgradesEtc = abj_service('database_upgrades');
193 if (!is_object($upgradesEtc)
194 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'runSelfHealPrologue')
195 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'createDatabaseTables')) {
196 $this->logger->warn('Service "database_upgrades" does not expose upgrade methods.');
197 }
198 if (!is_object($upgradesEtc)
199 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'runSelfHealPrologue')
200 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'createDatabaseTables')) {
201 throw new \RuntimeException('Service "database_upgrades" does not expose upgrade methods.');
202 }
203 $upgradesEtc->components()->selfHealUpgrade()->runSelfHealPrologue();
204 $upgradesEtc->components()->bootstrapUpgrade()->createDatabaseTables(true);
205 }
206
207 /** @return void */
208 private function refreshUpgradeCrons(): void {
209 abj_cron_scheduler()->clearHook(ABJ_404_Solution_CronScheduler::HOOK_DUPLICATE_LEGACY);
210
211 ABJ_404_Solution_PluginLogicLifecycle::doUnregisterCrons();
212 ABJ_404_Solution_PluginLogicLifecycle::doRegisterCrons();
213 }
214
215 /**
216 * @param mixed $service
217 */
218 private function databaseUpgradeServiceCanInvoke($service, string $method): bool {
219 return is_object($service)
220 && (method_exists($service, $method) || method_exists($service, '__call'));
221 }
222
223 /**
224 * @param array<string, mixed> $options
225 * @return void
226 */
227 private function migrateIgnoredUserAgents(
228 array &$options,
229 string $currentDBVersion,
230 ABJ_404_Solution_PluginLogic $pluginLogic
231 ): void {
232 if (version_compare($currentDBVersion, '1.9.0') >= 0) {
233 return;
234 }
235
236 $ignoreDoProcessStr = is_string($options['ignore_doprocess']) ? $options['ignore_doprocess'] : '';
237 $userAgents = $this->f->explodeNewline($ignoreDoProcessStr);
238
239 $uasForSearch = $this->f->explodeNewline($ignoreDoProcessStr);
240
241 foreach ($userAgents as &$str) {
242 if ($this->f->strtolower(trim($str)) == 'slurp') {
243 $str = 'Yahoo! Slurp';
244 $this->logger->infoMessage('Changed user agent "Slurp" to "Yahoo! Slurp" in the do not log list.');
245 }
246 }
247
248 if (!in_array('seznambot', $uasForSearch)) {
249 $userAgents[] = 'SeznamBot';
250 $this->logger->infoMessage('Added user agent "SeznamBot" to do not log list."');
251 }
252 if (!in_array('pinterestbot', $uasForSearch)) {
253 $userAgents[] = 'Pinterestbot';
254 $this->logger->infoMessage('Added user agent "Pinterestbot" to do not log list."');
255 }
256 if (!in_array('uptimerobot', $uasForSearch)) {
257 $userAgents[] = 'UptimeRobot';
258 $this->logger->infoMessage('Added user agent "UptimeRobot" to do not log list."');
259 }
260
261 $options['ignore_doprocess'] = implode("\n", $userAgents);
262 abj_service('options_repository')->updateOptions($options);
263 }
264
265 /** @return void */
266 private function migrateLegacyLogsTable(string $currentDBVersion): void {
267 if (version_compare($currentDBVersion, '1.8.0') >= 0) {
268 return;
269 }
270 // Refuse to run from cron. Migration is operator-driven (admin upgrade path).
271 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
272 return;
273 }
274
275 $query = "SHOW TABLES LIKE '{wp_abj404_logs}'";
276 $dbCore = $this->dbCore;
277 if (!$dbCore instanceof ABJ_404_Solution_DatabaseQueryInterface
278 || !$dbCore instanceof ABJ_404_Solution_DatabaseCoreInterface) {
279 throw new \RuntimeException('PluginLogicVersionUpgrader requires database query and table-name resolver methods.');
280 }
281
282 $result = $dbCore->queryAndGetResults($query);
283 $rows = isset($result['rows']) ? $result['rows'] : array();
284
285 $filteredRows = is_array($rows) ? array_filter($rows) : array();
286 if (empty($filteredRows)) {
287 return;
288 }
289
290 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . '/../sql/migrateToNewLogsTable.sql');
291 $query = $dbCore->doTableNameReplacements($query);
292 $result = $dbCore->queryAndGetResults($query);
293
294 $rowsAffected = isset($result['rows_affected']) && is_numeric($result['rows_affected'])
295 ? (int)$result['rows_affected']
296 : 0;
297 // The early-return at the top of this function ensures
298 // $currentDBVersion < '1.8.0' here, so the version gate that previously
299 // wrapped this block has been removed (PHPStan smaller.alwaysTrue).
300 if ($rowsAffected > 0) {
301 $this->logger->infoMessage($rowsAffected .
302 ' log rows were migrated to the new table structre.');
303 $dbCore->queryAndGetResults('drop table ' . $dbCore->tableNameResolver()->getLowercasePrefix() . 'abj404_logs');
304 }
305 }
306
307 /**
308 * @param array<string, mixed> $options
309 * @return void
310 */
311 private function migrateIgnoredFolders(
312 array &$options,
313 string $currentDBVersion,
314 ABJ_404_Solution_PluginLogic $pluginLogic
315 ): void {
316 if (version_compare($currentDBVersion, '2.18.0') >= 0) {
317 return;
318 }
319
320 $foldersIgnoreStr = is_string($options['folders_files_ignore']) ? $options['folders_files_ignore'] : '';
321 $originalItems = $this->f->explodeNewline($foldersIgnoreStr);
322
323 $newItems = array('wp-content/plugins/*', 'wp-content/themes/*', '.well-known/acme-challenge/*');
324 foreach ($newItems as $newItem) {
325 if (array_search($newItem, $originalItems) === false) {
326 $originalItems[] = $newItem;
327 $this->logger->infoMessage('Added ' . $newItem . ' to the list of folders to ignore."');
328 }
329 }
330
331 $options['folders_files_ignore'] = implode("\n", $originalItems);
332 abj_service('options_repository')->updateOptions($options);
333 }
334
335 /**
336 * @param array<string, mixed> $options
337 * @return void
338 */
339 private function normalizeDest404Page(array &$options, ABJ_404_Solution_PluginLogic $pluginLogic): void {
340 $dest404page = is_string($options['dest404page']) ? $options['dest404page'] : '';
341 if ($this->f->strpos($dest404page, '|') !== false) {
342 return;
343 }
344
345 if ($dest404page == '0') {
346 $dest404page .= '|' . ABJ404_TYPE_404_DISPLAYED;
347 } else {
348 $dest404page .= '|' . ABJ404_TYPE_POST;
349 }
350 $options['dest404page'] = $dest404page;
351 abj_service('options_repository')->updateOptions($options);
352 }
353
354 /** @return void */
355 private function markSetupCompletedForExistingInstall(string $currentDBVersion): void {
356 if ($currentDBVersion === '0.0.0' || version_compare($currentDBVersion, '3.0.7') >= 0) {
357 return;
358 }
359
360 // @cache-write-audit: opt-out - stores a setup-completion date marker, not a query result
361 update_option('abj404_setup_completed', gmdate('Y-m-d', abj_clock()->now()));
362 $this->logger->infoMessage('Marked setup wizard as completed for existing user.');
363 }
364
365 /**
366 * @param array<string, mixed> $options
367 * @return void
368 */
369 private function migrateSuggestMinScoreEnabled(array &$options, ABJ_404_Solution_PluginLogic $pluginLogic): void {
370 if (isset($options['suggest_minscore_enabled'])) {
371 return;
372 }
373
374 if (isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) && intval($options['suggest_minscore']) >= 25) {
375 $options['suggest_minscore_enabled'] = '1';
376 $this->logger->infoMessage('Enabled minimum score filtering based on existing suggest_minscore setting.');
377 } else {
378 $options['suggest_minscore_enabled'] = '0';
379 }
380 abj_service('options_repository')->updateOptions($options);
381 }
382
383 /**
384 * @param array<string, mixed> $options
385 * @return void
386 */
387 private function migrateDest404Behavior(array &$options, ABJ_404_Solution_PluginLogic $pluginLogic): void {
388 if (isset($options['dest404_behavior']) && $options['dest404_behavior'] !== 'theme_default') {
389 return;
390 }
391
392 $dest = is_string($options['dest404page']) ? $options['dest404page'] : '';
393 $options['dest404_behavior'] = self::dest404BehaviorFromDestination($dest);
394 abj_service('options_repository')->updateOptions($options);
395 }
396
397 private static function dest404BehaviorFromDestination(string $dest): string {
398 if ($dest === '0|' . ABJ404_TYPE_404_DISPLAYED || $dest === (string)ABJ404_TYPE_404_DISPLAYED || $dest === '') {
399 return 'theme_default';
400 }
401 if ($dest === '0|' . ABJ404_TYPE_HOME) {
402 return 'homepage';
403 }
404
405 $parts = explode('|', $dest);
406 $pageId = isset($parts[0]) ? (int)$parts[0] : 0;
407 if ($pageId > 0 && ABJ_404_Solution_SystemPage::isSystemPage($pageId)) {
408 return 'suggest';
409 }
410 return 'custom';
411 }
412
413 /** @return ABJ_404_Solution_Functions */
414 private static function functions(): ABJ_404_Solution_Functions {
415 return self::service('functions', ABJ_404_Solution_Functions::class);
416 }
417
418 /** @return ABJ_404_Solution_Logging */
419 private static function logging(): ABJ_404_Solution_Logging {
420 return self::service('logging', ABJ_404_Solution_Logging::class);
421 }
422
423 /** @return ABJ_404_Solution_SynchronizationUtils */
424 private static function syncUtils(): ABJ_404_Solution_SynchronizationUtils {
425 return self::service('sync_utils', ABJ_404_Solution_SynchronizationUtils::class);
426 }
427
428 /** @return ABJ_404_Solution_PermalinkCache */
429 private static function permalinkCache(): ABJ_404_Solution_PermalinkCache {
430 return self::service('permalink_cache', ABJ_404_Solution_PermalinkCache::class);
431 }
432
433 /** @return ABJ_404_Solution_PluginLogic */
434 private static function pluginLogic(): ABJ_404_Solution_PluginLogic {
435 return self::service('plugin_logic', ABJ_404_Solution_PluginLogic::class);
436 }
437
438 /**
439 * @template T of object
440 * @param string $name
441 * @param class-string<T> $className
442 * @return T
443 */
444 private static function service(string $name, string $className) {
445 $service = abj_service($name);
446 if (!$service instanceof $className) {
447 throw new \RuntimeException('Service "' . $name . '" is not a ' . $className . ' instance.');
448 }
449 return $service;
450 }
451
452 /**
453 * @param mixed $dao
454 * @return object
455 */
456 private static function dbCoreFromService($dao): object {
457 $dbCore = is_object($dao) && method_exists($dao, 'getDbCore') ? $dao->getDbCore() : $dao;
458 if (!is_object($dbCore)) {
459 throw new \RuntimeException('PluginLogicVersionUpgrader requires a database service object.');
460 }
461 return $dbCore;
462 }
463 }
464