. */ /** * Uninstaller — pattern-walk all `prweb_*` options + transients, clean up rows * left under the two historical prefixes, and remove the static rsl.xml. * * Earnings audit (the events table) is preserved by default — dropping it * requires explicit opt-in via `define('PRWEB_DROP_ON_UNINSTALL', true)` in * wp-config.php before uninstall. Per `docs/publisher/R1.md` exit criteria + * Mullenweg FIX 3 (D-04 synthesis): a publisher uninstalling the plugin must * not lose the audit trail of payments earned. * * Convention: pattern-walk via `LIKE` is preferred over hand-listing options — * it matches every `prweb_*` option and every `_transient_prweb_*` (and * `_transient_timeout_prweb_*`) regardless of which collaborator registered it. * New collaborators that follow the brand-prefix convention are cleaned * automatically; no uninstall.php edit required. * * The one exception is `pds_`, which is three characters of a common acronym * and could match another plugin's rows — those names are hand-listed, and * that list is the same one the storage-prefix migration moves forward. * * Historical prefixes are still swept here even though the migration renames * them on upgrade: an install can be uninstalled without ever having loaded * the plugin after the upgrade, and a half-finished migration must not leave * rows behind (ORL-213). * * @package Prweb */ declare(strict_types=1); defined( 'WP_UNINSTALL_PLUGIN' ) || exit; // ORL-112 -- PCP suppressions with rationale (WordPress.org reviewer audit trail). // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- One-shot, idempotency-gated DDL on the plugin's own tables. Caching DDL is meaningless; ALTER/DROP have no WP API. Verified there is no $_GET/$_POST/$_REQUEST/$_COOKIE anywhere in this file and every interpolation is $wpdb->prefix + a hardcoded literal. global $wpdb; // The generated brand class carries both the current prefixes and the // historical ones. Reading them from there keeps this file correct across the // next rename without an edit — hand-editing brand values is the ORL-167 // failure mode. require_once __DIR__ . '/includes/class-brand.php'; // Needed for log_dir_candidates(): the debug-log directory names are derived // from the brand prefix in one place, and uninstall must sweep the same list // the writer uses rather than repeating the derivation. The class is // dependency-free apart from Prweb_Brand above. require_once __DIR__ . '/includes/class-pds-debug-log.php'; // Needed for static_rsl_path(): uninstall must delete rsl.xml from the same // resolved document root the writer used (ORL-244). Dependency-free at load. require_once __DIR__ . '/includes/class-rsl-document.php'; /** Prefixes distinctive enough to sweep wholesale, current first. */ $prweb_sweepable_prefixes = [ Prweb_Brand::OPTION_PREFIX ]; /** Generic prefixes, mapped to the exact names this plugin owns under them. */ $prweb_narrow_prefixes = [ 'pds_' => [ 'pds_user_id', 'pds_prv_key', 'pds_api_base', 'pds_network', 'pds_validator_url', 'pds_rsl_mode', 'pds_wallet_id', 'pds_offer_defaults', ], ]; foreach ( Prweb_Brand::LEGACY_OPTION_PREFIXES as $prweb_legacy_prefix ) { if ( ! isset( $prweb_narrow_prefixes[ $prweb_legacy_prefix ] ) ) { $prweb_sweepable_prefixes[] = $prweb_legacy_prefix; } } // ----------------------------------------------------------------------------- // 1. Pattern-walk options + transients for every sweepable prefix. // // $wpdb->esc_like() escapes underscore wildcards inside the LIKE pattern; // $wpdb->prepare() then quote-escapes the prepared SQL. Combined, the LIKE is // injection-safe and matches only the literal prefix followed by anything. // ----------------------------------------------------------------------------- foreach ( $prweb_sweepable_prefixes as $prweb_prefix ) { // Transients live in wp_options as _transient_ + _transient_timeout_ // (or in a sitemeta table on multisite — handled below). $prweb_wrappers = [ '', '_transient_', '_transient_timeout_' ]; foreach ( $prweb_wrappers as $prweb_wrapper ) { // Options table — direct DELETE is correct (delete_option() forces an // option-by-option round-trip and would not match the prefix). $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( $prweb_wrapper . $prweb_prefix ) . '%' ) ); } // Multisite: site-transients live in wp_sitemeta as _site_transient_. if ( is_multisite() && ! empty( $wpdb->sitemeta ) ) { foreach ( [ '_site_transient_', '_site_transient_timeout_' ] as $prweb_wrapper ) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s", $wpdb->esc_like( $prweb_wrapper . $prweb_prefix ) . '%' ) ); } } } // ----------------------------------------------------------------------------- // 2. Hand-listed cleanup for the generic `pds_` prefix — publishers who // installed pre-rebrand, or whose upgrade never ran the storage-prefix // migration. Hand-listed, because `pds_%` LIKE could over-match another // plugin's rows. // ----------------------------------------------------------------------------- foreach ( $prweb_narrow_prefixes as $prweb_legacy_options ) { foreach ( $prweb_legacy_options as $prweb_opt ) { delete_option( $prweb_opt ); if ( is_multisite() ) { delete_site_option( $prweb_opt ); } } } // ----------------------------------------------------------------------------- // 3. Post / user / term meta under the current and historical meta prefixes. // `_pds_` is excluded for the same over-match reason as `pds_` above; the // plugin has never written a meta key there. // ----------------------------------------------------------------------------- $prweb_meta_prefixes = [ Prweb_Brand::META_PREFIX ]; foreach ( Prweb_Brand::LEGACY_META_PREFIXES as $prweb_legacy_meta ) { if ( ! isset( $prweb_narrow_prefixes[ ltrim( $prweb_legacy_meta, '_' ) ] ) ) { $prweb_meta_prefixes[] = $prweb_legacy_meta; } } foreach ( $prweb_meta_prefixes as $prweb_meta_prefix ) { foreach ( [ $wpdb->postmeta, $wpdb->usermeta, $wpdb->termmeta ] as $prweb_meta_table ) { if ( ! is_string( $prweb_meta_table ) || $prweb_meta_table === '' ) { continue; } $wpdb->query( $wpdb->prepare( "DELETE FROM {$prweb_meta_table} WHERE meta_key LIKE %s", $wpdb->esc_like( $prweb_meta_prefix ) . '%' ) ); } } // ----------------------------------------------------------------------------- // 4. Tables. The companion tables (`metadata`, `readers`) are dropped // unconditionally — they hold catalog and reader-session state, never // earnings. The events table (the audit canon — cross-referenced from // `shared/schemas/audit-event.schema.json` per D-05 + integration T-07) // survives unless the operator explicitly opts in: // // define('PRWEB_DROP_ON_UNINSTALL', true); // // Every prefix spelling is dropped, not just the current one: an install // uninstalled before the storage-prefix migration ever ran still holds its // tables under the old name. // ----------------------------------------------------------------------------- $prweb_table_prefixes = array_merge( [ Prweb_Brand::TABLE_PREFIX ], Prweb_Brand::LEGACY_TABLE_PREFIXES ); foreach ( $prweb_table_prefixes as $prweb_table_prefix ) { foreach ( [ 'metadata', 'readers' ] as $prweb_bare_table ) { $prweb_table = $wpdb->prefix . $prweb_table_prefix . $prweb_bare_table; $wpdb->query( "DROP TABLE IF EXISTS `{$prweb_table}`" ); } } if ( defined( 'PRWEB_DROP_ON_UNINSTALL' ) && PRWEB_DROP_ON_UNINSTALL ) { foreach ( $prweb_table_prefixes as $prweb_table_prefix ) { $prweb_events_table = $wpdb->prefix . $prweb_table_prefix . 'events'; $wpdb->query( "DROP TABLE IF EXISTS `{$prweb_events_table}`" ); } } // ----------------------------------------------------------------------------- // 5. Filesystem — remove the published rsl.xml at the site root if present. // ----------------------------------------------------------------------------- $prweb_static_rsl = Prweb_Rsl_Document::static_rsl_path(); if ( $prweb_static_rsl !== null && file_exists( $prweb_static_rsl ) ) { wp_delete_file( $prweb_static_rsl ); } // Releases before ORL-244 wrote it to the install directory; on a // subdirectory install that file is not the one above. $prweb_legacy_rsl = Prweb_Rsl_Document::legacy_static_rsl_path(); if ( $prweb_legacy_rsl !== null && file_exists( $prweb_legacy_rsl ) ) { wp_delete_file( $prweb_legacy_rsl ); } // The PDS debug log lives in a plugin-owned subdirectory of uploads. Every // spelling is swept, not just the current one: the directory carried the // `webapp-` prefix until ORL-229, and an install that never ran the rename // migration still holds it. Leaving it behind would orphan scrubbed API // traffic in a publisher's uploads folder forever. $prweb_upload = wp_upload_dir(); if ( empty( $prweb_upload['error'] ) && ! empty( $prweb_upload['basedir'] ) ) { foreach ( Prweb_Pds_Debug_Log::log_dir_candidates() as $prweb_log_dir_name ) { $prweb_log_dir = trailingslashit( $prweb_upload['basedir'] ) . $prweb_log_dir_name; if ( ! is_dir( $prweb_log_dir ) ) { continue; } // Only the three files this plugin writes, then the directory itself. // Never a recursive walk: uninstall must not delete anything it did not // create, whatever else has been dropped in there. foreach ( [ 'api_debug.json', '.htaccess', 'index.php' ] as $prweb_log_file ) { $prweb_path = $prweb_log_dir . '/' . $prweb_log_file; if ( file_exists( $prweb_path ) ) { wp_delete_file( $prweb_path ); } } // Removing a plugin-owned empty directory on uninstall; rmdir() fails // harmlessly if a file we did not create is still inside, which is the // behaviour we want. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- see above. @rmdir( $prweb_log_dir ); } }