prefix . 'image_lock_audit_bridge'; } /** * Check if migrations need to run and execute them */ public static function maybe_run_migrations() { $current_version = get_option( self::DB_VERSION_OPTION, '1.0.0' ); if ( version_compare( $current_version, self::DB_VERSION, '<' ) ) { self::run_migrations( $current_version ); update_option( self::DB_VERSION_OPTION, self::DB_VERSION ); } } /** * Run migrations based on version * * @param string $from_version Version we're migrating from */ private static function run_migrations( $from_version ) { // v1.0.0 -> v2.0.0: Add bridge table for ImageAudit integration if ( version_compare( $from_version, '2.0.0', '<' ) ) { self::create_bridge_table(); } // v2.0.0 -> v3.0.0: Add analytics tables if ( version_compare( $from_version, '3.0.0', '<' ) ) { self::create_analytics_tables(); } // v3.0.0 -> v4.0.0: ImageLock -> Gatewell rename. // Carries the analytics tables and post meta keys over to the new // prefix. Must run BEFORE create_analytics_tables() so the rename sees // the old tables rather than finding freshly created empty ones. if ( version_compare( $from_version, '4.0.0', '<' ) ) { self::migrate_legacy_prefix_tables(); self::migrate_legacy_prefix_post_meta(); self::create_analytics_tables(); } // v4.0.0 -> v4.1.0: repair the monthly analytics table. // // Its CREATE TABLE used an unquoted `year_month` column, which is a // reserved word in MySQL, so the statement always failed and the table // was never created on any install. create_analytics_tables() is // idempotent (IF NOT EXISTS / SHOW TABLES guard), so re-running it just // creates the missing table and leaves the other two alone. if ( version_compare( $from_version, '4.1.0', '<' ) ) { self::create_analytics_tables(); } } /** * Rename the analytics tables from the legacy prefix to the current one. * * The bridge table is deliberately NOT renamed - see get_bridge_table_name(). * * @since 4.0.0 */ private static function migrate_legacy_prefix_tables() { global $wpdb; $renames = array( 'imagelock_upload_events' => 'gatewell_upload_events', 'imagelock_analytics_daily' => 'gatewell_analytics_daily', 'imagelock_analytics_monthly' => 'gatewell_analytics_monthly', ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Both names are $wpdb->prefix plus a hard-coded literal from the $renames map above, never request data, and additionally passed through esc_sql(). A table name cannot be bound with prepare(). // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time schema migration; WordPress has no API for renaming a custom table, and a RENAME has no cacheable result. foreach ( $renames as $old_suffix => $new_suffix ) { $old_table = $wpdb->prefix . $old_suffix; $new_table = $wpdb->prefix . $new_suffix; $old_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $old_table ) ) === $old_table; $new_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $new_table ) ) === $new_table; // Only rename when the old table is there and the new one is not, // so a partially-applied migration can be replayed safely and an // existing new table is never clobbered. if ( $old_exists && ! $new_exists ) { $safe_old = esc_sql( $old_table ); $safe_new = esc_sql( $new_table ); $wpdb->query( "RENAME TABLE {$safe_old} TO {$safe_new}" ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter Debug::log( 'Legacy analytics tables migrated to gatewell_ prefix', 'Gatewell' ); } /** * Rename attachment post meta keys from the legacy prefix to the current one. * * _imagelock_original_file in particular is what the delete_attachment * cleanup hook reads to find and remove the stored original, so losing it * would orphan every stored original on disk. * * @since 4.0.0 */ private static function migrate_legacy_prefix_post_meta() { global $wpdb; $meta_keys = array( '_imagelock_original_file' => '_gatewell_original_file', '_imagelock_optimized' => '_gatewell_optimized', '_imagelock_processed' => '_gatewell_processed', ); $renamed = 0; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time bulk meta-key rename across every attachment. Doing this through update_post_meta() would mean loading and rewriting each row individually; both statements bind their values through %s, and wp_cache_flush() below drops the now-stale meta cache. foreach ( $meta_keys as $old_key => $new_key ) { // Drop any pre-existing new-key rows for the same post first, so the // UPDATE cannot collide and so a replay stays idempotent. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ( SELECT post_id FROM ( SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s ) AS t )", $new_key, $old_key ) ); $result = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->postmeta} SET meta_key = %s WHERE meta_key = %s", $new_key, $old_key ) ); if ( $result ) { $renamed += (int) $result; } } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // Raw SQL bypasses the object cache, so stale meta must be dropped. if ( $renamed > 0 ) { wp_cache_flush(); } Debug::log( 'Legacy post meta keys migrated: ' . $renamed . ' rows', 'Gatewell' ); } /** * Create bridge table for ImageAudit integration * Allows Gatewell to record optimization events that ImageAudit can reference */ private static function create_bridge_table() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); // Table name is $wpdb->prefix plus a hard-coded literal (see // get_bridge_table_name()); it never contains request data. /* * dbDelta requirements, all of which were previously broken here: * - No "IF NOT EXISTS". dbDelta parses the table name with * preg_match( '|CREATE TABLE ([^ ]*)|' ), so "IF" became the table name, * "DESCRIBE IF" failed, and dbDelta fell back to running the raw query. * That silently disabled schema diffing: no ALTER TABLE would ever be * generated for this table again. dbDelta is already idempotent. * - Two spaces after PRIMARY KEY. * - Lowercase column types. * - version_info is longtext, not JSON: JSON needs MySQL 5.7.8+ / * MariaDB 10.2+, above WordPress's own minimum, and on an older server * the CREATE failed silently and left the bridge dead with no error. */ $sql = "CREATE TABLE " . self::$bridge_table . " ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, attachment_id bigint(20) unsigned NOT NULL, file_path varchar(500) NOT NULL, full_url varchar(500) NOT NULL, optimized_at datetime DEFAULT CURRENT_TIMESTAMP, compression_quality tinyint(3) unsigned, output_format varchar(20), original_file_size bigint(20), optimized_file_size bigint(20), format_converted tinyint(1) DEFAULT 0, version_info longtext, PRIMARY KEY (id), UNIQUE KEY file_path (file_path(191)), KEY attachment_id (attachment_id), KEY optimized_at (optimized_at) ) $charset_collate;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql ); // Log successful creation. Debug::log( 'Bridge table created successfully', 'Gatewell' ); } /** * Create analytics tables for upload event tracking * * @since 3.0.0 */ private static function create_analytics_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // Raw upload events (30-day TTL) $events_table = $wpdb->prefix . 'gatewell_upload_events'; $sql_events = "CREATE TABLE {$events_table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, created_at datetime DEFAULT CURRENT_TIMESTAMP, post_id bigint(20) unsigned DEFAULT 0, post_type varchar(50) DEFAULT '', attachment_id bigint(20) unsigned DEFAULT 0, upload_context varchar(50) DEFAULT 'media-library', user_id bigint(20) unsigned DEFAULT 0, file_type varchar(50) DEFAULT '', file_size_original bigint(20) DEFAULT 0, file_size_final bigint(20) DEFAULT 0, was_cropped tinyint(1) DEFAULT 0, was_compressed tinyint(1) DEFAULT 0, was_rejected tinyint(1) DEFAULT 0, rejection_reason text, alt_text_length int DEFAULT 0, title_length int DEFAULT 0, caption_length int DEFAULT 0, description_length int DEFAULT 0, alt_text_quality_score tinyint(3) unsigned DEFAULT 0, title_quality_score tinyint(3) unsigned DEFAULT 0, used_ai_alt_text tinyint(1) DEFAULT 0, used_ai_title tinyint(1) DEFAULT 0, PRIMARY KEY (id), KEY created_at (created_at), KEY post_id (post_id), KEY user_id (user_id), KEY upload_context (upload_context) ) {$charset_collate};"; dbDelta( $sql_events ); // Daily aggregates (90-day TTL) $daily_table = $wpdb->prefix . 'gatewell_analytics_daily'; $sql_daily = "CREATE TABLE {$daily_table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, date date NOT NULL, total_uploads int DEFAULT 0, total_rejections int DEFAULT 0, avg_compression_ratio decimal(5,2) DEFAULT 0.00, avg_alt_text_quality decimal(5,2) DEFAULT 0.00, avg_title_quality decimal(5,2) DEFAULT 0.00, ai_alt_text_used_count int DEFAULT 0, ai_title_used_count int DEFAULT 0, top_rejection_reason varchar(255) DEFAULT '', file_type_counts longtext, rejection_reason_counts longtext, context_counts longtext, post_type_counts longtext, PRIMARY KEY (id), UNIQUE KEY date (date), KEY date_idx (date) ) {$charset_collate};"; dbDelta( $sql_daily ); // Monthly aggregates (2-year TTL) // // Note: Using direct query instead of dbDelta to avoid dbDelta quirks with // tables that have no plain KEY (only PRIMARY KEY + UNIQUE KEY). // // `year_month` MUST stay backtick-quoted everywhere it appears in raw SQL: // YEAR_MONTH is a reserved word in MySQL (an INTERVAL unit), so an // unquoted occurrence is a syntax error and this CREATE TABLE fails // silently, leaving the monthly aggregate table missing forever. $monthly_table = $wpdb->prefix . 'gatewell_analytics_monthly'; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is $wpdb->prefix plus a hard-coded literal; not user input and not parameterizable. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- One-time schema creation; there is no WordPress API for creating a custom table and nothing to cache. if ( $wpdb->get_var( "SHOW TABLES LIKE '{$monthly_table}'" ) !== $monthly_table ) { $wpdb->query( "CREATE TABLE {$monthly_table} ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, `year_month` varchar(7) NOT NULL, total_uploads int DEFAULT 0, total_rejections int DEFAULT 0, avg_quality_score decimal(5,2) DEFAULT 0.00, ai_usage_count int DEFAULT 0, summary_json longtext, PRIMARY KEY (id), UNIQUE KEY ym_idx (`year_month`) ) {$charset_collate}" ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared Debug::log( 'Analytics tables created successfully', 'Gatewell' ); } /** * Get analytics table names * * @return array Array of table names keyed by type */ public static function get_analytics_tables() { global $wpdb; return array( 'events' => $wpdb->prefix . 'gatewell_upload_events', 'daily' => $wpdb->prefix . 'gatewell_analytics_daily', 'monthly' => $wpdb->prefix . 'gatewell_analytics_monthly', ); } /** * Check if bridge table exists */ public static function table_exists() { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHOW TABLES existence probe for a plugin-owned table; WordPress has no API for it. Deliberately uncached: ImageAudit can create or drop this shared bridge table at any time, and a cached "missing" answer would silently disable the integration until the cache expired. return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', self::$bridge_table ) ) === self::$bridge_table; // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } /** * Get recent optimizations for a file * * @param string $file_path File path to check * @param int $hours Number of hours to look back (default: 2) * * @return object|null Recent optimization record or null */ public static function get_recent_optimization( $file_path, $hours = 2 ) { global $wpdb; if ( ! self::table_exists() ) { return null; } // Table name cannot be bound with prepare(); it is $wpdb->prefix plus a // hard-coded literal and is additionally passed through esc_sql(). // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- See above; $file_path and $hours are bound through placeholders. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned bridge table shared with ImageAudit; this is a freshness check whose answer must not be cached. $table = esc_sql( self::$bridge_table ); return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE file_path = %s AND optimized_at > DATE_SUB(NOW(), INTERVAL %d HOUR) ORDER BY optimized_at DESC LIMIT 1", $file_path, $hours ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Get all optimizations for attachment * * @param int $attachment_id Attachment ID * * @return array Array of optimization records */ public static function get_attachment_optimizations( $attachment_id ) { global $wpdb; if ( ! self::table_exists() ) { return array(); } // Table name cannot be bound with prepare(); it is $wpdb->prefix plus a // hard-coded literal and is additionally passed through esc_sql(). // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- See above; $attachment_id is bound through a %d placeholder. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned bridge table shared with ImageAudit; read on demand by the integration, and its whole point is to report the current contents. $table = esc_sql( self::$bridge_table ); return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE attachment_id = %d ORDER BY optimized_at DESC", $attachment_id ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Record an optimization in the bridge table * * @param array $optimization_data Optimization data * * @return int|false Number of rows inserted or false on error */ public static function record_optimization( $optimization_data ) { global $wpdb; if ( ! self::table_exists() ) { return false; } $defaults = array( 'attachment_id' => 0, 'file_path' => '', 'full_url' => '', 'compression_quality' => 85, 'output_format' => 'original', 'original_file_size' => 0, 'optimized_file_size' => 0, 'format_converted' => 0, 'version_info' => '{}', ); $data = wp_parse_args( $optimization_data, $defaults ); // Encode version info if it's an array if ( is_array( $data['version_info'] ) ) { $data['version_info'] = wp_json_encode( $data['version_info'] ); } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned bridge table; $wpdb->insert() is the WordPress API for a custom table, and an INSERT has no cacheable result. return $wpdb->insert( self::$bridge_table, array( 'attachment_id' => (int) $data['attachment_id'], 'file_path' => (string) $data['file_path'], 'full_url' => (string) $data['full_url'], 'optimized_at' => current_time( 'mysql' ), 'compression_quality' => (int) $data['compression_quality'], 'output_format' => (string) $data['output_format'], 'original_file_size' => (int) $data['original_file_size'], 'optimized_file_size' => (int) $data['optimized_file_size'], 'format_converted' => (bool) $data['format_converted'] ? 1 : 0, 'version_info' => (string) $data['version_info'], ), array( '%d', '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%d', '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } /** * Clear old optimization records (keep last 90 days) */ public static function cleanup_old_records() { global $wpdb; if ( ! self::table_exists() ) { return false; } // Table names cannot be parameterized in $wpdb->prepare(). // self::$bridge_table is $wpdb->prefix plus a hard-coded literal. // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- See above; the query carries no runtime values at all, the interval is a literal. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned bridge table; a retention DELETE has no cacheable result. return $wpdb->query( "DELETE FROM " . self::$bridge_table . " WHERE optimized_at < DATE_SUB(NOW(), INTERVAL 90 DAY)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Uninstall bridge table (called when Gatewell uninstalled) */ public static function uninstall() { global $wpdb; // The bridge table is shared with ImageAudit, so it is kept while that plugin // is active. The analytics tables below are Gatewell's alone and are always // dropped — an early return here used to leak all three of them. $keep_bridge_table = defined( 'IMAGE_AUDIT_VERSION' ); // Table names cannot be parameterized in $wpdb->prepare(). Every name // here is $wpdb->prefix plus a hard-coded literal and is additionally // passed through esc_sql(). // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- See above. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall-time DROP TABLE for plugin-owned tables; WordPress has no API for dropping a custom table, and there is nothing to cache. if ( ! $keep_bridge_table ) { $table = esc_sql( self::$bridge_table ); $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); } // Drop analytics tables foreach ( self::get_analytics_tables() as $analytics_table ) { $safe_table = esc_sql( $analytics_table ); $wpdb->query( "DROP TABLE IF EXISTS {$safe_table}" ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } } // Initialize migration handler Migration::init();