PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / database / class-db-migrations.php

class-db-migrations.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.0, at database/class-db-migrations.php

928 lines 35.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * The database migration for the plugin.
5 *
6 * @since 1.0.0
7 * @package Metasync
8 * @subpackage Metasync/database
9 * @author Engineering Team <[email protected]>
10 */
11 // Some Plugins declare class name DBMigration to avoid conflict, renamed the class
12 class MetaSync_DBMigration
13 {
14
15 /**
16 * Option flag marking the leftover wp-config.php copy cleanup as done.
17 */
18 const WPCONFIG_BACKUP_CLEANUP_OPTION = 'metasync_wpconfig_backup_cleanup_done';
19
20 /**
21 * Age in seconds before an orphaned .metasync-tmp-* file is safe to delete.
22 */
23 const WPCONFIG_TMP_MAX_AGE = 300;
24
25 /**
26 * activation of migration.
27 */
28 public static function activation()
29 {
30 self::run_migrations();
31 }
32
33 /**
34 * Run all database migrations
35 */
36 public static function run_migrations()
37 {
38 global $wpdb;
39 $collate = $wpdb->get_charset_collate();
40
41 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
42
43 // Create 404 Monitor Table
44 require_once dirname(__FILE__, 2) . '/404-monitor/class-metasync-404-monitor-database.php';
45 $tableName = esc_sql($wpdb->prefix . Metasync_Error_Monitor_Database::$table_name);
46
47 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $tableName)) != $tableName) {
48 $table_sql = "CREATE TABLE {$tableName} (
49 id BIGINT(20) unsigned NOT NULL AUTO_INCREMENT,
50 uri VARCHAR(255) NOT NULL,
51 date_time DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
52 hits_count BIGINT(20) unsigned NOT NULL DEFAULT 1,
53 user_agent VARCHAR(255) NOT NULL DEFAULT '',
54 PRIMARY KEY id (id),
55 KEY uri (uri(191))
56 ) $collate;";
57
58 dbDelta($table_sql);
59 }
60
61 // Create Redirections Table
62 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-database.php';
63 $tableNameRedirection = esc_sql($wpdb->prefix . Metasync_Redirection_Database::$table_name);
64
65 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameRedirection)) != $tableNameRedirection) {
66 $table_sql = "CREATE TABLE {$tableNameRedirection} (
67 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
68 sources_from TEXT NOT NULL,
69 url_redirect_to TEXT NOT NULL,
70 http_code SMALLINT(4) unsigned NOT NULL DEFAULT 301,
71 hits_count BIGINT(20) unsigned NOT NULL DEFAULT '0',
72 status VARCHAR(25) NOT NULL DEFAULT 'active',
73 pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact',
74 regex_pattern TEXT NULL,
75 description TEXT NULL,
76 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
77 updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
78 last_accessed_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
79 PRIMARY KEY id (id),
80 KEY status (status),
81 KEY pattern_type (pattern_type),
82 KEY created_at (created_at),
83 KEY idx_active_redirects (status, sources_from(191))
84 ) $collate;";
85
86 dbDelta($table_sql);
87 } else {
88 // Check if new columns exist and add them if they don't
89 $columns = $wpdb->get_col("DESCRIBE {$tableNameRedirection}");
90
91 if (!in_array('pattern_type', $columns)) {
92 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact' AFTER status");
93 }
94
95 if (!in_array('regex_pattern', $columns)) {
96 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN regex_pattern TEXT NULL AFTER pattern_type");
97 }
98
99 if (!in_array('description', $columns)) {
100 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN description TEXT NULL AFTER regex_pattern");
101 }
102
103 // Add indexes if they don't exist
104 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameRedirection}");
105 $index_names = array_column($indexes, 'Key_name');
106
107 if (!in_array('pattern_type', $index_names)) {
108 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY pattern_type (pattern_type)");
109 }
110
111 if (!in_array('created_at', $index_names)) {
112 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY created_at (created_at)");
113 }
114
115 // PERFORMANCE OPTIMIZATION: Add composite index for active redirects lookup
116 if (!in_array('idx_active_redirects', $index_names)) {
117 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY idx_active_redirects (status, sources_from(191))");
118 }
119
120 // Set default pattern_type for existing records
121 $wpdb->query("UPDATE {$tableNameRedirection} SET pattern_type = 'exact' WHERE pattern_type IS NULL OR pattern_type = ''");
122 }
123
124 // One-time migration: auto-enable external redirects if the site already has any
125 if (!get_option('metasync_external_redirects_migrated')) {
126 // Compare against every home-URL variant (http/https, www/non-www),
127 // not just the exact home_url() string — otherwise same-site
128 // destinations such as 'https://example.com/about' on a
129 // www-prefixed site count as external and silently flip the setting.
130 $home_host = wp_parse_url(home_url(), PHP_URL_HOST);
131 $home_host = is_string($home_host) ? strtolower(preg_replace('/^www\./i', '', $home_host)) : '';
132 $params = ['http%'];
133 if ($home_host !== '') {
134 $not_like = '';
135 foreach (['http://', 'https://'] as $scheme) {
136 foreach ([$home_host, 'www.' . $home_host] as $host) {
137 $not_like .= ' AND url_redirect_to NOT LIKE %s';
138 $params[] = $wpdb->esc_like($scheme . $host) . '%';
139 }
140 }
141 } else {
142 $not_like = ' AND url_redirect_to NOT LIKE %s';
143 $params[] = $wpdb->esc_like(trailingslashit(home_url())) . '%';
144 }
145 $external_count = $wpdb->get_var(
146 $wpdb->prepare(
147 "SELECT COUNT(*) FROM {$tableNameRedirection} WHERE url_redirect_to LIKE %s{$not_like}",
148 ...$params
149 )
150 );
151 if ($external_count > 0) {
152 update_option('metasync_allow_external_redirects', 1, true);
153 }
154 update_option('metasync_external_redirects_migrated', 1, true);
155 }
156
157 // Create HeartBeat Error Monitor Table
158 require_once dirname(__FILE__, 2) . '/heartbeat-error-monitor/class-metasync-heartbeat-error-monitor-database.php';
159 $tableNameHeartBeatErrorMonitor = esc_sql($wpdb->prefix . Metasync_HeartBeat_Error_Monitor_Database::$table_name);
160
161 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameHeartBeatErrorMonitor)) != $tableNameHeartBeatErrorMonitor) {
162 $table_sql = "CREATE TABLE {$tableNameHeartBeatErrorMonitor} (
163 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
164 attribute_name VARCHAR(25) NOT NULL DEFAULT '',
165 object_count VARCHAR(25) NOT NULL DEFAULT '',
166 error_description TEXT NULL,
167 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
168 PRIMARY KEY id (id)
169 ) $collate;";
170
171 dbDelta($table_sql);
172 }
173
174 // Create Headless Refresh History Table
175 require_once dirname(__FILE__, 2) . '/headless-refresh-history/class-metasync-headless-refresh-history-database.php';
176 $tableNameHeadlessRefreshHistory = esc_sql($wpdb->prefix . Metasync_Headless_Refresh_History::$table_name);
177 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameHeadlessRefreshHistory)) != $tableNameHeadlessRefreshHistory) {
178 $table_sql = "CREATE TABLE {$tableNameHeadlessRefreshHistory} (
179 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
180 object_type VARCHAR(20) NOT NULL DEFAULT '',
181 object_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
182 url_hash CHAR(64) NOT NULL DEFAULT '',
183 outcome VARCHAR(32) NOT NULL DEFAULT '',
184 reason VARCHAR(64) NOT NULL DEFAULT '',
185 duration_ms INT UNSIGNED NOT NULL DEFAULT 0,
186 object_count SMALLINT UNSIGNED NOT NULL DEFAULT 0,
187 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
188 PRIMARY KEY (id),
189 KEY idx_created_at (created_at),
190 KEY idx_object (object_type, object_id, created_at),
191 KEY idx_outcome (outcome, created_at)
192 ) $collate;";
193 dbDelta($table_sql);
194 }
195
196 // Create Sync History Table
197 require_once dirname(__FILE__, 2) . '/sync-history/class-metasync-sync-history-database.php';
198 $tableNameSyncHistory = esc_sql($wpdb->prefix . Metasync_Sync_History_Database::$table_name);
199
200 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameSyncHistory)) != $tableNameSyncHistory) {
201 $table_sql = "CREATE TABLE {$tableNameSyncHistory} (
202 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
203 title VARCHAR(255) NOT NULL DEFAULT '',
204 source VARCHAR(50) NOT NULL DEFAULT '',
205 status VARCHAR(25) NOT NULL DEFAULT 'draft',
206 content_type VARCHAR(50) NOT NULL DEFAULT '',
207 url TEXT NULL,
208 meta_data TEXT NULL,
209 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
210 PRIMARY KEY id (id),
211 KEY source (source),
212 KEY status (status),
213 KEY created_at (created_at),
214 KEY idx_dedup (source, created_at),
215 KEY idx_search (title(50), source, created_at)
216 ) $collate;";
217
218 dbDelta($table_sql);
219 } else {
220 // PERFORMANCE OPTIMIZATION: Add composite indexes to existing tables
221 // Check and add indexes if they don't exist
222 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameSyncHistory}");
223 $index_names = array_column($indexes, 'Key_name');
224
225 // Add deduplication index (source, created_at)
226 if (!in_array('idx_dedup', $index_names)) {
227 $wpdb->query("ALTER TABLE {$tableNameSyncHistory} ADD KEY idx_dedup (source, created_at)");
228 }
229
230 // Add search index (title(50), source, created_at)
231 if (!in_array('idx_search', $index_names)) {
232 $wpdb->query("ALTER TABLE {$tableNameSyncHistory} ADD KEY idx_search (title(50), source, created_at)");
233 }
234 }
235
236 // Create OTTO Excluded URLs Table
237 require_once dirname(__FILE__, 2) . '/otto/class-metasync-otto-excluded-urls-database.php';
238 $tableNameOttoExcludedURLs = esc_sql($wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name);
239
240 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameOttoExcludedURLs)) != $tableNameOttoExcludedURLs) {
241 $table_sql = "CREATE TABLE {$tableNameOttoExcludedURLs} (
242 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
243 url_pattern TEXT NOT NULL,
244 pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex') NOT NULL DEFAULT 'exact',
245 description TEXT NULL,
246 status VARCHAR(25) NOT NULL DEFAULT 'active',
247 is_permanent TINYINT(1) NOT NULL DEFAULT 0,
248 auto_excluded TINYINT(1) NOT NULL DEFAULT 0,
249 recheck_after DATETIME NULL DEFAULT NULL,
250 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
251 PRIMARY KEY id (id),
252 KEY status (status),
253 KEY pattern_type (pattern_type),
254 KEY created_at (created_at),
255 KEY is_permanent (is_permanent),
256 KEY auto_excluded (auto_excluded),
257 KEY recheck_after (recheck_after),
258 UNIQUE KEY url_pattern_type_unique (url_pattern(191), pattern_type)
259 ) $collate;";
260
261 dbDelta($table_sql);
262 }
263
264 // Create Robots.txt Backups Table
265 require_once dirname(__FILE__, 2) . '/robots-txt/class-metasync-robots-txt-database.php';
266 $robots_db = Metasync_Robots_Txt_Database::get_instance();
267 $table_name_robots = esc_sql($wpdb->prefix . 'metasync_robots_txt_backups');
268 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name_robots)) != $table_name_robots) {
269 $robots_db->create_table();
270 }
271
272 }
273
274 /**
275 * deactivation of migration.
276 */
277 public static function deactivation()
278 {
279 global $wpdb;
280 // require_once dirname(__FILE__, 2) . '/404-monitor/class-metasync-404-monitor-database.php';
281 // $tableName = esc_sql($wpdb->prefix . Metasync_Error_Monitor_Database::$table_name);
282
283 /* drop wp_metasync_404_logs table */
284 // $sql = "DROP TABLE IF EXISTS `$tableName` ";
285 // $wpdb->query($sql);
286
287 // require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-database.php';
288 // $tableNameRedirection = esc_sql($wpdb->prefix . Metasync_Redirection_Database::$table_name);
289
290 /* drop wp_metasync_redirections table */
291 // $sql = "DROP TABLE IF EXISTS `$tableNameRedirection` ";
292 // $wpdb->query($sql);
293
294 // Data tables are preserved on deactivation so a deactivate/reactivate cycle
295 // does not silently destroy logged data (e.g. heartbeat error history).
296 // Removal belongs to uninstall, not deactivation.
297 // require_once dirname(__FILE__, 2) . '/heartbeat-error-monitor/class-metasync-heartbeat-error-monitor-database.php';
298 // $tableNameHeartBeatErrorMonitor = esc_sql($wpdb->prefix . Metasync_HeartBeat_Error_Monitor_Database::$table_name);
299 /* drop wp_metasync_heartbeat_error_logs table */
300 // $sql = "DROP TABLE IF EXISTS `$tableNameHeartBeatErrorMonitor` ";
301 // $wpdb->query($sql);
302 }
303
304 /**
305 * Run version-specific migrations
306 */
307 public static function run_version_migrations($from_version, $to_version)
308 {
309 // If from_version is 9.9.9, always run all migrations
310 $force_run = ($from_version === '9.9.9');
311
312 // Migration for versions 2.5.4+ - Enhanced 404 monitor and redirections
313 if ($force_run || version_compare($to_version, '2.5.4', '>=')) {
314 self::migrate_enhanced_features_v2_5_4();
315 }
316
317 // Migration for versions 2.5.6+ - Robots.txt management
318 if ($force_run || version_compare($to_version, '2.5.6', '>=')) {
319 self::migrate_robots_txt_v2_5_6();
320 }
321
322 // Migration for versions 2.5.9+ - OTTO Excluded URLs
323 if ($force_run || version_compare($to_version, '2.5.9', '>=')) {
324 self::migrate_otto_excluded_urls_v2_5_9();
325 }
326
327 // Migration for versions 2.5.20+ - Remove insecure wp-config.php backup copies from the web root
328 if ($force_run || version_compare($to_version, '2.5.20', '>=')) {
329 self::migrate_remove_wpconfig_backups_v2_5_20();
330 }
331
332 // Add more version-specific migrations here as needed
333 // if (version_compare($from_version, '1.1.0', '<')) {
334 // self::migrate_something_v1_1();
335 // }
336 }
337
338 /**
339 * Migrate enhanced features for version 2.5.4+
340 */
341 private static function migrate_enhanced_features_v2_5_4()
342 {
343 global $wpdb;
344 $collate = $wpdb->get_charset_collate();
345
346 // Load WordPress upgrade functions for dbDelta
347 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
348
349 // Enhanced 404 Error Monitor Table
350 require_once dirname(__FILE__, 2) . '/404-monitor/class-metasync-404-monitor-database.php';
351 $tableName404Monitor = esc_sql($wpdb->prefix . Metasync_Error_Monitor_Database::$table_name);
352
353 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableName404Monitor)) != $tableName404Monitor) {
354 // Table doesn't exist, create enhanced version
355 $table_sql = "CREATE TABLE {$tableName404Monitor} (
356 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
357 uri TEXT NOT NULL,
358 hits_count BIGINT(20) unsigned NOT NULL DEFAULT '1',
359 date_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
360 user_agent TEXT NULL,
361 referer TEXT NULL,
362 ip_address VARCHAR(45) NULL,
363 PRIMARY KEY id (id),
364 KEY uri (uri(191)),
365 KEY hits_count (hits_count),
366 KEY date_time (date_time)
367 ) $collate;";
368
369 dbDelta($table_sql);
370 } else {
371 // Table exists, check for missing columns and add them
372 $columns = $wpdb->get_col("DESCRIBE {$tableName404Monitor}");
373
374 // Add referer column if it doesn't exist
375 if (!in_array('referer', $columns)) {
376 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD COLUMN referer TEXT NULL AFTER user_agent");
377 }
378
379 // Add ip_address column if it doesn't exist
380 if (!in_array('ip_address', $columns)) {
381 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD COLUMN ip_address VARCHAR(45) NULL AFTER referer");
382 }
383
384 // Update uri column to TEXT if it's VARCHAR(255)
385 $uri_column = $wpdb->get_row("SHOW COLUMNS FROM {$tableName404Monitor} LIKE 'uri'");
386 if ($uri_column && strpos($uri_column->Type, 'varchar') !== false) {
387 $wpdb->query("ALTER TABLE {$tableName404Monitor} MODIFY COLUMN uri TEXT NOT NULL");
388 }
389
390 // Update user_agent column to TEXT if it's VARCHAR(255)
391 $ua_column = $wpdb->get_row("SHOW COLUMNS FROM {$tableName404Monitor} LIKE 'user_agent'");
392 if ($ua_column && strpos($ua_column->Type, 'varchar') !== false) {
393 $wpdb->query("ALTER TABLE {$tableName404Monitor} MODIFY COLUMN user_agent TEXT NULL");
394 }
395
396 // Add missing indexes
397 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableName404Monitor}");
398 $index_names = array_column($indexes, 'Key_name');
399
400 if (!in_array('hits_count', $index_names)) {
401 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD KEY hits_count (hits_count)");
402 }
403
404 if (!in_array('date_time', $index_names)) {
405 $wpdb->query("ALTER TABLE {$tableName404Monitor} ADD KEY date_time (date_time)");
406 }
407 }
408
409 // Enhanced Redirections Table with new columns
410 require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-database.php';
411 $tableNameRedirection = esc_sql($wpdb->prefix . Metasync_Redirection_Database::$table_name);
412
413 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s ", $tableNameRedirection)) == $tableNameRedirection) {
414 // Table exists, check for new columns
415 $columns = $wpdb->get_col("DESCRIBE {$tableNameRedirection}");
416
417 // Add pattern_type column if it doesn't exist
418 if (!in_array('pattern_type', $columns)) {
419 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex', 'wildcard') NOT NULL DEFAULT 'exact' AFTER status");
420 }
421
422 // Add regex_pattern column if it doesn't exist
423 if (!in_array('regex_pattern', $columns)) {
424 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN regex_pattern TEXT NULL AFTER pattern_type");
425 }
426
427 // Add description column if it doesn't exist
428 if (!in_array('description', $columns)) {
429 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN description TEXT NULL AFTER regex_pattern");
430 }
431
432 // Add timestamp columns if they don't exist
433 if (!in_array('created_at', $columns)) {
434 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER description");
435 }
436
437 if (!in_array('updated_at', $columns)) {
438 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN updated_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER created_at");
439 }
440
441 if (!in_array('last_accessed_at', $columns)) {
442 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD COLUMN last_accessed_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' AFTER updated_at");
443 }
444
445 // Add indexes if they don't exist
446 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameRedirection}");
447 $index_names = array_column($indexes, 'Key_name');
448
449 if (!in_array('pattern_type', $index_names)) {
450 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY pattern_type (pattern_type)");
451 }
452
453 if (!in_array('created_at', $index_names)) {
454 $wpdb->query("ALTER TABLE {$tableNameRedirection} ADD KEY created_at (created_at)");
455 }
456
457 // Set default pattern_type for existing records
458 $wpdb->query("UPDATE {$tableNameRedirection} SET pattern_type = 'exact' WHERE pattern_type IS NULL OR pattern_type = ''");
459 }
460 }
461
462 /**
463 * Migrate robots.txt management for version 2.5.6+
464 */
465 private static function migrate_robots_txt_v2_5_6()
466 {
467 global $wpdb;
468
469 // Create Robots.txt Backups Table
470 require_once dirname(__FILE__, 2) . '/robots-txt/class-metasync-robots-txt-database.php';
471 $robots_db = Metasync_Robots_Txt_Database::get_instance();
472 $table_name = esc_sql($wpdb->prefix . 'metasync_robots_txt_backups');
473
474 // Check if table already exists
475 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table_name)) != $table_name) {
476 // Table doesn't exist, create it
477 $robots_db->create_table();
478 }
479 }
480
481 /**
482 * Migrate OTTO Excluded URLs for version 2.5.9+
483 */
484 private static function migrate_otto_excluded_urls_v2_5_9()
485 {
486 global $wpdb;
487 $collate = $wpdb->get_charset_collate();
488
489 // Load WordPress upgrade functions for dbDelta
490 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
491
492 // Create OTTO Excluded URLs Table
493 require_once dirname(__FILE__, 2) . '/otto/class-metasync-otto-excluded-urls-database.php';
494 $tableNameOttoExcludedURLs = esc_sql($wpdb->prefix . Metasync_Otto_Excluded_URLs_Database::$table_name);
495
496 // Check if table already exists
497 if ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $tableNameOttoExcludedURLs)) != $tableNameOttoExcludedURLs) {
498 // Table doesn't exist, create it
499 $table_sql = "CREATE TABLE {$tableNameOttoExcludedURLs} (
500 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
501 url_pattern TEXT NOT NULL,
502 pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex') NOT NULL DEFAULT 'exact',
503 description TEXT NULL,
504 status VARCHAR(25) NOT NULL DEFAULT 'active',
505 is_permanent TINYINT(1) NOT NULL DEFAULT 0,
506 auto_excluded TINYINT(1) NOT NULL DEFAULT 0,
507 recheck_after DATETIME NULL DEFAULT NULL,
508 created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
509 PRIMARY KEY id (id),
510 KEY status (status),
511 KEY pattern_type (pattern_type),
512 KEY created_at (created_at),
513 KEY is_permanent (is_permanent),
514 KEY auto_excluded (auto_excluded),
515 KEY status_auto_excluded (status, auto_excluded),
516 KEY recheck_after (recheck_after),
517 UNIQUE KEY url_pattern_type_unique (url_pattern(191), pattern_type)
518 ) $collate;";
519
520 dbDelta($table_sql);
521
522 // Log successful migration
523 // error_log('MetaSync: OTTO Excluded URLs table created successfully (v2.5.9)');
524 } else {
525 // Table exists, verify structure and add any missing columns if needed
526 $columns = $wpdb->get_col("DESCRIBE {$tableNameOttoExcludedURLs}");
527
528 // Check for required columns and add if missing
529 $missing_columns = false;
530
531 if (!in_array('pattern_type', $columns)) {
532 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN pattern_type ENUM('exact', 'contain', 'start', 'end', 'regex') NOT NULL DEFAULT 'exact' AFTER url_pattern");
533 $missing_columns = true;
534 }
535
536 if (!in_array('description', $columns)) {
537 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN description TEXT NULL AFTER pattern_type");
538 $missing_columns = true;
539 }
540
541 if (!in_array('status', $columns)) {
542 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN status VARCHAR(25) NOT NULL DEFAULT 'active' AFTER description");
543 $missing_columns = true;
544 }
545
546 if (!in_array('is_permanent', $columns)) {
547 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN is_permanent TINYINT(1) NOT NULL DEFAULT 0 AFTER status");
548 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY is_permanent (is_permanent)");
549 }
550
551 if (!in_array('auto_excluded', $columns)) {
552 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN auto_excluded TINYINT(1) NOT NULL DEFAULT 0 AFTER is_permanent");
553 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY auto_excluded (auto_excluded)");
554 // Backfill: mark existing 404 exclusions as auto_excluded
555 $wpdb->query("UPDATE {$tableNameOttoExcludedURLs} SET auto_excluded = 1 WHERE description = 'Auto-excluded: 404'");
556 }
557
558 if (!in_array('recheck_after', $columns)) {
559 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD COLUMN recheck_after DATETIME NULL DEFAULT NULL AFTER auto_excluded");
560 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY recheck_after (recheck_after)");
561 // Backfill: set recheck_after = created_at + 7 days for auto-excluded URLs
562 $wpdb->query("UPDATE {$tableNameOttoExcludedURLs} SET recheck_after = DATE_ADD(created_at, INTERVAL 7 DAY) WHERE auto_excluded = 1 AND (recheck_after IS NULL OR recheck_after = '0000-00-00 00:00:00')");
563 }
564
565 // Check and add indexes if they don't exist
566 $indexes = $wpdb->get_results("SHOW INDEX FROM {$tableNameOttoExcludedURLs}");
567 $index_names = array_column($indexes, 'Key_name');
568
569 if (!in_array('status', $index_names)) {
570 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY status (status)");
571 }
572
573 if (!in_array('pattern_type', $index_names)) {
574 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY pattern_type (pattern_type)");
575 }
576
577 if (!in_array('created_at', $index_names)) {
578 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY created_at (created_at)");
579 }
580
581 // Add unique index on url_pattern + pattern_type to prevent duplicates at database level
582 // Note: TEXT columns need a prefix length for indexing (767 is max for UTF8)
583 if (!in_array('url_pattern_type_unique', $index_names)) {
584 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD UNIQUE KEY url_pattern_type_unique (url_pattern(191), pattern_type)");
585 }
586
587 // Composite index for the cache-miss path in metasync_is_otto_url_manually_excluded()
588 if (!in_array('status_auto_excluded', $index_names)) {
589 $wpdb->query("ALTER TABLE {$tableNameOttoExcludedURLs} ADD KEY status_auto_excluded (status, auto_excluded)");
590 }
591
592 // if ($missing_columns) {
593 // error_log('MetaSync: OTTO Excluded URLs table structure updated (v2.5.9)');
594 // }
595 }
596 }
597
598 /**
599 * One-time cleanup of canonical meta corrupted to the literal
600 * "Array" (and its esc_url'd forms "http://Array" / "https://Array").
601 *
602 * Deletions are exact-match only — a legitimate URL can never match.
603 * Also repairs legacy rows still stored as (possibly nested) serialized
604 * arrays, and clears the mirrored corruption from Yoast / RankMath /
605 * AIOSEO storage that plugin-sync propagated, so cleaned sites are not
606 * re-polluted by stale third-party caches. Idempotent by construction.
607 */
608 public static function cleanup_corrupted_canonicals()
609 {
610 global $wpdb;
611
612 $meta_keys = array('meta_canonical', '_metasync_canonical_url', '_yoast_wpseo_canonical', 'rank_math_canonical_url');
613 $bad_values = array('array', 'http://array', 'https://array');
614
615 $keys_placeholders = implode(',', array_fill(0, count($meta_keys), '%s'));
616 $vals_placeholders = implode(',', array_fill(0, count($bad_values), '%s'));
617
618 // Normalized comparison: trailing slashes stripped in SQL so
619 // "http://Array/" and "http://Array//" both match the literals.
620 $norm_meta = "LOWER(TRIM(TRAILING '/' FROM TRIM(meta_value)))";
621
622 // 1. Post meta + term meta: delete exact-match corrupted rows.
623 // Batched and deleted by primary key so huge postmeta tables aren't
624 // range-locked in one statement, with per-object meta-cache
625 // invalidation — raw SQL alone would leave persistent object caches
626 // (Redis/Memcached) serving the deleted value to Yoast/RankMath
627 // readers indefinitely.
628 $meta_targets = array(
629 array($wpdb->postmeta, 'post_id', 'post_meta'),
630 array($wpdb->termmeta, 'term_id', 'term_meta'),
631 );
632 foreach ($meta_targets as $target) {
633 list($table, $object_col, $cache_group) = $target;
634 for ($batch = 0; $batch < 50; $batch++) {
635 $rows = $wpdb->get_results($wpdb->prepare(
636 "SELECT meta_id, {$object_col} AS object_id FROM {$table} WHERE meta_key IN ({$keys_placeholders}) AND {$norm_meta} IN ({$vals_placeholders}) ORDER BY meta_id LIMIT 500",
637 array_merge($meta_keys, $bad_values)
638 ));
639 if (empty($rows)) {
640 break;
641 }
642 $meta_ids = implode(',', array_map('intval', wp_list_pluck($rows, 'meta_id')));
643 $wpdb->query("DELETE FROM {$table} WHERE meta_id IN ({$meta_ids})");
644 foreach ($rows as $row) {
645 wp_cache_delete((int) $row->object_id, $cache_group);
646 }
647 if (count($rows) < 500) {
648 break;
649 }
650 }
651 }
652
653 // 2. Rows still stored as serialized arrays (the raw material the
654 // "Array" casts came from): repair MetaSync's own keys to the first
655 // usable URL inside; third-party keys are delete-only (never invent
656 // a value inside another plugin's storage). Written with direct SQL
657 // by meta_id so the updated_post_meta plugin-sync cascade, Yoast
658 // indexable rebuilds, and sitemap cache busts don't fire once per
659 // row; loops until exhausted (repaired rows stop matching LIKE).
660 if (class_exists('Metasync_Canonical_Sanitizer')) {
661 $own_keys = array('meta_canonical', '_metasync_canonical_url');
662 for ($batch = 0; $batch < 50; $batch++) {
663 $rows = $wpdb->get_results($wpdb->prepare(
664 "SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key IN ({$keys_placeholders}) AND meta_value LIKE 'a:%%' ORDER BY meta_id LIMIT 500",
665 $meta_keys
666 ));
667 if (empty($rows)) {
668 break;
669 }
670 foreach ($rows as $row) {
671 $repaired = '';
672 if (in_array($row->meta_key, $own_keys, true)) {
673 $repaired = Metasync_Canonical_Sanitizer::sanitize(maybe_unserialize($row->meta_value));
674 }
675 if ($repaired !== '') {
676 $wpdb->update($wpdb->postmeta, array('meta_value' => $repaired), array('meta_id' => (int) $row->meta_id));
677 } else {
678 $wpdb->delete($wpdb->postmeta, array('meta_id' => (int) $row->meta_id));
679 }
680 wp_cache_delete((int) $row->post_id, 'post_meta');
681 }
682 if (count($rows) < 500) {
683 break;
684 }
685 }
686 }
687
688 // 3. Yoast indexable cache: null corrupted canonical columns so the
689 // frontend and sitemaps stop serving the bad value immediately.
690 $indexable_table = $wpdb->prefix . 'yoast_indexable';
691 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($indexable_table))) === $indexable_table) {
692 $wpdb->query($wpdb->prepare(
693 "UPDATE {$indexable_table} SET canonical = NULL WHERE LOWER(TRIM(TRAILING '/' FROM TRIM(canonical))) IN ({$vals_placeholders})",
694 $bad_values
695 ));
696 }
697
698 // 4. AIOSEO custom tables: null corrupted canonical_url columns.
699 foreach (array('aioseo_posts', 'aioseo_terms') as $aioseo_table) {
700 $table = $wpdb->prefix . $aioseo_table;
701 if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) {
702 $wpdb->query($wpdb->prepare(
703 "UPDATE {$table} SET canonical_url = NULL WHERE LOWER(TRIM(TRAILING '/' FROM TRIM(canonical_url))) IN ({$vals_placeholders})",
704 $bad_values
705 ));
706 }
707 }
708
709 // 5. Yoast stores term canonicals in the wpseo_taxonomy_meta option,
710 // not termmeta. Strip only exact corruption literals.
711 if (class_exists('Metasync_Canonical_Sanitizer')) {
712 $tax_meta = get_option('wpseo_taxonomy_meta');
713 if (is_array($tax_meta)) {
714 $changed = false;
715 foreach ($tax_meta as $taxonomy => $terms) {
716 if (!is_array($terms)) {
717 continue;
718 }
719 foreach ($terms as $term_id => $fields) {
720 if (is_array($fields) && isset($fields['wpseo_canonical'])
721 && Metasync_Canonical_Sanitizer::is_corrupted($fields['wpseo_canonical'])) {
722 unset($tax_meta[$taxonomy][$term_id]['wpseo_canonical']);
723 $changed = true;
724 }
725 }
726 }
727 if ($changed) {
728 update_option('wpseo_taxonomy_meta', $tax_meta);
729 }
730 }
731 }
732 }
733
734 /**
735 * One-time repair of Local Business logo values corrupted to
736 * "http://<attachment-id>" (and the https:// / trailing-slash variants).
737 *
738 * The legacy save path ran the logo through
739 * sanitize_url() (= esc_url_raw()), which prepends a scheme to any value
740 * that has none — so a stored attachment ID "45589" became "http://45589",
741 * the admin preview rendered a broken image, and the front-end JSON-LD
742 * published "logo": "http://45589" as structured data.
743 *
744 * The corrupted shape is unambiguous (a scheme followed by digits only — no
745 * dot, no path, so it can never be a resolvable host) and encodes the
746 * original ID exactly, so restoring the digits is lossless and needs no
747 * rollback path. Idempotent by construction: repaired values no longer
748 * match, so a second run writes nothing.
749 *
750 * Uses the same shared helper as the admin preview and the schema output
751 * (metasync_repair_scheme_prefixed_media_id()), so the three can never
752 * disagree about what "corrupted" means.
753 *
754 * @see metasync_repair_scheme_prefixed_media_id()
755 */
756 public static function repair_corrupted_local_seo_logo()
757 {
758 $options = get_option('metasync_options');
759
760 if (!is_array($options) || !isset($options['localseo']['local_seo_logo'])) {
761 return;
762 }
763
764 $stored = $options['localseo']['local_seo_logo'];
765 $repaired = metasync_repair_scheme_prefixed_media_id($stored);
766
767 if ($repaired === $stored) {
768 return; // already clean: URL, plain attachment ID, or empty
769 }
770
771 $options['localseo']['local_seo_logo'] = $repaired;
772 update_option('metasync_options', $options, true);
773 }
774
775
776 /**
777 * Remove insecure wp-config.php backup copies left in the web root by prior versions.
778 *
779 * Older versions wrote a full copy of wp-config.php (containing DB credentials and
780 * auth salts) to `wp-config.php.metasync-backup-<time()>` in the same directory as
781 * wp-config.php before each debug-mode write. This cleanup deletes any such leftover
782 * copies, plus any `.metasync-tmp-*` file orphaned by an interrupted atomic save.
783 *
784 * Claimed via an option so it runs once rather than on every later version bump, and
785 * left unclaimed if anything could not be deleted so a later upgrade retries.
786 *
787 * @return void
788 */
789 private static function migrate_remove_wpconfig_backups_v2_5_20()
790 {
791 if (get_option(self::WPCONFIG_BACKUP_CLEANUP_OPTION)) {
792 return;
793 }
794
795 // This runs from `init` at priority 1, so a wp_dlct_config_file_manager_path filter
796 // another plugin registers at the default priority is not added yet and the filtered
797 // value can still be the default. Sweep every candidate directory rather than
798 // trusting one resolution, and only claim the cleanup once wp-config.php was
799 // actually found in one of them - otherwise a later upgrade retries.
800 $candidates = [
801 ABSPATH . 'wp-config.php',
802 dirname(ABSPATH) . '/wp-config.php',
803 apply_filters('wp_dlct_config_file_manager_path', ABSPATH . 'wp-config.php'),
804 ];
805
806 $sweptDirs = [];
807 $located = false;
808 $failed = 0;
809 $scan_failed = 0;
810
811 foreach ($candidates as $config_file) {
812 if (!is_string($config_file) || '' === $config_file) {
813 continue;
814 }
815
816 $config_dir = dirname($config_file);
817 if (isset($sweptDirs[$config_dir])) {
818 continue;
819 }
820 $sweptDirs[$config_dir] = true;
821
822 if (@file_exists($config_file)) {
823 $located = true;
824 }
825
826 $result = self::remove_wpconfig_backups($config_dir);
827 $failed += $result['failed'];
828 $scan_failed += $result['scan_failed'];
829 }
830
831 if ($scan_failed) {
832 error_log(sprintf(
833 'MetaSync: could not inspect %d wp-config.php backup directory scan(s); cleanup will be retried.',
834 $scan_failed
835 ));
836 return;
837 }
838
839 if ($failed) {
840 // A copy left behind is exactly the exposure this cleanup exists to remove,
841 // so surface it rather than failing silently.
842 error_log(sprintf(
843 'MetaSync: could not delete %d leftover wp-config.php copy/copies in %s - remove them manually.',
844 $failed,
845 implode(', ', array_keys($sweptDirs))
846 ));
847 return;
848 }
849
850 if (!$located) {
851 // wp-config.php was not in any directory we swept, so it is relocated somewhere
852 // we could not resolve. Leave the flag unset so a later upgrade tries again.
853 error_log('MetaSync: wp-config.php was not found while cleaning up legacy backup copies in '
854 . implode(', ', array_keys($sweptDirs)) . ' - cleanup will be retried.');
855 return;
856 }
857
858 update_option(self::WPCONFIG_BACKUP_CLEANUP_OPTION, 1, false);
859 }
860
861 /**
862 * Delete leftover full copies of wp-config.php from a directory.
863 *
864 * Covers the legacy `wp-config.php.metasync-backup-*` copies and `.metasync-tmp-*`
865 * files orphaned by an interrupted atomic save. Temp files are only removed once
866 * they are older than WPCONFIG_TMP_MAX_AGE, so a save running concurrently in
867 * another request never has its temp file pulled out from under it.
868 *
869 * @param string $config_dir Directory that may contain leftover copies.
870 *
871 * @return array{removed:int,failed:int,scan_failed:int} Cleanup and scan-failure counts.
872 */
873 public static function remove_wpconfig_backups($config_dir)
874 {
875 $dir = rtrim($config_dir, '/');
876 $removed = 0;
877 $failed = 0;
878 $scan_failed = 0;
879
880 $backups = glob($dir . '/wp-config.php.metasync-backup-*');
881 if (false === $backups) {
882 $scan_failed++;
883 $backups = [];
884 }
885
886 foreach ($backups as $backup) {
887 if (!is_file($backup)) {
888 continue;
889 }
890
891 if (@unlink($backup)) {
892 $removed++;
893 } else {
894 $failed++;
895 }
896 }
897
898 $temps = glob($dir . '/.metasync-tmp-*');
899 if (false === $temps) {
900 $scan_failed++;
901 $temps = [];
902 }
903
904 foreach ($temps as $temp) {
905 if (!is_file($temp)) {
906 continue;
907 }
908
909 // abs() so a future mtime (clock skew, NFS) still ages out instead of being
910 // skipped forever.
911 $mtime = @filemtime($temp);
912 if (false === $mtime || abs(time() - $mtime) < self::WPCONFIG_TMP_MAX_AGE) {
913 continue;
914 }
915
916 if (@unlink($temp)) {
917 $removed++;
918 } else {
919 $failed++;
920 }
921 }
922
923 return ['removed' => $removed, 'failed' => $failed, 'scan_failed' => $scan_failed];
924 }
925
926
927 }
928