PluginProbe
Contact Forms by Cimatti / 2.2.32
Contact Forms by Cimatti v2.2.32
2.3.6 2.3.5 2.3.0 2.2.32 2.2.4 2.2.0 2.1.2 2.1.1 trunk 1.0 1.1 1.2 1.2.1 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 All 62 releases
contact-forms / includes / privacy.php

privacy.php in Contact Forms by Cimatti 2.2.32, at includes/privacy.php

799 lines 30.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) exit;
3
4 /* =========================================================================
5 * GDPR DATA RETENTION & ANONYMIZATION
6 * ========================================================================= */
7
8 add_action( 'wp_ajax_accua-forms-anonymize-submission', 'accua_forms_ajax_anonymize_submission' );
9 /**
10 * AJAX handler to anonymize a single submission.
11 */
12 function accua_forms_ajax_anonymize_submission() {
13 if ( ! current_user_can( 'manage_options' ) ) {
14 wp_die( 0, 403 );
15 }
16 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified below after extracting subid
17 $subid = isset( $_POST['subid'] ) ? (int) $_POST['subid'] : 0;
18 if ( $subid ) {
19 check_ajax_referer( "anonymize_sub_{$subid}", '_nonce_anonymize' );
20 if ( accua_forms_erase_submission( $subid, 'anonymize' ) ) {
21 wp_die( 1 );
22 }
23 }
24 wp_die( 0, 500 );
25 }
26
27 add_action( 'wp_ajax_accua_forms_bulk_anonymize_preview', 'accua_forms_ajax_bulk_anonymize_preview' );
28 /**
29 * AJAX handler to preview how many submissions per form would be anonymized.
30 */
31 function accua_forms_ajax_bulk_anonymize_preview() {
32 if ( ! current_user_can( 'manage_options' ) ) {
33 wp_send_json_error( array( 'message' => 'Permission denied.' ), 403 );
34 }
35 check_ajax_referer( 'accua_forms_danger_zone', 'nonce' );
36
37 $value = isset( $_POST['value'] ) ? absint( $_POST['value'] ) : 0;
38 $unit = isset( $_POST['unit'] ) ? sanitize_key( wp_unslash( $_POST['unit'] ) ) : '';
39
40 if ( $value < 1 || ! in_array( $unit, array( 'days', 'months', 'years' ), true ) ) {
41 wp_send_json_error( array( 'message' => __( 'Invalid period.', 'contact-forms' ) ) );
42 }
43
44 $seconds = accua_forms_retention_to_seconds( $value, $unit );
45 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $seconds );
46
47 global $wpdb;
48 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
49
50 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
51 $rows = $wpdb->get_results( $wpdb->prepare(
52 "SELECT afs_form_id, COUNT(*) AS cnt FROM `{$table_subs}` WHERE afs_submitted < %s AND afs_anonymized = 0 GROUP BY afs_form_id ORDER BY cnt DESC",
53 $cutoff
54 ) );
55 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
56
57 $forms_data = get_option( 'accua_forms_saved_forms', array() );
58 $total = 0;
59 $forms = array();
60
61 foreach ( $rows as $row ) {
62 $fid = $row->afs_form_id;
63 $count = (int) $row->cnt;
64 $total += $count;
65 /* translators: %s: Form ID number */
66 $form_default_title = sprintf( __( 'Form #%s', 'contact-forms' ), $fid );
67 $title = isset( $forms_data[ $fid ]['title'] ) && $forms_data[ $fid ]['title'] !== ''
68 ? $forms_data[ $fid ]['title']
69 : $form_default_title;
70 $forms[] = array(
71 'id' => $fid,
72 'title' => $title,
73 'count' => $count,
74 );
75 }
76
77 wp_send_json_success( array(
78 'total' => $total,
79 'forms' => $forms,
80 ) );
81 }
82
83 add_action( 'wp_ajax_accua_forms_bulk_anonymize', 'accua_forms_ajax_bulk_anonymize' );
84 /**
85 * AJAX handler to bulk-anonymize submissions older than a given period.
86 */
87 function accua_forms_ajax_bulk_anonymize() {
88 if ( ! current_user_can( 'manage_options' ) ) {
89 wp_send_json_error( array( 'message' => 'Permission denied.' ), 403 );
90 }
91 check_ajax_referer( 'accua_forms_danger_zone', 'nonce' );
92
93 $value = isset( $_POST['value'] ) ? absint( $_POST['value'] ) : 0;
94 $unit = isset( $_POST['unit'] ) ? sanitize_key( wp_unslash( $_POST['unit'] ) ) : '';
95
96 if ( $value < 1 || ! in_array( $unit, array( 'days', 'months', 'years' ), true ) ) {
97 wp_send_json_error( array( 'message' => __( 'Invalid period.', 'contact-forms' ) ) );
98 }
99
100 $seconds = accua_forms_retention_to_seconds( $value, $unit );
101 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $seconds );
102
103 global $wpdb;
104 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
105
106 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
107 $ids = $wpdb->get_col( $wpdb->prepare(
108 "SELECT afs_id FROM `{$table_subs}` WHERE afs_submitted < %s AND afs_anonymized = 0",
109 $cutoff
110 ) );
111 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
112
113 $count = 0;
114 foreach ( $ids as $id ) {
115 if ( accua_forms_erase_submission( (int) $id, 'anonymize' ) ) {
116 $count++;
117 }
118 }
119
120 $unit_labels = array(
121 'days' => __( 'days', 'contact-forms' ),
122 'months' => __( 'months', 'contact-forms' ),
123 'years' => __( 'years', 'contact-forms' ),
124 );
125
126 wp_send_json_success( array(
127 'message' => sprintf(
128 /* translators: 1: number of anonymized submissions, 2: total found, 3: retention period, 4: unit */
129 __( 'Done. %1$d of %2$d submissions older than %3$d %4$s have been anonymized.', 'contact-forms' ),
130 $count,
131 count( $ids ),
132 $value,
133 $unit_labels[ $unit ] ?? $unit
134 ),
135 ) );
136 }
137
138 add_action( 'wp_ajax_accua_forms_delete_all_data', 'accua_forms_ajax_delete_all_data' );
139 /**
140 * AJAX handler to delete ALL Contact Forms plugin data.
141 */
142 function accua_forms_ajax_delete_all_data() {
143 if ( ! current_user_can( 'manage_options' ) ) {
144 wp_send_json_error( array( 'message' => 'Permission denied.' ), 403 );
145 }
146 check_ajax_referer( 'accua_forms_danger_zone', 'nonce' );
147
148 $confirm_domain = isset( $_POST['confirm_domain'] ) ? sanitize_text_field( wp_unslash( $_POST['confirm_domain'] ) ) : '';
149 $expected = wp_parse_url( home_url(), PHP_URL_HOST );
150
151 if ( $confirm_domain !== $expected ) {
152 wp_send_json_error( array( 'message' => __( 'Domain confirmation does not match.', 'contact-forms' ) ) );
153 }
154
155 _accua_forms_delete_all_plugin_data();
156
157 wp_send_json_success( array(
158 'message' => __( 'All Contact Forms data has been deleted. The plugin is now reset. You may deactivate it or reload this page.', 'contact-forms' ),
159 ) );
160 }
161
162 /**
163 * Delete all Contact Forms plugin data: uploaded files, DB tables, options, cron, and transients.
164 *
165 * Used by both the Danger Zone "Delete all data" and the deactivation cleanup handler.
166 */
167 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Internal helper with intentional underscore prefix
168 function _accua_forms_delete_all_plugin_data() {
169 global $wpdb;
170
171 // 1. Delete uploaded files
172 $dest_path = _accua_forms_get_abs_dest_path(
173 get_option( 'accua_forms_file_data', array() )['dest_path'] ?? ''
174 );
175 if ( is_dir( $dest_path ) ) {
176 accua_forms_recursive_rmdir( $dest_path );
177 }
178
179 // 2. Drop custom database tables
180 $tables = array(
181 esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' ),
182 esc_sql( $wpdb->prefix . 'accua_forms_submissions_notes' ),
183 esc_sql( $wpdb->prefix . 'accua_forms_submissions' ),
184 );
185 foreach ( $tables as $table ) {
186 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
187 $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
188 }
189
190 // 3. Delete all plugin options
191 $options = array(
192 'accua_forms_saved_forms',
193 'accua_forms_trash_forms',
194 'accua_forms_default_form_data',
195 'accua_forms_avail_fields',
196 'accua_forms_avail_fields_order',
197 'accua_forms_file_data',
198 'accua_forms_anonymize_ip_data',
199 'accua_forms_retention_data',
200 'accua_forms_matomo_data',
201 'accua_forms_ga_data',
202 'accua_forms_style',
203 'accua_forms_db_version',
204 'accua_forms_layout',
205 'accua_forms_lastid',
206 'accua_form_api_keys',
207 );
208 foreach ( $options as $option ) {
209 delete_option( $option );
210 }
211
212 // 4. Clear any pending cron events
213 wp_clear_scheduled_hook( 'accua_forms_retention_cleanup' );
214
215 // 5. Delete draft transients
216 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
217 $wpdb->query(
218 "DELETE FROM `{$wpdb->options}` WHERE option_name LIKE '_transient_accua_forms_draft_%' OR option_name LIKE '_transient_timeout_accua_forms_draft_%'"
219 );
220
221 // 6. Prevent accua_forms_check_db_version_and_update() from re-creating data
222 // during the deactivation redirect (plugin still loads once more).
223 set_transient( '_accua_forms_data_deleted', 1, 60 );
224 }
225
226 add_action( 'wp_ajax_accua_forms_deactivation_cleanup', 'accua_forms_ajax_deactivation_cleanup' );
227 /**
228 * AJAX handler for the deactivation modal.
229 *
230 * Accepts a mode: 'delete' (remove all data), 'anonymize' (anonymize all submissions), or 'skip' (do nothing).
231 */
232 function accua_forms_ajax_deactivation_cleanup() {
233 if ( ! current_user_can( 'manage_options' ) ) {
234 wp_send_json_error( array( 'message' => 'Permission denied.' ), 403 );
235 }
236 check_ajax_referer( 'accua_forms_deactivation_cleanup', 'nonce' );
237
238 $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : '';
239
240 if ( ! in_array( $mode, array( 'delete', 'anonymize' ), true ) ) {
241 wp_send_json_error( array( 'message' => __( 'Invalid mode.', 'contact-forms' ) ) );
242 }
243
244 if ( $mode === 'delete' ) {
245 _accua_forms_delete_all_plugin_data();
246 wp_send_json_success();
247 }
248
249 // Anonymize all non-anonymized submissions
250 global $wpdb;
251 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
252
253 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
254 $ids = $wpdb->get_col( "SELECT afs_id FROM `{$table_subs}` WHERE afs_anonymized = 0" );
255
256 $count = 0;
257 foreach ( $ids as $id ) {
258 if ( accua_forms_erase_submission( (int) $id, 'anonymize' ) ) {
259 $count++;
260 }
261 }
262
263 wp_send_json_success( array(
264 'message' => sprintf(
265 /* translators: %d: number of submissions anonymized */
266 __( '%d submissions anonymized.', 'contact-forms' ),
267 $count
268 ),
269 ) );
270 }
271
272 /**
273 * Recursively delete a directory and its contents.
274 *
275 * @param string $dir Directory path.
276 */
277 function accua_forms_recursive_rmdir( $dir ) {
278 if ( ! is_dir( $dir ) ) {
279 return;
280 }
281 $items = new RecursiveIteratorIterator(
282 new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
283 RecursiveIteratorIterator::CHILD_FIRST
284 );
285 global $wp_filesystem;
286 if ( ! function_exists( 'WP_Filesystem' ) ) {
287 require_once ABSPATH . 'wp-admin/includes/file.php';
288 }
289 WP_Filesystem();
290 foreach ( $items as $item ) {
291 if ( $item->isDir() ) {
292 $wp_filesystem->rmdir( $item->getRealPath() );
293 } else {
294 wp_delete_file( $item->getRealPath() );
295 }
296 }
297 $wp_filesystem->rmdir( $dir );
298 }
299
300 /**
301 * Map a Contact Forms field type to a wp_privacy_anonymize_data() type.
302 *
303 * @param string $afsv_type Field type stored in afsv_type column.
304 * @return string One of 'email', 'url', 'text', 'longtext'.
305 */
306 function accua_forms_privacy_anonymize_type( $afsv_type ) {
307 $afsv_type = strtolower( $afsv_type );
308 switch ( $afsv_type ) {
309 case 'email':
310 case 'autoreply_email':
311 return 'email';
312 case 'url':
313 case 'website':
314 return 'url';
315 default:
316 return 'text';
317 }
318 }
319
320 /**
321 * Erase or anonymize a single form submission.
322 *
323 * @param int $submission_id The afs_id of the submission.
324 * @param string $mode Either 'anonymize' or 'delete'.
325 * @return bool True if something was erased/anonymized.
326 */
327 function accua_forms_erase_submission( $submission_id, $mode = 'anonymize' ) {
328 global $wpdb;
329 $submission_id = absint( $submission_id );
330 if ( ! $submission_id ) {
331 return false;
332 }
333
334 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
335 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
336 $table_notes = esc_sql( $wpdb->prefix . 'accua_forms_submissions_notes' );
337
338 if ( $mode === 'delete' ) {
339 // Delete uploaded files first
340 accua_forms_delete_submission_files( $submission_id );
341
342 // phpcs:disable WordPress.DB.DirectDatabaseQuery
343 $wpdb->delete( $table_values, array( 'afsv_sub_id' => $submission_id ), array( '%d' ) );
344 $wpdb->delete( $table_notes, array( 'afsn_sub_id' => $submission_id ), array( '%d' ) );
345 $wpdb->delete( $table_subs, array( 'afs_id' => $submission_id ), array( '%d' ) );
346 // phpcs:enable WordPress.DB.DirectDatabaseQuery
347 return true;
348 }
349
350 // Anonymize mode
351
352 // Delete uploaded files first
353 accua_forms_delete_submission_files( $submission_id );
354
355 // Anonymize each field value based on its type
356 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
357 $fields = $wpdb->get_results( $wpdb->prepare(
358 "SELECT afsv_field_id, afsv_type FROM `{$table_values}` WHERE afsv_sub_id = %d",
359 $submission_id
360 ) );
361 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
362
363 if ( $fields ) {
364 foreach ( $fields as $field ) {
365 $anon_type = accua_forms_privacy_anonymize_type( $field->afsv_type );
366 $anon_value = wp_privacy_anonymize_data( $anon_type );
367
368 // Use our own string for text fields — WP's [deleted]/[eliminato] is ambiguous
369 if ( $anon_type === 'text' ) {
370 $anon_value = __( '[Anonymized]', 'contact-forms' );
371 }
372
373 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
374 $wpdb->update(
375 $table_values,
376 array( 'afsv_value' => $anon_value ),
377 array( 'afsv_sub_id' => $submission_id, 'afsv_field_id' => $field->afsv_field_id ),
378 array( '%s' ),
379 array( '%d', '%s' )
380 );
381 }
382 }
383
384 // Anonymize submission metadata (IP, stats)
385 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
386 $wpdb->update(
387 $table_subs,
388 array(
389 'afs_ip' => '0.0.0.0',
390 'afs_stats' => '',
391 'afs_anonymized' => 1,
392 ),
393 array( 'afs_id' => $submission_id ),
394 array( '%s', '%s', '%d' ),
395 array( '%d' )
396 );
397
398 // Anonymize notes — use our own string for consistency with field values
399 $anon_text = __( '[Anonymized]', 'contact-forms' );
400 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
401 $wpdb->query( $wpdb->prepare(
402 "UPDATE `{$table_notes}` SET afsn_text = %s, afsn_user = %s WHERE afsn_sub_id = %d",
403 $anon_text,
404 $anon_text,
405 $submission_id
406 ) );
407 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
408
409 return true;
410 }
411
412 /**
413 * Delete uploaded files associated with a submission.
414 *
415 * @param int $submission_id The afs_id of the submission.
416 */
417 function accua_forms_delete_submission_files( $submission_id ) {
418 global $wpdb;
419 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
420
421 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
422 $file_fields = $wpdb->get_results( $wpdb->prepare(
423 "SELECT afsv_value FROM `{$table_values}` WHERE afsv_sub_id = %d AND afsv_type = 'file' AND afsv_value != ''",
424 $submission_id
425 ) );
426 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
427
428 if ( ! $file_fields ) {
429 return;
430 }
431
432 $upload_base = _accua_forms_get_abs_dest_path(
433 isset( get_option( 'accua_forms_default_file_field_data', array() )['dest_path'] )
434 ? get_option( 'accua_forms_default_file_field_data', array() )['dest_path']
435 : ''
436 );
437
438 foreach ( $file_fields as $file_field ) {
439 $filename = $file_field->afsv_value;
440 if ( empty( $filename ) ) {
441 continue;
442 }
443 // The value is the filename within the upload directory
444 $filepath = trailingslashit( $upload_base ) . $filename;
445 // Safety: only delete if within the upload directory
446 $real_upload = realpath( $upload_base );
447 $real_file = realpath( $filepath );
448 if ( $real_file && $real_upload && strpos( $real_file, $real_upload ) === 0 ) {
449 wp_delete_file( $real_file );
450 }
451 }
452 }
453
454 /**
455 * Find submission IDs for a given email address.
456 *
457 * Looks up submissions by matching email-type fields (afsv_type IN ('email', 'autoreply_email')).
458 *
459 * @param string $email_address Email to search for.
460 * @param int $page Page number (1-based).
461 * @param int $per_page Results per page.
462 * @return array Array of submission row objects (afs_id, afs_form_id).
463 */
464 function accua_forms_find_submissions_by_email( $email_address, $page = 1, $per_page = 50 ) {
465 global $wpdb;
466 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
467 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
468
469 $offset = ( $page - 1 ) * $per_page;
470
471 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
472 return $wpdb->get_results( $wpdb->prepare(
473 "SELECT DISTINCT s.afs_id, s.afs_form_id
474 FROM `{$table_subs}` s
475 INNER JOIN `{$table_values}` sv ON s.afs_id = sv.afsv_sub_id
476 WHERE sv.afsv_type IN ('email', 'autoreply_email')
477 AND sv.afsv_value = %s
478 AND s.afs_status >= 0
479 AND s.afs_anonymized = 0
480 ORDER BY s.afs_id ASC
481 LIMIT %d OFFSET %d",
482 $email_address,
483 $per_page,
484 $offset
485 ) );
486 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
487 }
488
489 /* -------------------------------------------------------------------------
490 * WordPress Privacy API — Personal Data Exporter
491 * ------------------------------------------------------------------------- */
492
493 add_filter( 'wp_privacy_personal_data_exporters', 'accua_forms_register_privacy_exporter' );
494 /**
495 * Register the Contact Forms personal data exporter.
496 *
497 * @param array $exporters Registered exporters.
498 * @return array
499 */
500 function accua_forms_register_privacy_exporter( $exporters ) {
501 $exporters['contact-forms'] = array(
502 'exporter_friendly_name' => __( 'Contact Forms Submissions', 'contact-forms' ),
503 'callback' => 'accua_forms_privacy_exporter',
504 );
505 return $exporters;
506 }
507
508 /**
509 * Export personal data for a given email address.
510 *
511 * @param string $email_address The email to export data for.
512 * @param int $page Page number.
513 * @return array Export data array with 'data' and 'done' keys.
514 */
515 function accua_forms_privacy_exporter( $email_address, $page = 1 ) {
516 global $wpdb;
517 $per_page = 50;
518 $export_items = array();
519 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
520 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
521
522 $submissions = accua_forms_find_submissions_by_email( $email_address, $page, $per_page );
523
524 foreach ( $submissions as $sub ) {
525 $data = array();
526
527 // Get submission metadata
528 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
529 $meta = $wpdb->get_row( $wpdb->prepare(
530 "SELECT afs_ip, afs_uri, afs_referrer, afs_submitted, afs_stats FROM `{$table_subs}` WHERE afs_id = %d",
531 $sub->afs_id
532 ) );
533 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
534
535 if ( $meta ) {
536 if ( $meta->afs_ip !== '' ) {
537 $data[] = array(
538 'name' => __( 'IP Address', 'contact-forms' ),
539 'value' => $meta->afs_ip,
540 );
541 }
542 $data[] = array(
543 'name' => __( 'Submitted', 'contact-forms' ),
544 'value' => $meta->afs_submitted,
545 );
546 if ( $meta->afs_uri !== '' ) {
547 $data[] = array(
548 'name' => __( 'Page URL', 'contact-forms' ),
549 'value' => $meta->afs_uri,
550 );
551 }
552 if ( $meta->afs_referrer !== '' ) {
553 $data[] = array(
554 'name' => __( 'Referrer', 'contact-forms' ),
555 'value' => $meta->afs_referrer,
556 );
557 }
558 }
559
560 // Get all field values
561 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
562 $fields = $wpdb->get_results( $wpdb->prepare(
563 "SELECT afsv_field_id, afsv_value FROM `{$table_values}` WHERE afsv_sub_id = %d",
564 $sub->afs_id
565 ) );
566 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
567
568 foreach ( $fields as $field ) {
569 $data[] = array(
570 'name' => $field->afsv_field_id,
571 'value' => $field->afsv_value,
572 );
573 }
574
575 $export_items[] = array(
576 'group_id' => 'contact-form-submissions',
577 'group_label' => __( 'Contact Form Submissions', 'contact-forms' ),
578 'group_description' => __( 'Data submitted through contact forms on this site.', 'contact-forms' ),
579 'item_id' => "contact-form-submission-{$sub->afs_id}",
580 'data' => $data,
581 );
582 }
583
584 return array(
585 'data' => $export_items,
586 'done' => count( $submissions ) < $per_page,
587 );
588 }
589
590 /* -------------------------------------------------------------------------
591 * WordPress Privacy API — Personal Data Eraser
592 * ------------------------------------------------------------------------- */
593
594 add_filter( 'wp_privacy_personal_data_erasers', 'accua_forms_register_privacy_eraser' );
595 /**
596 * Register the Contact Forms personal data eraser.
597 *
598 * @param array $erasers Registered erasers.
599 * @return array
600 */
601 function accua_forms_register_privacy_eraser( $erasers ) {
602 $erasers['contact-forms'] = array(
603 'eraser_friendly_name' => __( 'Contact Forms Submissions', 'contact-forms' ),
604 'callback' => 'accua_forms_privacy_eraser',
605 );
606 return $erasers;
607 }
608
609 /**
610 * Erase personal data for a given email address.
611 *
612 * @param string $email_address The email to erase data for.
613 * @param int $page Page number.
614 * @return array Eraser response array.
615 */
616 function accua_forms_privacy_eraser( $email_address, $page = 1 ) {
617 $per_page = 50;
618 $items_removed = false;
619 $items_retained = false;
620 $messages = array();
621
622 $submissions = accua_forms_find_submissions_by_email( $email_address, $page, $per_page );
623
624 foreach ( $submissions as $sub ) {
625 $config = accua_forms_get_retention_config( $sub->afs_form_id );
626 $mode = $config['mode'];
627
628 if ( accua_forms_erase_submission( $sub->afs_id, $mode ) ) {
629 $items_removed = true;
630 }
631 }
632
633 return array(
634 'items_removed' => $items_removed,
635 'items_retained' => $items_retained,
636 'messages' => $messages,
637 'done' => count( $submissions ) < $per_page,
638 );
639 }
640
641 /* -------------------------------------------------------------------------
642 * WordPress Privacy API — Privacy Policy Suggestion
643 * ------------------------------------------------------------------------- */
644
645 add_action( 'admin_init', 'accua_forms_add_privacy_policy_content' );
646 /**
647 * Suggest privacy policy content for Contact Forms.
648 */
649 function accua_forms_add_privacy_policy_content() {
650 if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
651 return;
652 }
653
654 $content = '<h2>' . __( 'Contact Forms', 'contact-forms' ) . '</h2>' .
655 '<p>' . __( 'When you submit a form on this site, we collect the data you provide in the form fields (such as your name, email address, phone number, and message), as well as your IP address and browser user-agent string to help spam detection.', 'contact-forms' ) . '</p>' .
656 '<p>' . __( 'If the form includes file upload fields, the uploaded files are stored on our server.', 'contact-forms' ) . '</p>' .
657 '<p>' . __( 'Form submissions are retained for the period configured by the site administrator. After the retention period expires, submissions are automatically anonymized or deleted depending on site settings.', 'contact-forms' ) . '</p>' .
658 '<p>' . __( 'If you request data erasure through the WordPress personal data erasure tool, all form submissions associated with your email address will be anonymized or deleted.', 'contact-forms' ) . '</p>';
659
660 wp_add_privacy_policy_content( 'Contact Forms', wp_kses_post( $content ) );
661 }
662
663 /* -------------------------------------------------------------------------
664 * Data Retention Settings — Resolution Helper
665 * ------------------------------------------------------------------------- */
666
667 /**
668 * Get the retention configuration for a specific form.
669 *
670 * Checks per-form override first, then falls back to global default.
671 *
672 * @param string $form_id Form ID.
673 * @return array {
674 * @type int $seconds Retention period in seconds (0 = no expiry).
675 * @type string $mode 'anonymize' or 'delete'.
676 * }
677 */
678 function accua_forms_get_retention_config( $form_id = '' ) {
679 $default = array(
680 'seconds' => 0,
681 'mode' => 'anonymize',
682 );
683
684 // Check per-form override
685 if ( $form_id !== '' ) {
686 $forms_data = get_option( 'accua_forms_saved_forms', array() );
687 if ( isset( $forms_data[ $form_id ] ) ) {
688 $form = $forms_data[ $form_id ];
689 if ( ! empty( $form['submission_retention_override'] ) ) {
690 $val = isset( $form['submission_retention_value'] ) ? (int) $form['submission_retention_value'] : 0;
691 $unit = isset( $form['submission_retention_unit'] ) ? $form['submission_retention_unit'] : 'months';
692 $mode = isset( $form['submission_retention_mode'] ) ? $form['submission_retention_mode'] : 'anonymize';
693 if ( $val > 0 ) {
694 return array(
695 'seconds' => accua_forms_retention_to_seconds( $val, $unit ),
696 'mode' => in_array( $mode, array( 'anonymize', 'delete' ), true ) ? $mode : 'anonymize',
697 );
698 }
699 return array( 'seconds' => 0, 'mode' => in_array( $mode, array( 'anonymize', 'delete' ), true ) ? $mode : 'anonymize' );
700 }
701 }
702 }
703
704 // Fall back to global setting
705 $retention_data = get_option( 'accua_forms_retention_data', array() );
706 $val = isset( $retention_data['retention_value'] ) ? (int) $retention_data['retention_value'] : 0;
707 $unit = isset( $retention_data['retention_unit'] ) ? $retention_data['retention_unit'] : 'months';
708 $mode = isset( $retention_data['retention_mode'] ) ? $retention_data['retention_mode'] : 'anonymize';
709
710 if ( $val > 0 ) {
711 return array(
712 'seconds' => accua_forms_retention_to_seconds( $val, $unit ),
713 'mode' => in_array( $mode, array( 'anonymize', 'delete' ), true ) ? $mode : 'anonymize',
714 );
715 }
716
717 return $default;
718 }
719
720 /**
721 * Convert a retention value + unit to seconds.
722 *
723 * @param int $value Retention value.
724 * @param string $unit 'days', 'months', or 'years'.
725 * @return int Seconds.
726 */
727 function accua_forms_retention_to_seconds( $value, $unit ) {
728 $value = max( 0, (int) $value );
729 switch ( $unit ) {
730 case 'days':
731 return $value * DAY_IN_SECONDS;
732 case 'years':
733 return $value * YEAR_IN_SECONDS;
734 case 'months':
735 default:
736 return $value * 30 * DAY_IN_SECONDS;
737 }
738 }
739
740 /* -------------------------------------------------------------------------
741 * Data Retention — WP-Cron Cleanup Handler
742 * ------------------------------------------------------------------------- */
743
744 add_action( 'accua_forms_retention_cleanup', 'accua_forms_retention_cleanup_handler' );
745 /**
746 * Cron callback: anonymize or delete expired submissions.
747 */
748 function accua_forms_retention_cleanup_handler() {
749 global $wpdb;
750 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
751
752 $forms_data = get_option( 'accua_forms_saved_forms', array() );
753 if ( ! is_array( $forms_data ) ) {
754 return;
755 }
756
757 // Collect all unique form IDs that have submissions (including deleted forms)
758 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
759 $form_ids = $wpdb->get_col( "SELECT DISTINCT afs_form_id FROM `{$table_subs}` WHERE afs_status >= 0" );
760
761 foreach ( $form_ids as $form_id ) {
762 $config = accua_forms_get_retention_config( $form_id );
763 if ( $config['seconds'] <= 0 ) {
764 continue;
765 }
766
767 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $config['seconds'] );
768
769 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
770 $expired_ids = $wpdb->get_col( $wpdb->prepare(
771 "SELECT afs_id FROM `{$table_subs}`
772 WHERE afs_form_id = %s
773 AND afs_submitted < %s
774 AND afs_status >= 0
775 AND ( %s = 'delete' OR afs_anonymized = 0 )
776 ORDER BY afs_id ASC
777 LIMIT 100",
778 $form_id,
779 $cutoff,
780 $config['mode']
781 ) );
782 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
783
784 foreach ( $expired_ids as $sub_id ) {
785 accua_forms_erase_submission( (int) $sub_id, $config['mode'] );
786 }
787 }
788 }
789
790 /**
791 * Self-healing: ensure the retention cron is scheduled.
792 */
793 add_action( 'admin_init', 'accua_forms_ensure_retention_cron' );
794 function accua_forms_ensure_retention_cron() {
795 if ( ! wp_next_scheduled( 'accua_forms_retention_cleanup' ) ) {
796 wp_schedule_event( time(), 'daily', 'accua_forms_retention_cleanup' );
797 }
798 }
799