prepare()`, which earlier * versions do not understand. * * @package OW_Consent */ // WordPress sets this constant before including the file. Anything else is an // unauthorised direct request. if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; } if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Option names this plugin owns outside of the `owco_%` sweep. * * @return string[] */ function owco_uninstall_option_names() { return array( 'owco_settings', 'owco_version', 'owco_pending_upgrade', 'owco_upgrade_lock', 'owco_catalog_sanitized_v2', 'owco_match_target_migrated_v1', 'owco_ledger_hmac_migrated_v1', 'owco_inline_sig_labels_cleaned_v1', 'owco_scanner_secret_rotated_v1', 'owco_ledger_key_source', 'owco_gvl_cache', 'owco_scanner_secret', ); } /** * Cron hooks this plugin schedules. * * All six of them, as literals: the plugin classes are not loaded during uninstall, so * the constants (OWCO_Scanner::CRON_HOOK, OWCO_Scanner::CRON_BATCH_HOOK, * OWCO_Consent::CRON_RETENTION, OWCO_TCF::CRON_REFRESH, OWCO_Install::UPGRADE_HOOK, * OWCO_Install::MAINTENANCE_HOOK) cannot be read here. Keep this list in sync with * OWCO_Install::cron_hooks(). * * @return string[] */ function owco_uninstall_cron_hooks() { return array( 'owco_scanner_run', 'owco_scanner_run_batch', 'owco_ledger_retention', 'owco_tcf_refresh_gvl', 'owco_run_upgrade', 'owco_daily_maintenance', // pre-2.0.0 spellings, in case the rename migration never ran here. 'owc_scanner_run', 'owc_scanner_run_batch', 'owc_ledger_retention', 'owc_tcf_refresh_gvl', 'owc_run_upgrade', 'owc_daily_maintenance', ); } /** * Clear every scheduled event this plugin owns on the CURRENT blog. * * Deliberately NOT part of owco_uninstall_current_site(): that function only runs when * the site owner opted into data deletion, and an event left behind fires forever * against a hook nobody handles whether or not they did. * * @return void */ function owco_uninstall_clear_cron() { foreach ( owco_uninstall_cron_hooks() as $owco_hook ) { wp_clear_scheduled_hook( $owco_hook ); } } /** * Delete the transient rows (value + timeout) whose name starts with one of $prefixes. * * @param string[] $owco_prefixes Transient name prefixes, without the `_transient_` part. * @return void */ function owco_uninstall_purge_transients( $owco_prefixes ) { global $wpdb; foreach ( (array) $owco_prefixes as $owco_prefix ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot cleanup, no WP API for a prefix sweep. $owco_rows = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( '_transient_' . $owco_prefix ) . '%', $wpdb->esc_like( '_transient_timeout_' . $owco_prefix ) . '%' ) ); foreach ( (array) $owco_rows as $owco_row ) { delete_option( $owco_row ); } } } /** * Erase the DSAR register on the CURRENT blog, whatever the data decision was. * * `wp_owco_dsar` is the one plugin table holding DIRECTLY IDENTIFYING personal data * about third parties: `subject_email`, `subject_name`, and the free text a data * subject wrote about themselves in `details` / `response_notes` / * `response_payload`. Everything else the plugin stores is either pseudonymous (the * ledger keys on a random visitor token, a truncated IP and a UA digest) or is site * configuration. * * Leaving it behind by default was a storage-limitation defect of its own (GDPR * art. 5.1.e). Once the plugin is deleted, OWCO_Install::run_maintenance() never runs * again, so the `retention_dsar_days` purge that is the ONLY thing bounding this * table is gone for good: the addresses would sit in the database forever, with no * screen left able to read, export, answer or erase them. Article 7(1) is not an * argument for keeping them either — the consent proof lives in `wp_owco_ledger`, * which is still governed by the operator's `delete_data_on_uninstall` choice. * * The rate-limit transients keyed on a hashed submitter address go with it. * * @return void */ function owco_uninstall_dsar_personal_data() { global $wpdb; $owco_dsar_table = $wpdb->prefix . 'owco_dsar'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- an uninstaller removing a table this plugin created IS a schema change, and it is the point of the file. A DDL statement has nothing to cache. The table identifier is bound with %i. $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $owco_dsar_table ) ); // `owco_rl_dsar` is the 1.2.0 throttle bucket (hashed IP / hashed e-mail); the two // `owco_dsar_rl_*` names are the pre-1.2.0 spelling, kept so an upgraded install is // cleaned too. owco_uninstall_purge_transients( array( 'owco_rl_dsar', 'owco_dsar_rl_ip_', 'owco_dsar_rl_email_' ) ); } /** * Remove every trace of the plugin from the CURRENT blog. * * @return void */ function owco_uninstall_current_site() { global $wpdb; // Tables. Names are rebuilt from $wpdb->prefix so this is correct inside // switch_to_blog() too — the plugin constants are not loaded during uninstall. // owco_form_links is the 1.2.0 table owned by OWCO_Forms (consent record <-> form // submission links, incl. email hashes): dropping it here keeps the uninstaller // free of any plugin include. // owco_dsar is listed for completeness only — owco_uninstall_dsar_personal_data() // has already dropped it by the time this runs, and DROP ... IF EXISTS is idempotent. // The `owc_` names are the pre-2.0.0 spelling. OWCO_Install::migrate_owc_prefix() // normally renames them on the first upgraded admin request, but a site that was // upgraded and deleted without one ever running still has them, and an uninstall // that says it removes everything has to mean it. $owco_tables = array( $wpdb->prefix . 'owco_ledger', $wpdb->prefix . 'owco_dsar', $wpdb->prefix . 'owco_scanner', $wpdb->prefix . 'owco_scripts', $wpdb->prefix . 'owco_form_links', $wpdb->prefix . 'owc_ledger', $wpdb->prefix . 'owc_dsar', $wpdb->prefix . 'owc_scanner', $wpdb->prefix . 'owc_scripts', $wpdb->prefix . 'owc_form_links', ); foreach ( $owco_tables as $owco_table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- the authorised half of the uninstall, so dropping the plugin's own tables is the intended schema change, and a DDL statement has nothing to cache. Each table identifier is bound with %i. $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $owco_table ) ); } // Named options. foreach ( owco_uninstall_option_names() as $owco_option ) { delete_option( $owco_option ); } // Anything else the plugin created under its own prefix, plus its transients and // their timeout rows. The `_site_transient_` variants are listed for completeness: // on a single-site install they live in this same table. The plugin itself uses // set_transient() exclusively, so nothing of ours ever lands in the network's // sitemeta table. // esc_like() escapes the trailing underscore, so `owc\_%` matches this plugin's // own rows and not a third party's `owcart_…`. $owco_like = $wpdb->esc_like( 'owco_' ) . '%'; $owco_prefixes = array( 'owco_', '_transient_owco_', '_transient_timeout_owco_', '_site_transient_owco_', '_site_transient_timeout_owco_', // pre-2.0.0 'owc_', '_transient_owc_', '_transient_timeout_owc_', '_site_transient_owc_', '_site_transient_timeout_owc_', ); $owco_likes = array(); foreach ( $owco_prefixes as $owco_prefix ) { $owco_likes[] = $wpdb->esc_like( $owco_prefix ) . '%'; } $owco_where = implode( ' OR ', array_fill( 0, count( $owco_likes ), 'option_name LIKE %s' ) ); $owco_sql = "SELECT option_name FROM {$wpdb->options} WHERE {$owco_where}"; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $owco_sql is assembled two lines up out of a literal WHERE clause repeated count( $owco_likes ) times and carries no data of its own; the sniff cannot count placeholders through that loop, but $owco_where always holds exactly count( $owco_likes ) `%s` tokens, and prepare() unwraps the single $owco_likes array into that many bound values. One-shot cleanup, no caching layer to update, no WP API for a prefix sweep. $owco_names = $wpdb->get_col( $wpdb->prepare( $owco_sql, $owco_likes ) ); foreach ( (array) $owco_names as $owco_name ) { delete_option( $owco_name ); } // Rate-limit / cache transients that are keyed by a hash suffix. owco_uninstall_purge_transients( array( 'owco_dsar_rl_ip_', 'owco_dsar_rl_email_', 'owco_rl_' ) ); // User meta the admin screens may have stored (dismissed notices, screen options). // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-shot cleanup, no WP API for a meta-key prefix sweep. $owco_meta_keys = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT meta_key FROM {$wpdb->usermeta} WHERE meta_key LIKE %s OR meta_key LIKE %s", $owco_like, $wpdb->esc_like( 'owc_' ) . '%' ) ); foreach ( (array) $owco_meta_keys as $owco_meta_key ) { delete_metadata( 'user', 0, $owco_meta_key, '', true ); } } /** * Is data removal authorised on the current blog? * * @return bool */ function owco_uninstall_is_authorised() { $owco_settings = get_option( 'owco_settings' ); if ( ! is_array( $owco_settings ) ) { return false; } if ( ! array_key_exists( 'delete_data_on_uninstall', $owco_settings ) ) { return false; // Default is to KEEP the consent proof. } $owco_flag = $owco_settings['delete_data_on_uninstall']; return ( true === $owco_flag || 1 === $owco_flag || '1' === $owco_flag || 'yes' === $owco_flag || 'on' === $owco_flag ); } if ( is_multisite() ) { $owco_sites = get_sites( array( 'fields' => 'ids', 'number' => 0, ) ); foreach ( (array) $owco_sites as $owco_blog_id ) { switch_to_blog( (int) $owco_blog_id ); // Cron first, and unconditionally: keeping the data is a decision about records, // not a reason to leave events firing against a hook nobody handles. The DSAR // register goes the same way, and for the same shape of reason. owco_uninstall_clear_cron(); owco_uninstall_dsar_personal_data(); if ( owco_uninstall_is_authorised() ) { owco_uninstall_current_site(); } restore_current_blog(); } // Network-level leftovers follow the main site's decision: it is the only site // whose administrator can be assumed to speak for the network. switch_to_blog( (int) get_main_site_id() ); $owco_network_ok = owco_uninstall_is_authorised(); restore_current_blog(); if ( $owco_network_ok ) { foreach ( owco_uninstall_option_names() as $owco_option ) { delete_site_option( $owco_option ); } } wp_cache_flush(); } else { // Same rule on a single site: the events and the DSAR register go whatever the // data decision is. owco_uninstall_clear_cron(); owco_uninstall_dsar_personal_data(); if ( owco_uninstall_is_authorised() ) { owco_uninstall_current_site(); } wp_cache_flush(); }