| 1 |
<?php |
| 2 |
/** |
| 3 |
* Handles plugin activation. |
| 4 |
* |
| 5 |
* @package Custom_404_Pro |
| 6 |
*/ |
| 7 |
|
| 8 |
/** |
| 9 |
* Activation class. |
| 10 |
*/ |
| 11 |
class ActivateClass { |
| 12 |
|
| 13 |
/** |
| 14 |
* Creates the logs table and seeds default settings for a single site. |
| 15 |
*/ |
| 16 |
private static function run_activation() { |
| 17 |
self::create_tables(); |
| 18 |
self::maybe_migrate_legacy_options(); |
| 19 |
self::initialize_options(); |
| 20 |
|
| 21 |
// Record the schema version so the plugins_loaded migration check can |
| 22 |
// skip the SHOW TABLES query on every subsequent page load. |
| 23 |
if ( defined( 'CUSTOM_404_PRO_VERSION' ) ) { |
| 24 |
update_option( 'custom_404_pro_db_version', CUSTOM_404_PRO_VERSION ); |
| 25 |
} |
| 26 |
|
| 27 |
// Schedule the daily log-pruning cron event if it is not already registered. |
| 28 |
if ( ! wp_next_scheduled( 'custom_404_pro_prune_logs' ) ) { |
| 29 |
wp_schedule_event( time(), 'daily', 'custom_404_pro_prune_logs' ); |
| 30 |
} |
| 31 |
} |
| 32 |
|
| 33 |
/** |
| 34 |
* Runs on plugin activation, handling multisite if needed. |
| 35 |
* |
| 36 |
* No capability check here — WordPress core already enforces that only users |
| 37 |
* with activate_plugins can trigger this hook, and the check would silently |
| 38 |
* break activation via WP-CLI or automated deployment pipelines. |
| 39 |
* |
| 40 |
* @since 3.12.9 |
| 41 |
*/ |
| 42 |
public static function activate() { |
| 43 |
if ( is_multisite() ) { |
| 44 |
$sites = get_sites( array( 'fields' => 'ids' ) ); |
| 45 |
foreach ( $sites as $blog_id ) { |
| 46 |
switch_to_blog( $blog_id ); |
| 47 |
self::run_activation(); |
| 48 |
restore_current_blog(); |
| 49 |
} |
| 50 |
} else { |
| 51 |
self::run_activation(); |
| 52 |
} |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Creates or upgrades the plugin logs table. |
| 57 |
* |
| 58 |
* The legacy options table is no longer created here — settings are stored |
| 59 |
* in wp_options under the Helpers::OPTION_KEY key. |
| 60 |
* |
| 61 |
* Schema notes: |
| 62 |
* - `id` is bigint because this table records one row per 404 hit. The |
| 63 |
* previous mediumint(9) ran out of AUTO_INCREMENT values at 8,388,607 |
| 64 |
* rows, after which every insert failed silently on a busy site. |
| 65 |
* - `created` is indexed because the retention policy added in 3.14.0 |
| 66 |
* both sorts and filters on it. Without the index each daily prune ran |
| 67 |
* a full table scan. |
| 68 |
* - dbDelta parses this string with a strict format: two spaces after |
| 69 |
* PRIMARY KEY, and one field or key per line. Reformatting it will make |
| 70 |
* dbDelta reissue ALTER statements on every run. |
| 71 |
* - Column types are lowercase because WordPress 6.4 and earlier compare |
| 72 |
* the declared type against MySQL's reported type case-sensitively. |
| 73 |
* Declaring `TIMESTAMP` there made dbDelta report "changed type from |
| 74 |
* timestamp to TIMESTAMP" forever and reissue the ALTER on every call. |
| 75 |
* Newer core normalises the case; the older behaviour is still within |
| 76 |
* our supported range, so match MySQL and stay lowercase. |
| 77 |
* |
| 78 |
* @since 3.12.9 |
| 79 |
* @since 3.16.0 Widened `id` to bigint and added an index on `created`. |
| 80 |
* @return array Map of changes dbDelta applied. Empty when the schema was |
| 81 |
* already up to date, which is what the idempotency test |
| 82 |
* asserts — a non-empty result on a second call would mean |
| 83 |
* the plugin reissues ALTER TABLE on every upgrade check. |
| 84 |
*/ |
| 85 |
public static function create_tables(): array { |
| 86 |
global $wpdb; |
| 87 |
$helpers = Helpers::singleton(); |
| 88 |
$table_logs = $wpdb->prefix . $helpers->table_logs; |
| 89 |
$charset_collate = $wpdb->get_charset_collate(); |
| 90 |
$sql_logs = "CREATE TABLE $table_logs ( |
| 91 |
id bigint(20) unsigned NOT NULL AUTO_INCREMENT, |
| 92 |
ip text, |
| 93 |
path text, |
| 94 |
referer text, |
| 95 |
user_agent text, |
| 96 |
created timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 97 |
updated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| 98 |
PRIMARY KEY (id), |
| 99 |
KEY created (created) |
| 100 |
) $charset_collate;"; |
| 101 |
include_once ABSPATH . 'wp-admin/includes/upgrade.php'; |
| 102 |
return (array) dbDelta( $sql_logs ); |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Seeds default settings into wp_options if they have not been set yet. |
| 107 |
* |
| 108 |
* Uses add_option() which is a no-op when the key already exists, making |
| 109 |
* this safe to call on every activation without overwriting saved settings. |
| 110 |
* |
| 111 |
* @since 3.12.9 |
| 112 |
*/ |
| 113 |
public static function initialize_options() { |
| 114 |
$helpers = Helpers::singleton(); |
| 115 |
add_option( Helpers::OPTION_KEY, $helpers->defaults() ); |
| 116 |
} |
| 117 |
|
| 118 |
/** |
| 119 |
* Migrates settings from the legacy custom_404_pro_options table to wp_options. |
| 120 |
* |
| 121 |
* If the legacy table does not exist this method returns immediately. After a |
| 122 |
* successful migration the legacy table is dropped. Safe to call multiple |
| 123 |
* times — subsequent calls are a no-op once the table is gone. |
| 124 |
* |
| 125 |
* Values are cast to their correct types (bool, int) using the defaults map |
| 126 |
* so that string values from the old text columns are not carried forward. |
| 127 |
* |
| 128 |
* @since 3.12.9 |
| 129 |
*/ |
| 130 |
public static function maybe_migrate_legacy_options() { |
| 131 |
global $wpdb; |
| 132 |
$legacy_table = $wpdb->prefix . 'custom_404_pro_options'; |
| 133 |
|
| 134 |
// Nothing to migrate if the legacy table does not exist. |
| 135 |
$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $legacy_table ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 136 |
if ( ! $table_exists ) { |
| 137 |
return; |
| 138 |
} |
| 139 |
|
| 140 |
// Already migrated — just clean up the legacy table. |
| 141 |
if ( get_option( Helpers::OPTION_KEY ) ) { |
| 142 |
$wpdb->query( 'DROP TABLE IF EXISTS ' . $legacy_table ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 143 |
return; |
| 144 |
} |
| 145 |
|
| 146 |
// Read legacy rows and build a settings array, casting each value to the |
| 147 |
// correct type so that legacy text-column strings do not persist as strings |
| 148 |
// where booleans or integers are expected. |
| 149 |
$rows = $wpdb->get_results( 'SELECT name, value FROM ' . $legacy_table, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 150 |
if ( ! empty( $rows ) ) { |
| 151 |
$defaults = ( new Helpers() )->defaults(); |
| 152 |
$settings = array(); |
| 153 |
foreach ( $rows as $row ) { |
| 154 |
$key = $row['name']; |
| 155 |
$value = $row['value']; |
| 156 |
if ( array_key_exists( $key, $defaults ) ) { |
| 157 |
if ( is_bool( $defaults[ $key ] ) ) { |
| 158 |
$value = ! empty( $value ) && '0' !== $value; |
| 159 |
} elseif ( is_int( $defaults[ $key ] ) ) { |
| 160 |
$value = (int) $value; |
| 161 |
} |
| 162 |
} |
| 163 |
$settings[ $key ] = $value; |
| 164 |
} |
| 165 |
update_option( Helpers::OPTION_KEY, $settings ); |
| 166 |
} |
| 167 |
|
| 168 |
// Drop the legacy table. |
| 169 |
$wpdb->query( 'DROP TABLE IF EXISTS ' . $legacy_table ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 170 |
} |
| 171 |
} |
| 172 |
|