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 / database / upgrades / DatabaseUpgradePluginUpdate.php

DatabaseUpgradePluginUpdate.php in 404 Solution 4.3.0, at includes/database/upgrades/DatabaseUpgradePluginUpdate.php

294 lines 12.8 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 class ABJ_404_Solution_DatabaseUpgradePluginUpdate extends ABJ_404_Solution_DatabaseUpgradeComponent {
8
9 /**
10 * Resolve the plugin-update metadata repository. Test subclasses can
11 * preload `$this->pluginUpdateRepo` via reflection; production paths
12 * read it from the service container; bare instantiation contexts
13 * (no container) build a fresh repo from the carrier deps.
14 *
15 * @return ABJ_404_Solution_PluginUpdateMetadataRepository
16 */
17 private function resolvePluginUpdateRepo(): ABJ_404_Solution_PluginUpdateMetadataRepository {
18 if ($this->pluginUpdateRepo instanceof ABJ_404_Solution_PluginUpdateMetadataRepository) {
19 return $this->pluginUpdateRepo;
20 }
21 if (class_exists('ABJ_404_Solution_ServiceContainer')
22 && ABJ_404_Solution_ServiceContainer::getInstance()->has('plugin_update_metadata_repository')) {
23 $repo = ABJ_404_Solution_ServiceContainer::getInstance()->get('plugin_update_metadata_repository');
24 if ($repo instanceof ABJ_404_Solution_PluginUpdateMetadataRepository) {
25 $this->pluginUpdateRepo = $repo;
26 return $repo;
27 }
28 }
29 $dbCore = $this->dbCore instanceof ABJ_404_Solution_DatabaseCore
30 ? $this->dbCore
31 : abj_service('db_core');
32 $fresh = new ABJ_404_Solution_PluginUpdateMetadataRepository(
33 $dbCore, $this->f, $this->logger
34 );
35 $this->pluginUpdateRepo = $fresh;
36 return $fresh;
37 }
38
39
40 /**
41 * Migrate existing redirects from absolute paths to relative paths.
42 * This is a one-time migration for upgrading from versions prior to 2.37.0.
43 * Fixes Issue #24: Redirects now survive WordPress subdirectory changes.
44 *
45 * Uses a single atomic SQL UPDATE statement - no locks or transactions needed.
46 *
47 * @return array<string, mixed> Migration results with counts
48 */
49 function migrateURLsToRelativePaths() {
50 global $wpdb;
51
52 $abj404logging = abj_service('logging');
53
54 // Get current WordPress subdirectory
55 $homeURL = get_home_url();
56 $urlPath = parse_url($homeURL, PHP_URL_PATH);
57
58 if ($urlPath === false || $urlPath === null) {
59 $urlPath = '';
60 }
61
62 $decodedPath = rawurldecode(rtrim($urlPath, '/'));
63 $subdirectory = preg_replace('/[\x00-\x1F\x7F]/', '', $decodedPath);
64
65 $results = array(
66 'redirects_updated' => 0,
67 'subdirectory' => $subdirectory,
68 'errors' => array()
69 );
70
71 // Skip if WordPress is at domain root (no subdirectory)
72 if (empty($subdirectory) || $subdirectory === '/') {
73 $abj404logging->debugMessage("No subdirectory detected. Migration skipped.");
74 return $results;
75 }
76
77 $startTime = abj_clock()->nowFloat();
78 $redirectsTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_redirects');
79
80 $abj404logging->infoMessage("Migrating redirects table to relative paths...");
81
82 // Single SQL UPDATE - atomic at database level, no transaction needed
83 // Uses CHAR_LENGTH() for UTF-8 multibyte character safety
84 $subdirectoryWithSlash = $subdirectory . '/';
85
86 // canonical_url stays in lockstep with url — same CASE expression
87 // applied to both columns so the captured-page JOIN keeps matching
88 // logs_hits.requested_url after the path rewrite. Wrapped in
89 // CONCAT('/', TRIM(BOTH '/' FROM ...)) to preserve the canonical form
90 // (no leading-slash duplication, no trailing slash). The CASE WHEN
91 // canonical_url IS NOT NULL guard avoids overwriting NULL rows
92 // (post-upgrade, pre-backfill) which the daily backfill will fill.
93 $canonicalCase = "CASE
94 WHEN url = %s OR url = %s THEN '/'
95 WHEN url LIKE %s THEN CONCAT('/', SUBSTRING(url, CHAR_LENGTH(%s) + 1))
96 ELSE url
97 END";
98
99 // DAO-bypass-approved: One-shot path-relativization migration; $wpdb->prepare is the only safe way to bind 11 ordered placeholders for the multi-line CASE expression; result is fed to the approved $wpdb->query below.
100 $updateQuery = $wpdb->prepare(
101 "UPDATE {$redirectsTable}
102 SET url = " . $canonicalCase . ",
103 canonical_url = CASE
104 WHEN canonical_url IS NULL THEN NULL
105 ELSE CONCAT('/', TRIM(BOTH '/' FROM (" . $canonicalCase . ")))
106 END
107 WHERE url = %s OR url = %s OR url LIKE %s",
108 $subdirectory, // url CASE: exact match /blog
109 $subdirectoryWithSlash, // url CASE: with slash /blog/
110 $wpdb->esc_like($subdirectoryWithSlash) . '%', // url CASE: with path /blog/*
111 $subdirectoryWithSlash, // url SUBSTRING length
112 $subdirectory, // canonical CASE: exact match /blog
113 $subdirectoryWithSlash, // canonical CASE: with slash /blog/
114 $wpdb->esc_like($subdirectoryWithSlash) . '%', // canonical CASE: with path /blog/*
115 $subdirectoryWithSlash, // canonical SUBSTRING length
116 $subdirectory, // WHERE: exact match
117 $subdirectoryWithSlash, // WHERE: with slash
118 $wpdb->esc_like($subdirectoryWithSlash) . '%' // WHERE: with path
119 );
120
121 // DAO-bypass-approved: One-shot path-relativization migration; already prepared (multi-line CASE with prefix args), wpdb->query for rows-affected return; tightly mocked in DatabaseMigrationTest
122 $updateResult = $wpdb->query($updateQuery);
123
124 // Check for errors
125 if ($updateResult === false) {
126 $results['errors'][] = "Failed to update redirects: " . $wpdb->last_error;
127 if (!$this->dbCore->errorClassifier()->classifyAndHandleInfrastructureError($wpdb->last_error ?? '')) {
128 $abj404logging->errorMessage("Migration failed: " . $wpdb->last_error);
129 }
130 } else {
131 $results['redirects_updated'] = $updateResult;
132
133 $duration = abj_clock()->nowFloat() - $startTime;
134 $abj404logging->infoMessage(sprintf(
135 "Migrated %d redirects in %.4f seconds.",
136 $results['redirects_updated'],
137 $duration
138 ));
139
140 // Note: Log entries are intentionally NOT migrated for performance.
141 // Historical logs with absolute paths are display-only and don't affect functionality.
142
143 // Mark migration as complete
144 update_option('abj404_migrated_to_relative_paths', '1');
145 update_option('abj404_migration_results', $results);
146 $abj404logging->infoMessage("Migration to relative paths completed successfully.");
147 }
148
149 return $results;
150 }
151
152
153 /** @return void */
154 function updatePluginCheck() {
155
156 $pluginInfo = $this->resolvePluginUpdateRepo()->getLatestPluginVersion();
157
158 $shouldUpdate = $this->shouldUpdate($pluginInfo);
159
160 if ($shouldUpdate) {
161 $this->doUpdatePlugin($pluginInfo);
162 }
163 }
164
165 /**
166 * @param array<string, mixed> $pluginInfo
167 * @return void
168 */
169 function doUpdatePlugin($pluginInfo) {
170
171 $targetVersion = isset($pluginInfo['version']) && is_scalar($pluginInfo['version'])
172 ? (string)$pluginInfo['version']
173 : '';
174 $this->logger->infoMessage("Attempting update to " . $targetVersion);
175
176 // do the update.
177 if (!class_exists('WP_Upgrader')) {
178 $this->logger->infoMessage("Including WP_Upgrader for update.");
179 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
180 }
181 if (!class_exists('Plugin_Upgrader')) {
182 $this->logger->infoMessage("Including Plugin_Upgrader for update.");
183 require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php';
184 }
185 if (!function_exists('show_message')) {
186 $this->logger->infoMessage("Including misc.php for update.");
187 require_once ABSPATH . 'wp-admin/includes/misc.php';
188 }
189 if (!class_exists('Plugin_Upgrader')) {
190 $this->logger->warn("There was an issue including the Plugin_Upgrader class.");
191 return;
192 }
193 if (!function_exists('show_message')) {
194 $this->logger->warn("There was an issue including the misc.php class.");
195 return;
196 }
197
198 $this->logger->infoMessage("Includes for update complete. Updating... ");
199
200 ob_start();
201 $upgrader = new Plugin_Upgrader();
202 $upret = $upgrader->upgrade(ABJ404_SOLUTION_BASENAME);
203 if ($upret) {
204 $this->logger->infoMessage("Plugin successfully upgraded to " . $targetVersion);
205 }
206 $output = "";
207 if (@ob_get_contents()) {
208 $outputValue = @ob_get_contents();
209 $output = is_string($outputValue) ? $outputValue : '';
210 @ob_end_clean();
211 }
212 if ($this->f->strlen(trim($output)) > 0) {
213 $this->logger->infoMessage("Upgrade output: " . $output);
214 }
215
216 $activateResult = activate_plugin(ABJ404_NAME);
217 if ($activateResult instanceof WP_Error) {
218 $this->logger->errorMessage("Plugin activation error " .
219 json_encode($activateResult->get_error_codes()) . ": " . json_encode($activateResult->get_error_messages()));
220
221 } else {
222 $this->logger->infoMessage("Successfully reactivated plugin after upgrade to version " .
223 $targetVersion);
224 }
225 }
226
227 /**
228 * @param array<string, mixed> $pluginInfo
229 * @return bool
230 */
231 function shouldUpdate($pluginInfo) {
232
233
234 $options = abj_service('options_repository')->getOptions(true);
235 $latestVersion = isset($pluginInfo['version']) && is_string($pluginInfo['version']) ? $pluginInfo['version'] : '';
236
237 if (ABJ404_VERSION == $latestVersion) {
238 $this->logger->debugMessage("The latest plugin version is already installed (" .
239 ABJ404_VERSION . ").");
240 return false;
241 }
242
243 // don't overwrite development versions.
244 if (version_compare(ABJ404_VERSION, $latestVersion) == 1) {
245 $this->logger->infoMessage("Development version: A more recent version is installed than " .
246 "what is available on the WordPress site (" . ABJ404_VERSION . " / " .
247 $latestVersion . ").");
248 return false;
249 }
250
251 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
252 if (in_array($serverName, array('127.0.0.1', '::1', 'localhost'))) {
253 $this->logger->infoMessage("Update narrowly avoided on localhost.");
254 return false;
255 }
256
257 // 1.12.0 becomes array("1", "12", "0")
258 $myVersionArray = explode(".", ABJ404_VERSION);
259 $latestVersionArray = explode(".", $latestVersion);
260
261 // check the latest date to see if it's been long enough to update.
262 $lastUpdated = isset($pluginInfo['last_updated']) && is_string($pluginInfo['last_updated']) ? $pluginInfo['last_updated'] : '';
263 $lastReleaseDate = new DateTime($lastUpdated);
264 $todayDate = new DateTime('@' . abj_clock()->now());
265 $dateInterval = $lastReleaseDate->diff($todayDate);
266 $daysDifference = $dateInterval->days;
267
268 // if there's a new minor version then update.
269 // only update if it was released at least 3 days ago.
270 if ($myVersionArray[0] == $latestVersionArray[0] &&
271 $myVersionArray[1] == $latestVersionArray[1] &&
272 intval($myVersionArray[2]) < intval($latestVersionArray[2]) &&
273 $daysDifference >= 3) {
274
275 $this->logger->infoMessage("A new minor version is available (" .
276 $latestVersion . "), currently version " . ABJ404_VERSION . " is installed.");
277 return true;
278 }
279
280 $minDaysDifference = isset($options['days_wait_before_major_update']) && is_numeric($options['days_wait_before_major_update'])
281 ? (int)$options['days_wait_before_major_update']
282 : 0;
283 if ($daysDifference >= $minDaysDifference) {
284 $this->logger->infoMessage("The latest major version is old enough for updating automatically (" .
285 $minDaysDifference . "days minimum, version " . $latestVersion . " is " . $daysDifference .
286 " days old), currently version " . ABJ404_VERSION . " is installed.");
287 return true;
288 }
289
290 return false;
291 }
292
293 }
294