PluginProbe
Contact Forms by Cimatti / 2.3.6
Contact Forms by Cimatti v2.3.6
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.3.6, at includes/privacy.php

714 lines 27.2 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 * The deletion routine itself - _accua_forms_delete_all_plugin_data(), plus
164 * accua_forms_is_safe_upload_dir() and accua_forms_recursive_rmdir() - lives in
165 * includes/data-deletion.php, so that uninstall.php can require it on its own
166 * with the plugin unloaded.
167 */
168
169 add_action( 'wp_ajax_accua_forms_deactivation_cleanup', 'accua_forms_ajax_deactivation_cleanup' );
170 /**
171 * AJAX handler for the deactivation modal.
172 *
173 * Accepts a mode: 'delete' (remove all data), 'anonymize' (anonymize all submissions), or 'skip' (do nothing).
174 */
175 function accua_forms_ajax_deactivation_cleanup() {
176 if ( ! current_user_can( 'manage_options' ) ) {
177 wp_send_json_error( array( 'message' => 'Permission denied.' ), 403 );
178 }
179 check_ajax_referer( 'accua_forms_deactivation_cleanup', 'nonce' );
180
181 $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : '';
182
183 if ( ! in_array( $mode, array( 'delete', 'anonymize' ), true ) ) {
184 wp_send_json_error( array( 'message' => __( 'Invalid mode.', 'contact-forms' ) ) );
185 }
186
187 if ( $mode === 'delete' ) {
188 _accua_forms_delete_all_plugin_data();
189 wp_send_json_success();
190 }
191
192 // Anonymize all non-anonymized submissions
193 global $wpdb;
194 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
195
196 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
197 $ids = $wpdb->get_col( "SELECT afs_id FROM `{$table_subs}` WHERE afs_anonymized = 0" );
198
199 $count = 0;
200 foreach ( $ids as $id ) {
201 if ( accua_forms_erase_submission( (int) $id, 'anonymize' ) ) {
202 $count++;
203 }
204 }
205
206 wp_send_json_success( array(
207 'message' => sprintf(
208 /* translators: %d: number of submissions anonymized */
209 __( '%d submissions anonymized.', 'contact-forms' ),
210 $count
211 ),
212 ) );
213 }
214
215 /**
216 * Map a Contact Forms field type to a wp_privacy_anonymize_data() type.
217 *
218 * @param string $afsv_type Field type stored in afsv_type column.
219 * @return string One of 'email', 'url', 'text', 'longtext'.
220 */
221 function accua_forms_privacy_anonymize_type( $afsv_type ) {
222 $afsv_type = strtolower( $afsv_type );
223 switch ( $afsv_type ) {
224 case 'email':
225 case 'autoreply_email':
226 return 'email';
227 case 'url':
228 case 'website':
229 return 'url';
230 default:
231 return 'text';
232 }
233 }
234
235 /**
236 * Erase or anonymize a single form submission.
237 *
238 * @param int $submission_id The afs_id of the submission.
239 * @param string $mode Either 'anonymize' or 'delete'.
240 * @return bool True if something was erased/anonymized.
241 */
242 function accua_forms_erase_submission( $submission_id, $mode = 'anonymize' ) {
243 global $wpdb;
244 $submission_id = absint( $submission_id );
245 if ( ! $submission_id ) {
246 return false;
247 }
248
249 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
250 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
251 $table_notes = esc_sql( $wpdb->prefix . 'accua_forms_submissions_notes' );
252
253 if ( $mode === 'delete' ) {
254 // Delete uploaded files first
255 accua_forms_delete_submission_files( $submission_id );
256
257 // phpcs:disable WordPress.DB.DirectDatabaseQuery
258 $wpdb->delete( $table_values, array( 'afsv_sub_id' => $submission_id ), array( '%d' ) );
259 $wpdb->delete( $table_notes, array( 'afsn_sub_id' => $submission_id ), array( '%d' ) );
260 $wpdb->delete( $table_subs, array( 'afs_id' => $submission_id ), array( '%d' ) );
261 // phpcs:enable WordPress.DB.DirectDatabaseQuery
262 return true;
263 }
264
265 // Anonymize mode
266
267 // Delete uploaded files first
268 accua_forms_delete_submission_files( $submission_id );
269
270 // Anonymize each field value based on its type
271 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
272 $fields = $wpdb->get_results( $wpdb->prepare(
273 "SELECT afsv_field_id, afsv_type FROM `{$table_values}` WHERE afsv_sub_id = %d",
274 $submission_id
275 ) );
276 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
277
278 if ( $fields ) {
279 foreach ( $fields as $field ) {
280 $anon_type = accua_forms_privacy_anonymize_type( $field->afsv_type );
281 $anon_value = wp_privacy_anonymize_data( $anon_type );
282
283 // Use our own string for text fields - WP's [deleted]/[eliminato] is ambiguous
284 if ( $anon_type === 'text' ) {
285 $anon_value = __( '[Anonymized]', 'contact-forms' );
286 }
287
288 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
289 $wpdb->update(
290 $table_values,
291 array( 'afsv_value' => $anon_value ),
292 array( 'afsv_sub_id' => $submission_id, 'afsv_field_id' => $field->afsv_field_id ),
293 array( '%s' ),
294 array( '%d', '%s' )
295 );
296 }
297 }
298
299 // Anonymize submission metadata (IP, stats)
300 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
301 $wpdb->update(
302 $table_subs,
303 array(
304 'afs_ip' => '0.0.0.0',
305 'afs_stats' => '',
306 'afs_anonymized' => 1,
307 ),
308 array( 'afs_id' => $submission_id ),
309 array( '%s', '%s', '%d' ),
310 array( '%d' )
311 );
312
313 // Anonymize notes - use our own string for consistency with field values
314 $anon_text = __( '[Anonymized]', 'contact-forms' );
315 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
316 $wpdb->query( $wpdb->prepare(
317 "UPDATE `{$table_notes}` SET afsn_text = %s, afsn_user = %s WHERE afsn_sub_id = %d",
318 $anon_text,
319 $anon_text,
320 $submission_id
321 ) );
322 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
323
324 return true;
325 }
326
327 /**
328 * Delete uploaded files associated with a submission.
329 *
330 * @param int $submission_id The afs_id of the submission.
331 */
332 function accua_forms_delete_submission_files( $submission_id ) {
333 global $wpdb;
334 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
335
336 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
337 $file_fields = $wpdb->get_results( $wpdb->prepare(
338 "SELECT afsv_value FROM `{$table_values}` WHERE afsv_sub_id = %d AND afsv_type = 'file' AND afsv_value != ''",
339 $submission_id
340 ) );
341 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
342
343 if ( ! $file_fields ) {
344 return;
345 }
346
347 $upload_base = _accua_forms_get_abs_dest_path(
348 isset( get_option( 'accua_forms_default_file_field_data', array() )['dest_path'] )
349 ? get_option( 'accua_forms_default_file_field_data', array() )['dest_path']
350 : ''
351 );
352
353 foreach ( $file_fields as $file_field ) {
354 $filename = $file_field->afsv_value;
355 if ( empty( $filename ) ) {
356 continue;
357 }
358 // The value is the filename within the upload directory
359 $filepath = trailingslashit( $upload_base ) . $filename;
360 // Safety: only delete if within the upload directory
361 $real_upload = realpath( $upload_base );
362 $real_file = realpath( $filepath );
363 if ( $real_file && $real_upload && strpos( $real_file, $real_upload ) === 0 ) {
364 wp_delete_file( $real_file );
365 }
366 }
367 }
368
369 /**
370 * Find submission IDs for a given email address.
371 *
372 * Looks up submissions by matching email-type fields (afsv_type IN ('email', 'autoreply_email')).
373 *
374 * @param string $email_address Email to search for.
375 * @param int $page Page number (1-based).
376 * @param int $per_page Results per page.
377 * @return array Array of submission row objects (afs_id, afs_form_id).
378 */
379 function accua_forms_find_submissions_by_email( $email_address, $page = 1, $per_page = 50 ) {
380 global $wpdb;
381 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
382 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
383
384 $offset = ( $page - 1 ) * $per_page;
385
386 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
387 return $wpdb->get_results( $wpdb->prepare(
388 "SELECT DISTINCT s.afs_id, s.afs_form_id
389 FROM `{$table_subs}` s
390 INNER JOIN `{$table_values}` sv ON s.afs_id = sv.afsv_sub_id
391 WHERE sv.afsv_type IN ('email', 'autoreply_email')
392 AND sv.afsv_value = %s
393 AND s.afs_status >= 0
394 AND s.afs_anonymized = 0
395 ORDER BY s.afs_id ASC
396 LIMIT %d OFFSET %d",
397 $email_address,
398 $per_page,
399 $offset
400 ) );
401 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
402 }
403
404 /* -------------------------------------------------------------------------
405 * WordPress Privacy API - Personal Data Exporter
406 * ------------------------------------------------------------------------- */
407
408 add_filter( 'wp_privacy_personal_data_exporters', 'accua_forms_register_privacy_exporter' );
409 /**
410 * Register the Contact Forms personal data exporter.
411 *
412 * @param array $exporters Registered exporters.
413 * @return array
414 */
415 function accua_forms_register_privacy_exporter( $exporters ) {
416 $exporters['contact-forms'] = array(
417 'exporter_friendly_name' => __( 'Contact Forms Submissions', 'contact-forms' ),
418 'callback' => 'accua_forms_privacy_exporter',
419 );
420 return $exporters;
421 }
422
423 /**
424 * Export personal data for a given email address.
425 *
426 * @param string $email_address The email to export data for.
427 * @param int $page Page number.
428 * @return array Export data array with 'data' and 'done' keys.
429 */
430 function accua_forms_privacy_exporter( $email_address, $page = 1 ) {
431 global $wpdb;
432 $per_page = 50;
433 $export_items = array();
434 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
435 $table_values = esc_sql( $wpdb->prefix . 'accua_forms_submissions_values' );
436
437 $submissions = accua_forms_find_submissions_by_email( $email_address, $page, $per_page );
438
439 foreach ( $submissions as $sub ) {
440 $data = array();
441
442 // Get submission metadata
443 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
444 $meta = $wpdb->get_row( $wpdb->prepare(
445 "SELECT afs_ip, afs_uri, afs_referrer, afs_submitted, afs_stats FROM `{$table_subs}` WHERE afs_id = %d",
446 $sub->afs_id
447 ) );
448 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
449
450 if ( $meta ) {
451 if ( $meta->afs_ip !== '' ) {
452 $data[] = array(
453 'name' => __( 'IP Address', 'contact-forms' ),
454 'value' => $meta->afs_ip,
455 );
456 }
457 $data[] = array(
458 'name' => __( 'Submitted', 'contact-forms' ),
459 'value' => $meta->afs_submitted,
460 );
461 if ( $meta->afs_uri !== '' ) {
462 $data[] = array(
463 'name' => __( 'Page URL', 'contact-forms' ),
464 'value' => $meta->afs_uri,
465 );
466 }
467 if ( $meta->afs_referrer !== '' ) {
468 $data[] = array(
469 'name' => __( 'Referrer', 'contact-forms' ),
470 'value' => $meta->afs_referrer,
471 );
472 }
473 }
474
475 // Get all field values
476 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
477 $fields = $wpdb->get_results( $wpdb->prepare(
478 "SELECT afsv_field_id, afsv_value FROM `{$table_values}` WHERE afsv_sub_id = %d",
479 $sub->afs_id
480 ) );
481 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
482
483 foreach ( $fields as $field ) {
484 $data[] = array(
485 'name' => $field->afsv_field_id,
486 'value' => $field->afsv_value,
487 );
488 }
489
490 $export_items[] = array(
491 'group_id' => 'contact-form-submissions',
492 'group_label' => __( 'Contact Form Submissions', 'contact-forms' ),
493 'group_description' => __( 'Data submitted through contact forms on this site.', 'contact-forms' ),
494 'item_id' => "contact-form-submission-{$sub->afs_id}",
495 'data' => $data,
496 );
497 }
498
499 return array(
500 'data' => $export_items,
501 'done' => count( $submissions ) < $per_page,
502 );
503 }
504
505 /* -------------------------------------------------------------------------
506 * WordPress Privacy API - Personal Data Eraser
507 * ------------------------------------------------------------------------- */
508
509 add_filter( 'wp_privacy_personal_data_erasers', 'accua_forms_register_privacy_eraser' );
510 /**
511 * Register the Contact Forms personal data eraser.
512 *
513 * @param array $erasers Registered erasers.
514 * @return array
515 */
516 function accua_forms_register_privacy_eraser( $erasers ) {
517 $erasers['contact-forms'] = array(
518 'eraser_friendly_name' => __( 'Contact Forms Submissions', 'contact-forms' ),
519 'callback' => 'accua_forms_privacy_eraser',
520 );
521 return $erasers;
522 }
523
524 /**
525 * Erase personal data for a given email address.
526 *
527 * @param string $email_address The email to erase data for.
528 * @param int $page Page number.
529 * @return array Eraser response array.
530 */
531 function accua_forms_privacy_eraser( $email_address, $page = 1 ) {
532 $per_page = 50;
533 $items_removed = false;
534 $items_retained = false;
535 $messages = array();
536
537 $submissions = accua_forms_find_submissions_by_email( $email_address, $page, $per_page );
538
539 foreach ( $submissions as $sub ) {
540 $config = accua_forms_get_retention_config( $sub->afs_form_id );
541 $mode = $config['mode'];
542
543 if ( accua_forms_erase_submission( $sub->afs_id, $mode ) ) {
544 $items_removed = true;
545 }
546 }
547
548 return array(
549 'items_removed' => $items_removed,
550 'items_retained' => $items_retained,
551 'messages' => $messages,
552 'done' => count( $submissions ) < $per_page,
553 );
554 }
555
556 /* -------------------------------------------------------------------------
557 * WordPress Privacy API - Privacy Policy Suggestion
558 * ------------------------------------------------------------------------- */
559
560 add_action( 'admin_init', 'accua_forms_add_privacy_policy_content' );
561 /**
562 * Suggest privacy policy content for Contact Forms.
563 */
564 function accua_forms_add_privacy_policy_content() {
565 if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
566 return;
567 }
568
569 $content = '<h2>' . __( 'Contact Forms', 'contact-forms' ) . '</h2>' .
570 '<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>' .
571 '<p>' . __( 'If the form includes file upload fields, the uploaded files are stored on our server.', 'contact-forms' ) . '</p>' .
572 '<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>' .
573 '<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>';
574
575 wp_add_privacy_policy_content( 'Contact Forms', wp_kses_post( $content ) );
576 }
577
578 /* -------------------------------------------------------------------------
579 * Data Retention Settings - Resolution Helper
580 * ------------------------------------------------------------------------- */
581
582 /**
583 * Get the retention configuration for a specific form.
584 *
585 * Checks per-form override first, then falls back to global default.
586 *
587 * @param string $form_id Form ID.
588 * @return array {
589 * @type int $seconds Retention period in seconds (0 = no expiry).
590 * @type string $mode 'anonymize' or 'delete'.
591 * }
592 */
593 function accua_forms_get_retention_config( $form_id = '' ) {
594 $default = array(
595 'seconds' => 0,
596 'mode' => 'anonymize',
597 );
598
599 // Check per-form override
600 if ( $form_id !== '' ) {
601 $forms_data = get_option( 'accua_forms_saved_forms', array() );
602 if ( isset( $forms_data[ $form_id ] ) ) {
603 $form = $forms_data[ $form_id ];
604 if ( ! empty( $form['submission_retention_override'] ) ) {
605 $val = isset( $form['submission_retention_value'] ) ? (int) $form['submission_retention_value'] : 0;
606 $unit = isset( $form['submission_retention_unit'] ) ? $form['submission_retention_unit'] : 'months';
607 $mode = isset( $form['submission_retention_mode'] ) ? $form['submission_retention_mode'] : 'anonymize';
608 if ( $val > 0 ) {
609 return array(
610 'seconds' => accua_forms_retention_to_seconds( $val, $unit ),
611 'mode' => in_array( $mode, array( 'anonymize', 'delete' ), true ) ? $mode : 'anonymize',
612 );
613 }
614 return array( 'seconds' => 0, 'mode' => in_array( $mode, array( 'anonymize', 'delete' ), true ) ? $mode : 'anonymize' );
615 }
616 }
617 }
618
619 // Fall back to global setting
620 $retention_data = get_option( 'accua_forms_retention_data', array() );
621 $val = isset( $retention_data['retention_value'] ) ? (int) $retention_data['retention_value'] : 0;
622 $unit = isset( $retention_data['retention_unit'] ) ? $retention_data['retention_unit'] : 'months';
623 $mode = isset( $retention_data['retention_mode'] ) ? $retention_data['retention_mode'] : 'anonymize';
624
625 if ( $val > 0 ) {
626 return array(
627 'seconds' => accua_forms_retention_to_seconds( $val, $unit ),
628 'mode' => in_array( $mode, array( 'anonymize', 'delete' ), true ) ? $mode : 'anonymize',
629 );
630 }
631
632 return $default;
633 }
634
635 /**
636 * Convert a retention value + unit to seconds.
637 *
638 * @param int $value Retention value.
639 * @param string $unit 'days', 'months', or 'years'.
640 * @return int Seconds.
641 */
642 function accua_forms_retention_to_seconds( $value, $unit ) {
643 $value = max( 0, (int) $value );
644 switch ( $unit ) {
645 case 'days':
646 return $value * DAY_IN_SECONDS;
647 case 'years':
648 return $value * YEAR_IN_SECONDS;
649 case 'months':
650 default:
651 return $value * 30 * DAY_IN_SECONDS;
652 }
653 }
654
655 /* -------------------------------------------------------------------------
656 * Data Retention - WP-Cron Cleanup Handler
657 * ------------------------------------------------------------------------- */
658
659 add_action( 'accua_forms_retention_cleanup', 'accua_forms_retention_cleanup_handler' );
660 /**
661 * Cron callback: anonymize or delete expired submissions.
662 */
663 function accua_forms_retention_cleanup_handler() {
664 global $wpdb;
665 $table_subs = esc_sql( $wpdb->prefix . 'accua_forms_submissions' );
666
667 $forms_data = get_option( 'accua_forms_saved_forms', array() );
668 if ( ! is_array( $forms_data ) ) {
669 return;
670 }
671
672 // Collect all unique form IDs that have submissions (including deleted forms)
673 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
674 $form_ids = $wpdb->get_col( "SELECT DISTINCT afs_form_id FROM `{$table_subs}` WHERE afs_status >= 0" );
675
676 foreach ( $form_ids as $form_id ) {
677 $config = accua_forms_get_retention_config( $form_id );
678 if ( $config['seconds'] <= 0 ) {
679 continue;
680 }
681
682 $cutoff = gmdate( 'Y-m-d H:i:s', time() - $config['seconds'] );
683
684 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names from $wpdb->prefix, escaped with esc_sql()
685 $expired_ids = $wpdb->get_col( $wpdb->prepare(
686 "SELECT afs_id FROM `{$table_subs}`
687 WHERE afs_form_id = %s
688 AND afs_submitted < %s
689 AND afs_status >= 0
690 AND ( %s = 'delete' OR afs_anonymized = 0 )
691 ORDER BY afs_id ASC
692 LIMIT 100",
693 $form_id,
694 $cutoff,
695 $config['mode']
696 ) );
697 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
698
699 foreach ( $expired_ids as $sub_id ) {
700 accua_forms_erase_submission( (int) $sub_id, $config['mode'] );
701 }
702 }
703 }
704
705 /**
706 * Self-healing: ensure the retention cron is scheduled.
707 */
708 add_action( 'admin_init', 'accua_forms_ensure_retention_cron' );
709 function accua_forms_ensure_retention_cron() {
710 if ( ! wp_next_scheduled( 'accua_forms_retention_cleanup' ) ) {
711 wp_schedule_event( time(), 'daily', 'accua_forms_retention_cleanup' );
712 }
713 }
714