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