PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 4.3.0, at includes/core/PluginLogicVersionUpgrader.php

495 lines 18.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: refresh opcache, acquire lock, run the
84 * upgrade action, then refresh the permalink cache.
85 *
86 * @param array<string, mixed> $options
87 * @return array<string, mixed>
88 */
89 public function upgradeIfNeeded(array $options) {
90 self::invalidateOpcacheForCriticalFiles();
91
92 $syncUtils = self::syncUtils();
93
94 $synchronizedKeyFromUser = 'update_db_version';
95 $uniqueID = $syncUtils->synchronizerAcquireLockTry($synchronizedKeyFromUser);
96
97 if ($uniqueID == '' || $uniqueID == null) {
98 $this->logger->debugMessage('Avoiding infinite loop on database update.');
99 return $options;
100 }
101
102 $returnValue = $options;
103
104 try {
105 $returnValue = $this->runUpgradeAction($options);
106 } catch (Throwable $e) {
107 $this->logger->errorMessage('Error updating to new version. ', $e instanceof \Exception ? $e : null);
108 throw $e;
109 } finally {
110 $syncUtils->synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser);
111 }
112
113 $permalinkCache = self::permalinkCache();
114 $permalinkCache->updatePermalinkCache(1);
115
116 return $returnValue;
117 }
118
119 /**
120 * The unsynchronized upgrade body: schema creation, cron re-registration,
121 * versioned migrations, DB_VERSION stamp. Public so integration tests can
122 * exercise migration branches without taking the synchronizer lock.
123 *
124 * @param array<string, mixed> $options
125 * @return array<string, mixed>
126 */
127 public function runUpgradeAction(array $options) {
128 $options = array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $options);
129
130 $currentDBVersion = self::currentDbVersionFromOptions($options);
131 $this->logger->infoMessage($this->uniqID . ': Updating database version from ' .
132 $currentDBVersion . ' to ' . ABJ404_VERSION . ' (begin).');
133
134 ABJ_404_Solution_FileSystemService::deleteDirectoryRecursively(ABJ404_PATH . 'temp/');
135 $this->createDatabaseTables();
136 $this->refreshUpgradeCrons();
137
138 $pluginLogic = self::pluginLogic();
139 $this->migrateIgnoredUserAgents($options, $currentDBVersion, $pluginLogic);
140 $this->migrateLegacyLogsTable($currentDBVersion);
141 $this->migrateIgnoredFolders($options, $currentDBVersion, $pluginLogic);
142 $this->normalizeDest404Page($options, $pluginLogic);
143 $this->markSetupCompletedForExistingInstall($currentDBVersion);
144 $this->migrateSuggestMinScoreEnabled($options, $pluginLogic);
145 $this->migrateDest404Behavior($options, $pluginLogic);
146
147 $options = $this->stampDbVersion($options);
148 $this->logger->infoMessage($this->uniqID . ': Updating database version to ' .
149 ABJ404_VERSION . ' (end).');
150
151 return $options;
152 }
153
154 /**
155 * Stamp DB_VERSION on the options array and persist it. Called both as the
156 * tail of runUpgradeAction() and directly from activation paths
157 * (PluginLogicLifecycle::activateSingleSite, DatabaseUpgradeMultiSite) so
158 * a freshly-activated site records the running version without going
159 * through the full upgrade action.
160 *
161 * @param array<string, mixed>|null $options
162 * @return array<string, mixed>
163 */
164 public function stampDbVersion($options = null): array {
165 $pluginLogic = self::pluginLogic();
166 if ($options == null) {
167 $options = abj_service('options_repository')->getOptions(true);
168 }
169
170 $options['DB_VERSION'] = ABJ404_VERSION;
171
172 abj_service('options_repository')->updateOptions($options);
173
174 return $options;
175 }
176
177 /** @param array<string, mixed> $options */
178 private static function currentDbVersionFromOptions(array $options): string {
179 if (array_key_exists('DB_VERSION', $options) && is_string($options['DB_VERSION'])) {
180 return $options['DB_VERSION'];
181 }
182 return '(unknown)';
183 }
184
185 /** @return void */
186 private function createDatabaseTables(): void {
187 $upgradesEtc = abj_service('database_upgrades');
188 if (!is_object($upgradesEtc)
189 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'runSelfHealPrologue')
190 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'createDatabaseTables')) {
191 $this->logger->warn('Service "database_upgrades" does not expose upgrade methods.');
192 }
193 if (!is_object($upgradesEtc)
194 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'runSelfHealPrologue')
195 || !$this->databaseUpgradeServiceCanInvoke($upgradesEtc, 'createDatabaseTables')) {
196 throw new \RuntimeException('Service "database_upgrades" does not expose upgrade methods.');
197 }
198 $upgradesEtc->components()->selfHealUpgrade()->runSelfHealPrologue();
199 $upgradesEtc->components()->bootstrapUpgrade()->createDatabaseTables(true);
200 }
201
202 /** @return void */
203 private function refreshUpgradeCrons(): void {
204 abj_cron_scheduler()->clearHook(ABJ_404_Solution_CronScheduler::HOOK_DUPLICATE_LEGACY);
205
206 ABJ_404_Solution_PluginLogicLifecycle::doUnregisterCrons();
207 ABJ_404_Solution_PluginLogicLifecycle::doRegisterCrons();
208 }
209
210 /**
211 * @param mixed $service
212 */
213 private function databaseUpgradeServiceCanInvoke($service, string $method): bool {
214 return is_object($service)
215 && (method_exists($service, $method) || method_exists($service, '__call'));
216 }
217
218 /**
219 * @param array<string, mixed> $options
220 * @return void
221 */
222 private function migrateIgnoredUserAgents(
223 array &$options,
224 string $currentDBVersion,
225 ABJ_404_Solution_PluginLogic $pluginLogic
226 ): void {
227 if (version_compare($currentDBVersion, '1.9.0') >= 0) {
228 return;
229 }
230
231 $ignoreDoProcessStr = is_string($options['ignore_doprocess']) ? $options['ignore_doprocess'] : '';
232 $userAgents = $this->f->explodeNewline($ignoreDoProcessStr);
233
234 $uasForSearch = $this->f->explodeNewline($ignoreDoProcessStr);
235
236 foreach ($userAgents as &$str) {
237 if ($this->f->strtolower(trim($str)) == 'slurp') {
238 $str = 'Yahoo! Slurp';
239 $this->logger->infoMessage('Changed user agent "Slurp" to "Yahoo! Slurp" in the do not log list.');
240 }
241 }
242
243 if (!in_array('seznambot', $uasForSearch)) {
244 $userAgents[] = 'SeznamBot';
245 $this->logger->infoMessage('Added user agent "SeznamBot" to do not log list."');
246 }
247 if (!in_array('pinterestbot', $uasForSearch)) {
248 $userAgents[] = 'Pinterestbot';
249 $this->logger->infoMessage('Added user agent "Pinterestbot" to do not log list."');
250 }
251 if (!in_array('uptimerobot', $uasForSearch)) {
252 $userAgents[] = 'UptimeRobot';
253 $this->logger->infoMessage('Added user agent "UptimeRobot" to do not log list."');
254 }
255
256 $options['ignore_doprocess'] = implode("\n", $userAgents);
257 abj_service('options_repository')->updateOptions($options);
258 }
259
260 /** @return void */
261 private function migrateLegacyLogsTable(string $currentDBVersion): void {
262 if (version_compare($currentDBVersion, '1.8.0') >= 0) {
263 return;
264 }
265 // Refuse to run from cron. Migration is operator-driven (admin upgrade path).
266 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
267 return;
268 }
269
270 $query = "SHOW TABLES LIKE '{wp_abj404_logs}'";
271 $dbCore = $this->dbCore;
272 if (!$dbCore instanceof ABJ_404_Solution_DatabaseQueryInterface
273 || !$dbCore instanceof ABJ_404_Solution_DatabaseCoreInterface) {
274 throw new \RuntimeException('PluginLogicVersionUpgrader requires database query and table-name resolver methods.');
275 }
276
277 $result = $dbCore->queryAndGetResults($query);
278 $rows = isset($result['rows']) ? $result['rows'] : array();
279
280 $filteredRows = is_array($rows) ? array_filter($rows) : array();
281 if (empty($filteredRows)) {
282 return;
283 }
284
285 $query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . '/../sql/migrateToNewLogsTable.sql');
286 $query = $dbCore->doTableNameReplacements($query);
287 $result = $dbCore->queryAndGetResults($query);
288
289 $rowsAffected = isset($result['rows_affected']) && is_numeric($result['rows_affected'])
290 ? (int)$result['rows_affected']
291 : 0;
292 // The early-return at the top of this function ensures
293 // $currentDBVersion < '1.8.0' here, so the version gate that previously
294 // wrapped this block has been removed (PHPStan smaller.alwaysTrue).
295 if ($rowsAffected > 0) {
296 $this->logger->infoMessage($rowsAffected .
297 ' log rows were migrated to the new table structre.');
298 $dbCore->queryAndGetResults('drop table ' . $dbCore->tableNameResolver()->getLowercasePrefix() . 'abj404_logs');
299 }
300 }
301
302 /**
303 * @param array<string, mixed> $options
304 * @return void
305 */
306 private function migrateIgnoredFolders(
307 array &$options,
308 string $currentDBVersion,
309 ABJ_404_Solution_PluginLogic $pluginLogic
310 ): void {
311 if (version_compare($currentDBVersion, '2.18.0') >= 0) {
312 return;
313 }
314
315 $foldersIgnoreStr = is_string($options['folders_files_ignore']) ? $options['folders_files_ignore'] : '';
316 $originalItems = $this->f->explodeNewline($foldersIgnoreStr);
317
318 $newItems = array('wp-content/plugins/*', 'wp-content/themes/*', '.well-known/acme-challenge/*');
319 foreach ($newItems as $newItem) {
320 if (array_search($newItem, $originalItems) === false) {
321 $originalItems[] = $newItem;
322 $this->logger->infoMessage('Added ' . $newItem . ' to the list of folders to ignore."');
323 }
324 }
325
326 $options['folders_files_ignore'] = implode("\n", $originalItems);
327 abj_service('options_repository')->updateOptions($options);
328 }
329
330 /**
331 * @param array<string, mixed> $options
332 * @return void
333 */
334 private function normalizeDest404Page(array &$options, ABJ_404_Solution_PluginLogic $pluginLogic): void {
335 $dest404page = is_string($options['dest404page']) ? $options['dest404page'] : '';
336 if ($this->f->strpos($dest404page, '|') !== false) {
337 return;
338 }
339
340 if ($dest404page == '0') {
341 $dest404page .= '|' . ABJ404_TYPE_404_DISPLAYED;
342 } else {
343 $dest404page .= '|' . ABJ404_TYPE_POST;
344 }
345 $options['dest404page'] = $dest404page;
346 abj_service('options_repository')->updateOptions($options);
347 }
348
349 /** @return void */
350 private function markSetupCompletedForExistingInstall(string $currentDBVersion): void {
351 if ($currentDBVersion === '0.0.0' || version_compare($currentDBVersion, '3.0.7') >= 0) {
352 return;
353 }
354
355 // @cache-write-audit: opt-out - stores a setup-completion date marker, not a query result
356 update_option('abj404_setup_completed', gmdate('Y-m-d', abj_clock()->now()));
357 $this->logger->infoMessage('Marked setup wizard as completed for existing user.');
358 }
359
360 /**
361 * @param array<string, mixed> $options
362 * @return void
363 */
364 private function migrateSuggestMinScoreEnabled(array &$options, ABJ_404_Solution_PluginLogic $pluginLogic): void {
365 if (isset($options['suggest_minscore_enabled'])) {
366 return;
367 }
368
369 if (isset($options['suggest_minscore']) && is_scalar($options['suggest_minscore']) && intval($options['suggest_minscore']) >= 25) {
370 $options['suggest_minscore_enabled'] = '1';
371 $this->logger->infoMessage('Enabled minimum score filtering based on existing suggest_minscore setting.');
372 } else {
373 $options['suggest_minscore_enabled'] = '0';
374 }
375 abj_service('options_repository')->updateOptions($options);
376 }
377
378 /**
379 * @param array<string, mixed> $options
380 * @return void
381 */
382 private function migrateDest404Behavior(array &$options, ABJ_404_Solution_PluginLogic $pluginLogic): void {
383 if (isset($options['dest404_behavior']) && $options['dest404_behavior'] !== 'theme_default') {
384 return;
385 }
386
387 $dest = is_string($options['dest404page']) ? $options['dest404page'] : '';
388 $options['dest404_behavior'] = self::dest404BehaviorFromDestination($dest);
389 abj_service('options_repository')->updateOptions($options);
390 }
391
392 private static function dest404BehaviorFromDestination(string $dest): string {
393 if ($dest === '0|' . ABJ404_TYPE_404_DISPLAYED || $dest === (string)ABJ404_TYPE_404_DISPLAYED || $dest === '') {
394 return 'theme_default';
395 }
396 if ($dest === '0|' . ABJ404_TYPE_HOME) {
397 return 'homepage';
398 }
399
400 $parts = explode('|', $dest);
401 $pageId = isset($parts[0]) ? (int)$parts[0] : 0;
402 if ($pageId > 0 && ABJ_404_Solution_SystemPage::isSystemPage($pageId)) {
403 return 'suggest';
404 }
405 return 'custom';
406 }
407
408 /** @return ABJ_404_Solution_Functions */
409 private static function functions(): ABJ_404_Solution_Functions {
410 return self::service('functions', ABJ_404_Solution_Functions::class);
411 }
412
413 /** @return ABJ_404_Solution_Logging */
414 private static function logging(): ABJ_404_Solution_Logging {
415 return self::service('logging', ABJ_404_Solution_Logging::class);
416 }
417
418 /** @return ABJ_404_Solution_SynchronizationUtils */
419 private static function syncUtils(): ABJ_404_Solution_SynchronizationUtils {
420 return self::service('sync_utils', ABJ_404_Solution_SynchronizationUtils::class);
421 }
422
423 /** @return ABJ_404_Solution_PermalinkCache */
424 private static function permalinkCache(): ABJ_404_Solution_PermalinkCache {
425 return self::service('permalink_cache', ABJ_404_Solution_PermalinkCache::class);
426 }
427
428 /** @return ABJ_404_Solution_PluginLogic */
429 private static function pluginLogic(): ABJ_404_Solution_PluginLogic {
430 return self::service('plugin_logic', ABJ_404_Solution_PluginLogic::class);
431 }
432
433 /**
434 * @template T of object
435 * @param string $name
436 * @param class-string<T> $className
437 * @return T
438 */
439 private static function service(string $name, string $className) {
440 $service = abj_service($name);
441 if (!$service instanceof $className) {
442 throw new \RuntimeException('Service "' . $name . '" is not a ' . $className . ' instance.');
443 }
444 return $service;
445 }
446
447 /**
448 * @param mixed $dao
449 * @return object
450 */
451 private static function dbCoreFromService($dao): object {
452 $dbCore = is_object($dao) && method_exists($dao, 'getDbCore') ? $dao->getDbCore() : $dao;
453 if (!is_object($dbCore)) {
454 throw new \RuntimeException('PluginLogicVersionUpgrader requires a database service object.');
455 }
456 return $dbCore;
457 }
458
459 /**
460 * Invalidate opcache for files whose APIs callers depend on across an
461 * upgrade. Runs BEFORE the synchronizer lock so a stale opcache copy of
462 * Functions.php cannot survive the upgrade and cause a fatal in the next
463 * request.
464 *
465 * @return string[] File paths that were successfully invalidated.
466 */
467 public static function invalidateOpcacheForCriticalFiles(): array {
468 if (!function_exists('opcache_invalidate')) {
469 return [];
470 }
471
472 $files = [
473 ABJ404_PATH . 'includes/core/Functions.php',
474 ABJ404_PATH . 'includes/php/MbStringAdapter.php',
475 ABJ404_PATH . 'includes/php/MbStringAdapterMb.php',
476 ABJ404_PATH . 'includes/php/MbStringAdapterPreg.php',
477 ABJ404_PATH . 'includes/core/RegexHelper.php',
478 ABJ404_PATH . 'includes/core/RegexHelperMb.php',
479 ABJ404_PATH . 'includes/core/RegexHelperPreg.php',
480 ABJ404_PATH . 'includes/core/QueryStringHelper.php',
481 ABJ404_PATH . 'includes/php/FunctionsMBString.php',
482 ABJ404_PATH . 'includes/php/FunctionsPreg.php',
483 ];
484
485 $invalidated = [];
486 foreach ($files as $file) {
487 if (is_file($file) && @opcache_invalidate($file, true)) {
488 $invalidated[] = $file;
489 }
490 }
491
492 return $invalidated;
493 }
494 }
495