| 1 |
<?php |
| 2 |
|
| 3 |
defined('ABSPATH') || die(); |
| 4 |
|
| 5 |
/** |
| 6 |
* Hooks form entries into WordPress' own privacy tools. |
| 7 |
* |
| 8 |
* Entries hold exactly the kind of thing a subject access or erasure request |
| 9 |
* is about - names, email addresses, whatever else the form asked for, plus |
| 10 |
* the submitter's ip address - and none of it was reachable from Tools > |
| 11 |
* Export Personal Data or Erase Personal Data. An administrator answering a |
| 12 |
* request had to go through the Entries screen by hand and hope they had |
| 13 |
* found every form. |
| 14 |
* |
| 15 |
* Entries are matched on the email address the request names: the submitter's |
| 16 |
* account email when they were signed in, or any email field on the form. |
| 17 |
* Both exporters and erasers page through the data, as core requires, so a |
| 18 |
* form with a large number of entries does not exhaust memory. |
| 19 |
*/ |
| 20 |
class HashFormPrivacy { |
| 21 |
|
| 22 |
/** Entries handled per batch. Core calls back until done is true. */ |
| 23 |
const PER_PAGE = 50; |
| 24 |
|
| 25 |
public function __construct() { |
| 26 |
add_filter('wp_privacy_personal_data_exporters', array($this, 'register_exporter')); |
| 27 |
add_filter('wp_privacy_personal_data_erasers', array($this, 'register_eraser')); |
| 28 |
/* |
| 29 |
* wp_add_privacy_policy_content() rather than the |
| 30 |
* wp_get_default_privacy_policy_content filter, which has been |
| 31 |
* deprecated since WordPress 5.7 and emits a notice. It must run on |
| 32 |
* admin_init, which is where core collects the suggested text. |
| 33 |
*/ |
| 34 |
add_action('admin_init', array($this, 'add_privacy_policy_content')); |
| 35 |
} |
| 36 |
|
| 37 |
public function register_exporter($exporters) { |
| 38 |
$exporters['hash-form'] = array( |
| 39 |
'exporter_friendly_name' => esc_html__('Hash Form entries', 'hash-form'), |
| 40 |
'callback' => array($this, 'export'), |
| 41 |
); |
| 42 |
|
| 43 |
return $exporters; |
| 44 |
} |
| 45 |
|
| 46 |
public function register_eraser($erasers) { |
| 47 |
$erasers['hash-form'] = array( |
| 48 |
'eraser_friendly_name' => esc_html__('Hash Form entries', 'hash-form'), |
| 49 |
'callback' => array($this, 'erase'), |
| 50 |
); |
| 51 |
|
| 52 |
return $erasers; |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Entry ids belonging to an email address, one page at a time. |
| 57 |
* |
| 58 |
* @param string $email |
| 59 |
* @param int $page 1-based, as core numbers them. |
| 60 |
* @return int[] |
| 61 |
*/ |
| 62 |
private function find_entry_ids($email, $page) { |
| 63 |
global $wpdb; |
| 64 |
|
| 65 |
$offset = (max(1, (int) $page) - 1) * self::PER_PAGE; |
| 66 |
$user = get_user_by('email', $email); |
| 67 |
$user_id = $user ? (int) $user->ID : 0; |
| 68 |
|
| 69 |
/* |
| 70 |
* Two ways an entry can belong to someone: it was submitted while |
| 71 |
* they were signed in, or one of the form's email fields holds their |
| 72 |
* address. The union covers both without returning an entry twice. |
| 73 |
*/ |
| 74 |
$sql = "SELECT DISTINCT e.id |
| 75 |
FROM {$wpdb->prefix}hashform_entries AS e |
| 76 |
LEFT JOIN {$wpdb->prefix}hashform_entry_meta AS m ON m.item_id = e.id |
| 77 |
LEFT JOIN {$wpdb->prefix}hashform_fields AS f ON f.id = m.field_id |
| 78 |
WHERE (f.type = 'email' AND m.meta_value = %s)"; |
| 79 |
|
| 80 |
$args = array($email); |
| 81 |
|
| 82 |
if ($user_id) { |
| 83 |
$sql .= ' OR e.user_id = %d'; |
| 84 |
$args[] = $user_id; |
| 85 |
} |
| 86 |
|
| 87 |
$sql .= ' ORDER BY e.id ASC LIMIT %d OFFSET %d'; |
| 88 |
$args[] = self::PER_PAGE; |
| 89 |
$args[] = $offset; |
| 90 |
|
| 91 |
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is assembled just above from literals and %s/%d placeholders only; every value is bound through $args. |
| 92 |
return array_map('absint', $wpdb->get_col($wpdb->prepare($sql, $args))); |
| 93 |
} |
| 94 |
|
| 95 |
/** |
| 96 |
* Everything stored against this person's entries, in core's export shape. |
| 97 |
*/ |
| 98 |
public function export($email, $page = 1) { |
| 99 |
$entry_ids = $this->find_entry_ids($email, $page); |
| 100 |
$export_items = array(); |
| 101 |
|
| 102 |
foreach ($entry_ids as $entry_id) { |
| 103 |
$entry = HashFormEntry::get_entry_vars($entry_id); |
| 104 |
|
| 105 |
if (!$entry) { |
| 106 |
continue; |
| 107 |
} |
| 108 |
|
| 109 |
$data = array( |
| 110 |
array( |
| 111 |
'name' => esc_html__('Entry ID', 'hash-form'), |
| 112 |
'value' => $entry->id, |
| 113 |
), |
| 114 |
array( |
| 115 |
'name' => esc_html__('Form', 'hash-form'), |
| 116 |
'value' => $entry->form_name, |
| 117 |
), |
| 118 |
array( |
| 119 |
'name' => esc_html__('Submitted', 'hash-form'), |
| 120 |
'value' => $entry->created_at, |
| 121 |
), |
| 122 |
); |
| 123 |
|
| 124 |
if (!empty($entry->ip)) { |
| 125 |
$data[] = array( |
| 126 |
'name' => esc_html__('IP address', 'hash-form'), |
| 127 |
'value' => $entry->ip, |
| 128 |
); |
| 129 |
} |
| 130 |
|
| 131 |
foreach ((array) $entry->metas as $meta) { |
| 132 |
$value = HashFormHelper::unserialize_or_decode($meta['value']); |
| 133 |
|
| 134 |
if (is_array($value)) { |
| 135 |
$value = implode(', ', array_filter(array_map('strval', $value))); |
| 136 |
} |
| 137 |
|
| 138 |
$data[] = array( |
| 139 |
'name' => $meta['name'], |
| 140 |
'value' => $value, |
| 141 |
); |
| 142 |
} |
| 143 |
|
| 144 |
$export_items[] = array( |
| 145 |
'group_id' => 'hashform-entries', |
| 146 |
'group_label' => esc_html__('Form entries', 'hash-form'), |
| 147 |
'group_description' => esc_html__('Entries submitted through Hash Form.', 'hash-form'), |
| 148 |
'item_id' => 'hashform-entry-' . $entry->id, |
| 149 |
'data' => $data, |
| 150 |
); |
| 151 |
} |
| 152 |
|
| 153 |
return array( |
| 154 |
'data' => $export_items, |
| 155 |
// Fewer than a full page means this was the last one. |
| 156 |
'done' => count($entry_ids) < self::PER_PAGE, |
| 157 |
); |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Delete this person's entries, uploaded files included. |
| 162 |
*/ |
| 163 |
public function erase($email, $page = 1) { |
| 164 |
$entry_ids = $this->find_entry_ids($email, $page); |
| 165 |
$removed = 0; |
| 166 |
$messages = array(); |
| 167 |
|
| 168 |
foreach ($entry_ids as $entry_id) { |
| 169 |
// Takes the entry's meta rows and its uploaded files with it. |
| 170 |
if (HashFormEntry::destroy_entry($entry_id)) { |
| 171 |
$removed++; |
| 172 |
} else { |
| 173 |
/* translators: %d: entry id. */ |
| 174 |
$messages[] = sprintf(esc_html__('Entry %d could not be removed.', 'hash-form'), $entry_id); |
| 175 |
} |
| 176 |
} |
| 177 |
|
| 178 |
return array( |
| 179 |
'items_removed' => $removed, |
| 180 |
'items_retained' => false, |
| 181 |
'messages' => $messages, |
| 182 |
'done' => count($entry_ids) < self::PER_PAGE, |
| 183 |
); |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Suggested wording for the site's privacy policy. |
| 188 |
* |
| 189 |
* Registered through wp_add_privacy_policy_content(), which is what |
| 190 |
* replaced the filter this used to hook. |
| 191 |
*/ |
| 192 |
public function add_privacy_policy_content() { |
| 193 |
if (!function_exists('wp_add_privacy_policy_content')) { |
| 194 |
return; |
| 195 |
} |
| 196 |
|
| 197 |
$content = '<p>' . esc_html__('When you submit a form on this site, the answers you give are stored so we can respond to you. Depending on the form, this may include your name, email address, and anything else the form asks for.', 'hash-form') . '</p>' |
| 198 |
. '<p>' . esc_html__('We also record the date of your submission and the IP address it came from, which helps us detect automated abuse.', 'hash-form') . '</p>' |
| 199 |
. '<p>' . esc_html__('You can ask us for a copy of the form submissions we hold about you, or ask us to delete them.', 'hash-form') . '</p>'; |
| 200 |
|
| 201 |
wp_add_privacy_policy_content(esc_html__('Hash Form', 'hash-form'), wp_kses_post($content)); |
| 202 |
} |
| 203 |
|
| 204 |
} |
| 205 |
|
| 206 |
new HashFormPrivacy(); |
| 207 |
|