| 1 |
<?php |
| 2 |
/** |
| 3 |
* Everything needed to remove the plugin's own data, and nothing else. |
| 4 |
* |
| 5 |
* This file is deliberately self-contained: it defines functions and registers |
| 6 |
* no hooks, so `uninstall.php` can require it directly. Uninstall runs with the |
| 7 |
* plugin NOT loaded - no constants, no other includes - which is why the upload |
| 8 |
* path helper lives here rather than in accua-forms.php, and why the deletion |
| 9 |
* routine has one home shared by the Danger Zone button, the deactivation modal |
| 10 |
* and the uninstaller instead of a second copy that can drift. |
| 11 |
* |
| 12 |
* @since 2.3.0 |
| 13 |
* @package Contact Forms by Cimatti |
| 14 |
*/ |
| 15 |
|
| 16 |
if ( ! defined( 'ABSPATH' ) ) exit; |
| 17 |
|
| 18 |
/** |
| 19 |
* Option holding the "also delete the data when the plugin is deleted" choice. |
| 20 |
* |
| 21 |
* A plain, standalone option rather than a key inside one of the settings |
| 22 |
* arrays: uninstall.php has to read it with the plugin unloaded, and a single |
| 23 |
* get_option() with no unserializing assumptions is the least that can go wrong. |
| 24 |
* |
| 25 |
* @since 2.3.0 |
| 26 |
*/ |
| 27 |
define( 'ACCUA_FORMS_UNINSTALL_OPTION', 'accua_forms_delete_data_on_uninstall' ); |
| 28 |
|
| 29 |
/** |
| 30 |
* Whether the administrator asked for the data to be deleted on uninstall. |
| 31 |
* |
| 32 |
* Defaults to false: deleting the plugin from the Plugins screen - by accident, |
| 33 |
* or to reinstall it - must not destroy years of submissions. Only an explicit |
| 34 |
* opt-in in the Danger Zone changes that. |
| 35 |
* |
| 36 |
* @since 2.3.0 |
| 37 |
* @return bool |
| 38 |
*/ |
| 39 |
function accua_forms_delete_data_on_uninstall() { |
| 40 |
return (bool) get_option( ACCUA_FORMS_UNINSTALL_OPTION, 0 ); |
| 41 |
} |
| 42 |
|
| 43 |
/** |
| 44 |
* Absolute path of the directory uploaded files are stored in. |
| 45 |
* |
| 46 |
* @param string $dest_path Configured destination: empty for the default one, |
| 47 |
* absolute when it starts with a slash, otherwise |
| 48 |
* relative to the WordPress root. |
| 49 |
* @return string |
| 50 |
*/ |
| 51 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Internal helper function, underscore prefix indicates private |
| 52 |
function _accua_forms_get_abs_dest_path($dest_path = '') { |
| 53 |
if ($dest_path === '') { |
| 54 |
return realpath(ABSPATH) . '/wp-content/uploads/accua-forms'; |
| 55 |
} elseif (substr($dest_path,0,1) === '/') { |
| 56 |
return $dest_path; |
| 57 |
} else { |
| 58 |
return realpath(ABSPATH) . '/' . $dest_path; |
| 59 |
} |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Delete all Contact Forms plugin data: uploaded files, DB tables, options, cron, and transients. |
| 64 |
* |
| 65 |
* Used by the Danger Zone "Delete all data" button, the deactivation cleanup |
| 66 |
* handler and - only when the administrator opted in - uninstall.php. |
| 67 |
*/ |
| 68 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Internal helper with intentional underscore prefix |
| 69 |
function _accua_forms_delete_all_plugin_data() { |
| 70 |
global $wpdb; |
| 71 |
|
| 72 |
// 1. Delete uploaded files. |
| 73 |
// The destination is read from the option the upload path actually uses |
| 74 |
// (accua_forms_default_file_field_data); the old accua_forms_file_data name |
| 75 |
// has not been written for a long time, so this used to resolve to an empty |
| 76 |
// value, wipe the default directory and leave every file of a site with a |
| 77 |
// configured path untouched. The default directory is cleaned too, since |
| 78 |
// files uploaded before a custom path was set still live there. |
| 79 |
$file_settings = get_option( 'accua_forms_default_file_field_data', array() ); |
| 80 |
$configured = is_array( $file_settings ) && isset( $file_settings['dest_path'] ) ? $file_settings['dest_path'] : ''; |
| 81 |
|
| 82 |
$upload_dirs = array( _accua_forms_get_abs_dest_path( '' ) ); |
| 83 |
if ( '' !== $configured ) { |
| 84 |
$upload_dirs[] = _accua_forms_get_abs_dest_path( $configured ); |
| 85 |
} |
| 86 |
|
| 87 |
foreach ( array_unique( $upload_dirs ) as $dest_path ) { |
| 88 |
if ( is_dir( $dest_path ) && accua_forms_is_safe_upload_dir( $dest_path ) ) { |
| 89 |
accua_forms_recursive_rmdir( $dest_path ); |
| 90 |
} |
| 91 |
} |
| 92 |
|
| 93 |
// 2. Drop custom database tables |
| 94 |
$tables = array( |
| 95 |
esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' ), |
| 96 |
esc_sql( $wpdb->prefix . 'accua_forms_submissions_notes' ), |
| 97 |
esc_sql( $wpdb->prefix . 'accua_forms_submissions' ), |
| 98 |
); |
| 99 |
foreach ( $tables as $table ) { |
| 100 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter |
| 101 |
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); |
| 102 |
} |
| 103 |
|
| 104 |
// 3. Delete all plugin options. |
| 105 |
// Every option the plugin writes, plus names used by older versions which |
| 106 |
// may still be sitting on a long-lived site. |
| 107 |
$options = array( |
| 108 |
'accua_forms_saved_forms', |
| 109 |
'accua_forms_trash_forms', |
| 110 |
'accua_forms_default_form_data', |
| 111 |
'accua_forms_avail_fields', |
| 112 |
'accua_forms_avail_fields_corrupt_backup', |
| 113 |
'accua_forms_default_captcha_field_data', |
| 114 |
'accua_forms_default_file_field_data', |
| 115 |
'accua_forms_default_analytics_data', |
| 116 |
'accua_forms_anonymize_ip_data', |
| 117 |
'accua_forms_retention_data', |
| 118 |
'accua_forms_spam_data', |
| 119 |
'accua_forms_db_version', |
| 120 |
'accua_forms_lastid', |
| 121 |
'accua_form_api_keys', |
| 122 |
// The uninstall preference itself: a reset install starts from the safe |
| 123 |
// default again. uninstall.php has already read it by the time we get here. |
| 124 |
ACCUA_FORMS_UNINSTALL_OPTION, |
| 125 |
// Legacy names, kept so an old install is cleaned out too. |
| 126 |
'accua_forms_avail_fields_order', |
| 127 |
'accua_forms_file_data', |
| 128 |
'accua_forms_matomo_data', |
| 129 |
'accua_forms_ga_data', |
| 130 |
'accua_forms_style', |
| 131 |
'accua_forms_layout', |
| 132 |
); |
| 133 |
foreach ( $options as $option ) { |
| 134 |
delete_option( $option ); |
| 135 |
} |
| 136 |
|
| 137 |
// 4. Clear any pending cron events |
| 138 |
wp_clear_scheduled_hook( 'accua_forms_retention_cleanup' ); |
| 139 |
|
| 140 |
// 5. Delete the plugin's transients: the form drafts and the cached |
| 141 |
// dashboard statistics (which hold submission counts of their own). |
| 142 |
// Underscores are escaped because they are single-character wildcards in |
| 143 |
// LIKE; the leading-underscore _accua_forms_data_deleted flag set below is |
| 144 |
// deliberately not matched by this pattern. |
| 145 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 146 |
$wpdb->query( |
| 147 |
"DELETE FROM `{$wpdb->options}` |
| 148 |
WHERE option_name LIKE '\_transient\_accua\_forms\_%' ESCAPE '\\\\' |
| 149 |
OR option_name LIKE '\_transient\_timeout\_accua\_forms\_%' ESCAPE '\\\\'" |
| 150 |
); |
| 151 |
|
| 152 |
// 6. Delete the per-user screen preferences for the plugin's admin screens: |
| 153 |
// the submissions-list per-page setting and the hidden-column choices, |
| 154 |
// whose key is manage{screen}columnshidden. Matched narrowly so that an |
| 155 |
// extension plugin's own user meta is never taken with them. |
| 156 |
// INSTR rather than LIKE: the key contains underscores, which are |
| 157 |
// single-character wildcards in LIKE, and escaping them through PHP |
| 158 |
// string literals is easy to get subtly wrong. |
| 159 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- No API to look up meta keys by pattern; each key is then removed through delete_metadata() |
| 160 |
$meta_keys = $wpdb->get_col( |
| 161 |
"SELECT DISTINCT meta_key FROM `{$wpdb->usermeta}` |
| 162 |
WHERE meta_key = 'accua_forms_submissions_per_page' |
| 163 |
OR ( INSTR( meta_key, 'accua_forms_submissions_list' ) > 0 AND meta_key LIKE '%columnshidden' )" |
| 164 |
); |
| 165 |
foreach ( $meta_keys as $meta_key ) { |
| 166 |
delete_metadata( 'user', 0, $meta_key, '', true ); |
| 167 |
} |
| 168 |
|
| 169 |
// Object caches hold the options we just removed. |
| 170 |
wp_cache_flush(); |
| 171 |
|
| 172 |
// 6. Prevent accua_forms_check_db_version_and_update() from re-creating data |
| 173 |
// during the deactivation redirect (plugin still loads once more). |
| 174 |
set_transient( '_accua_forms_data_deleted', 1, 60 ); |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* Whether a directory is safe to delete recursively as the plugin's upload dir. |
| 179 |
* |
| 180 |
* The destination is administrator-configurable, and now that the deletion |
| 181 |
* reads the option it is really stored in, a value like "wp-content/uploads" |
| 182 |
* would take the whole media library with it. A directory is only accepted |
| 183 |
* when it sits strictly inside the WordPress root or the uploads directory and |
| 184 |
* is not one of those roots itself. |
| 185 |
* |
| 186 |
* @since 2.3.0 |
| 187 |
* @param string $dir Absolute directory path. |
| 188 |
* @return bool |
| 189 |
*/ |
| 190 |
function accua_forms_is_safe_upload_dir( $dir ) { |
| 191 |
$real = realpath( $dir ); |
| 192 |
if ( false === $real ) { |
| 193 |
return false; |
| 194 |
} |
| 195 |
|
| 196 |
$normalize = function ( $path ) { |
| 197 |
return rtrim( str_replace( '\\', '/', (string) $path ), '/' ); |
| 198 |
}; |
| 199 |
|
| 200 |
$real = $normalize( $real ); |
| 201 |
$abspath = $normalize( realpath( ABSPATH ) ); |
| 202 |
$uploads = wp_get_upload_dir(); |
| 203 |
$basedir = empty( $uploads['basedir'] ) ? '' : $normalize( realpath( $uploads['basedir'] ) ); |
| 204 |
|
| 205 |
// Never the roots themselves, nor wp-content / wp-includes / wp-admin. |
| 206 |
$forbidden = array_filter( array( |
| 207 |
$abspath, |
| 208 |
$basedir, |
| 209 |
$abspath . '/wp-content', |
| 210 |
$abspath . '/wp-includes', |
| 211 |
$abspath . '/wp-admin', |
| 212 |
) ); |
| 213 |
if ( in_array( $real, $forbidden, true ) ) { |
| 214 |
return false; |
| 215 |
} |
| 216 |
|
| 217 |
// Must live under one of the two roots. |
| 218 |
foreach ( array_filter( array( $abspath, $basedir ) ) as $root ) { |
| 219 |
if ( 0 === strpos( $real . '/', $root . '/' ) ) { |
| 220 |
return true; |
| 221 |
} |
| 222 |
} |
| 223 |
|
| 224 |
return false; |
| 225 |
} |
| 226 |
|
| 227 |
/** |
| 228 |
* Recursively delete a directory and its contents. |
| 229 |
* |
| 230 |
* @param string $dir Directory path. |
| 231 |
*/ |
| 232 |
function accua_forms_recursive_rmdir( $dir ) { |
| 233 |
if ( ! is_dir( $dir ) ) { |
| 234 |
return; |
| 235 |
} |
| 236 |
$items = new RecursiveIteratorIterator( |
| 237 |
new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ), |
| 238 |
RecursiveIteratorIterator::CHILD_FIRST |
| 239 |
); |
| 240 |
global $wp_filesystem; |
| 241 |
if ( ! function_exists( 'WP_Filesystem' ) ) { |
| 242 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 243 |
} |
| 244 |
WP_Filesystem(); |
| 245 |
foreach ( $items as $item ) { |
| 246 |
if ( $item->isDir() ) { |
| 247 |
$wp_filesystem->rmdir( $item->getRealPath() ); |
| 248 |
} else { |
| 249 |
wp_delete_file( $item->getRealPath() ); |
| 250 |
} |
| 251 |
} |
| 252 |
$wp_filesystem->rmdir( $dir ); |
| 253 |
} |
| 254 |
|