'ids', 'number' => 0, ) ) as $site_id ) { switch_to_blog( (int) $site_id ); self::uninstall_site(); restore_current_blog(); } return; } self::uninstall_site(); } /** * Removes the log files and the directory holding them. */ private static function delete_logs(): void { ( new LogStore() )->clear(); $directory = LogStore::directory(); if ( '' === $directory || ! is_dir( $directory ) ) { return; } if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; if ( ! $wp_filesystem instanceof \WP_Filesystem_Base ) { return; } /* * By name, and never recursively: `formpilot_log_directory` can point the logs * anywhere, and a recursive delete would take everything else in that * directory with them. The list missed the web.config LogStore also * writes, so the directory was never empty, the rmdir() failed with a * warning, and an uninstall told to remove everything left it behind. */ foreach ( array( '.htaccess', 'index.php', 'web.config' ) as $guard ) { if ( $wp_filesystem->is_file( $directory . $guard ) ) { $wp_filesystem->delete( $directory . $guard ); } } // Only succeeds when empty, so a shared directory survives. $wp_filesystem->rmdir( $directory, false ); } /** * Performs the uninstall tasks for the current site. */ private static function uninstall_site(): void { if ( ! self::should_remove_data() ) { return; } /** * Fires while the plugin is being uninstalled on a single site, before * the tables are dropped. * * Add-ons clean up their own storage here. * * @since 1.0.0 */ do_action( 'formpilot_uninstall' ); self::delete_uploads(); self::delete_logs(); Installer::drop_tables(); delete_option( CronQueue::OPTION ); delete_option( Settings::OPTION ); delete_option( Activator::VERSION_OPTION ); delete_option( Activator::INSTALLED_OPTION ); delete_option( self::REMOVE_DATA_OPTION ); self::delete_transients(); } /** * Removes the transients the plugin leaves scattered through the options table. * * These are not incidental. `formpilot_progress_*` holds **whatever the visitor * typed** into a multi-step form, and used to survive an uninstall that had * been explicitly told to delete the plugin's data — so the one thing an * administrator was trying to remove was the thing left behind. The spam * counters and the saved filters go too. * * Deleted by name pattern because transients have no registry. The object * cache is flushed afterwards, since on a site with a persistent one the rows * are only half the story. */ private static function delete_transients(): void { global $wpdb; foreach ( array( 'formpilot_progress_', 'formpilot_rate_', 'formpilot_interval_', 'formpilot_reply_', 'formpilot_entries_', 'formpilot_installing', 'formpilot_notification_queue_lock' ) as $prefix ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall cleanup; transients have no registry to ask. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( '_transient_' . $prefix ) . '%', $wpdb->esc_like( '_transient_timeout_' . $prefix ) . '%' ) ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- See above. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'formpilot_' ) . '%' ) ); /* * Per-user storage has no name pattern in the options table at all, so it * has to be named. The saved entry filters live in user meta, which neither * the prefix sweep nor the blanket delete above can reach, so they were left * behind entirely by an uninstall told to remove the plugin's data. */ delete_metadata( 'user', 0, SavedFilters::META_KEY, '', true ); // The per-page screen options. Named here rather than read from the list // tables, which extend WP_List_Table, a class uninstall does not load. delete_metadata( 'user', 0, 'formpilot_forms_per_page', '', true ); delete_metadata( 'user', 0, 'formpilot_entries_per_page', '', true ); /* * Flushed because on a site with a persistent object cache the rows above * are only half the story. This evicts more than this plugin owns, which is * the price of not being able to enumerate what it cached - and it happens * once, during an uninstall that has already dropped five tables. */ wp_cache_flush(); } /** * Removes the plugin upload directory. * * Files registered in the media library are left alone: they belong to the * site once they appear there, and deleting them would break attachments. */ private static function delete_uploads(): void { $directory = UploadManager::base_dir(); if ( '' === $directory || ! is_dir( $directory ) ) { return; } if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } WP_Filesystem(); global $wp_filesystem; if ( $wp_filesystem instanceof \WP_Filesystem_Base ) { $wp_filesystem->delete( $directory, true ); } } /** * Determines whether the stored plugin data should be deleted. * * @return bool True when the site owner opted into full removal. */ private static function should_remove_data(): bool { /** * Filters whether the plugin data is removed on uninstall. * * @since 1.0.0 * * @param bool $remove Whether to delete every plugin table and option. */ $opted_in = Settings::flag( 'remove_data' ) || (bool) get_option( self::REMOVE_DATA_OPTION, false ); return (bool) apply_filters( 'formpilot_remove_data_on_uninstall', $opted_in ); } }