PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.26
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.26
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / database / class-db-migrations.php

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

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