| 1 |
<?php |
| 2 |
namespace ABlocksCookieConsent; |
| 3 |
|
| 4 |
if ( ! defined( 'ABSPATH' ) ) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
/** |
| 9 |
* Admin-side plumbing for the settings screen. |
| 10 |
* |
| 11 |
* The dashboard app already speaks admin-ajax with a shared nonce, so this |
| 12 |
* follows that rather than introducing a second convention for one screen. |
| 13 |
*/ |
| 14 |
class Ajax { |
| 15 |
|
| 16 |
public static function init() { |
| 17 |
$self = new self(); |
| 18 |
add_action( 'wp_ajax_ablocks/cookie_consent/get_settings', [ $self, 'get_settings' ] ); |
| 19 |
add_action( 'wp_ajax_ablocks/cookie_consent/save_settings', [ $self, 'save_settings' ] ); |
| 20 |
add_action( 'wp_ajax_ablocks/cookie_consent/get_report', [ $self, 'get_report' ] ); |
| 21 |
add_action( 'wp_ajax_ablocks/cookie_consent/clear_report', [ $self, 'clear_report' ] ); |
| 22 |
add_action( 'wp_ajax_ablocks/cookie_consent/get_records', [ $self, 'get_records' ] ); |
| 23 |
add_action( 'wp_ajax_ablocks/cookie_consent/delete_records', [ $self, 'delete_records' ] ); |
| 24 |
} |
| 25 |
|
| 26 |
/** |
| 27 |
* Every handler calls this first. |
| 28 |
* |
| 29 |
* The nonce sniff cannot see through a helper, so the `$_POST` reads below |
| 30 |
* are flagged despite always running after `check_ajax_referer`. |
| 31 |
* |
| 32 |
* phpcs:disable WordPress.Security.NonceVerification.Missing |
| 33 |
*/ |
| 34 |
private function guard() { |
| 35 |
check_ajax_referer( 'ablocks_nonce', 'security' ); |
| 36 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 37 |
wp_send_json_error( __( 'You are not allowed to do that.', 'ablocks' ), 403 ); |
| 38 |
} |
| 39 |
} |
| 40 |
|
| 41 |
public function get_settings() { |
| 42 |
$this->guard(); |
| 43 |
wp_send_json_success( Helper::get_settings() ); |
| 44 |
} |
| 45 |
|
| 46 |
public function save_settings() { |
| 47 |
$this->guard(); |
| 48 |
|
| 49 |
$defaults = Helper::defaults(); |
| 50 |
$saved = []; |
| 51 |
|
| 52 |
foreach ( [ 'enabled', 'buffer_gating', 'embed_gating', 'dry_run', 'consent_mode', 'consent_mode_ads', 'record_enabled', 'record_ip', 'hide_for_admins' ] as $key ) { |
| 53 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 54 |
$saved[ $key ] = isset( $_POST[ $key ] ) ? \ABlocks\Helper::sanitize_checkbox_field( $_POST[ $key ] ) : $defaults[ $key ]; |
| 55 |
} |
| 56 |
|
| 57 |
foreach ( [ 'policy_version', 'cookie_days', 'reconsent_days', 'consent_mode_wait', 'record_retention_days' ] as $key ) { |
| 58 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 59 |
$saved[ $key ] = isset( $_POST[ $key ] ) ? absint( $_POST[ $key ] ) : $defaults[ $key ]; |
| 60 |
} |
| 61 |
|
| 62 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 63 |
$mode = isset( $_POST['mode'] ) ? sanitize_key( $_POST['mode'] ) : $defaults['mode']; |
| 64 |
$saved['mode'] = in_array( $mode, [ 'optin', 'notice' ], true ) ? $mode : 'optin'; |
| 65 |
|
| 66 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 67 |
// Measurement IDs. Kept to the characters Google and Meta issue, so a |
| 68 |
// pasted snippet or a stray quote cannot reach the head; the printer |
| 69 |
// checks the full shape again before writing anything. |
| 70 |
foreach ( [ 'tag_gtm', 'tag_ga4', 'tag_meta_pixel' ] as $key ) { |
| 71 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 72 |
$value = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; |
| 73 |
$saved[ $key ] = preg_replace( '/[^A-Za-z0-9\-]/', '', $value ); |
| 74 |
} |
| 75 |
|
| 76 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 77 |
$mode = isset( $_POST['cookie_name_mode'] ) ? sanitize_key( $_POST['cookie_name_mode'] ) : ''; |
| 78 |
$saved['cookie_name_mode'] = 'custom' === $mode ? 'custom' : 'default'; |
| 79 |
|
| 80 |
// On 'default' the field is not even shown, so whatever the browser |
| 81 |
// last sent for it is ignored rather than trusted. |
| 82 |
if ( 'custom' === $saved['cookie_name_mode'] ) { |
| 83 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 84 |
$cookie_name = isset( $_POST['cookie_name'] ) ? sanitize_text_field( wp_unslash( $_POST['cookie_name'] ) ) : ''; |
| 85 |
$cookie_name = preg_replace( '/[^A-Za-z0-9_\-]/', '', $cookie_name ); |
| 86 |
$saved['cookie_name'] = $cookie_name ? $cookie_name : $defaults['cookie_name']; |
| 87 |
} else { |
| 88 |
$saved['cookie_name'] = $defaults['cookie_name']; |
| 89 |
} |
| 90 |
|
| 91 |
// Renaming the cookie would otherwise make every decision already given |
| 92 |
// unreadable — the banner would come back for the whole audience and |
| 93 |
// the old cookie would sit in their browser until its own expiry. |
| 94 |
// Remembering the previous name lets the client carry the decision |
| 95 |
// across once. One hop only: rename twice before visitors return and |
| 96 |
// the older one is genuinely gone. |
| 97 |
$current = Helper::get( 'cookie_name', $defaults['cookie_name'] ); |
| 98 |
$saved['cookie_name_previous'] = $saved['cookie_name'] === $current |
| 99 |
? Helper::get( 'cookie_name_previous', '' ) |
| 100 |
: $current; |
| 101 |
|
| 102 |
$saved['categories'] = $this->sanitize_categories( $this->post_json( 'categories' ), $defaults['categories'] ); |
| 103 |
$saved['rules'] = $this->sanitize_rules( $this->post_json( 'rules' ) ); |
| 104 |
|
| 105 |
// Embeds carry no pattern: the shipped list is the whole list and only |
| 106 |
// the two decisions the screen offers can come back from the browser. |
| 107 |
// Anything else in the payload is dropped rather than merged, so a |
| 108 |
// crafted request cannot introduce a matcher of its own. |
| 109 |
$saved['embed_rules'] = $this->sanitize_provider_rules( $this->post_json( 'embed_rules' ) ); |
| 110 |
$saved['pixel_rules'] = $this->sanitize_provider_rules( $this->post_json( 'pixel_rules' ) ); |
| 111 |
$saved['banner'] = $this->sanitize_banner( $this->post_json( 'banner' ), $defaults['banner'] ); |
| 112 |
|
| 113 |
Helper::save_settings( $saved ); |
| 114 |
|
| 115 |
// A table that was never created — the addon enabled before this |
| 116 |
// version shipped, say — is created on first save rather than leaving |
| 117 |
// recording silently broken. |
| 118 |
if ( $saved['record_enabled'] && Helper::can( 'records' ) && ! Database::table_exists() ) { |
| 119 |
Database::create_table(); |
| 120 |
} |
| 121 |
|
| 122 |
wp_send_json_success( Helper::get_settings() ); |
| 123 |
} |
| 124 |
|
| 125 |
public function get_report() { |
| 126 |
$this->guard(); |
| 127 |
wp_send_json_success( |
| 128 |
[ |
| 129 |
'entries' => Report::get(), |
| 130 |
'dry_run' => (bool) Helper::get( 'dry_run', false ), |
| 131 |
] |
| 132 |
); |
| 133 |
} |
| 134 |
|
| 135 |
public function clear_report() { |
| 136 |
$this->guard(); |
| 137 |
Report::clear(); |
| 138 |
wp_send_json_success( [ 'entries' => [] ] ); |
| 139 |
} |
| 140 |
|
| 141 |
public function get_records() { |
| 142 |
$this->guard(); |
| 143 |
if ( ! Helper::can( 'records' ) ) { |
| 144 |
wp_send_json_success( |
| 145 |
[ |
| 146 |
'records' => [], |
| 147 |
'total' => 0, |
| 148 |
'per_page' => 20, |
| 149 |
'locked' => true, |
| 150 |
] |
| 151 |
); |
| 152 |
} |
| 153 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash |
| 154 |
$page = isset( $_POST['page'] ) ? max( 1, absint( $_POST['page'] ) ) : 1; |
| 155 |
$per = 20; |
| 156 |
|
| 157 |
wp_send_json_success( |
| 158 |
[ |
| 159 |
'records' => Record::recent( $per, ( $page - 1 ) * $per ), |
| 160 |
'total' => Record::count(), |
| 161 |
'per_page' => $per, |
| 162 |
] |
| 163 |
); |
| 164 |
} |
| 165 |
|
| 166 |
public function delete_records() { |
| 167 |
$this->guard(); |
| 168 |
if ( ! Helper::can( 'records' ) ) { |
| 169 |
wp_send_json_error( __( 'Consent records are a Pro feature.', 'ablocks' ), 403 ); |
| 170 |
} |
| 171 |
Record::delete_all(); |
| 172 |
wp_send_json_success( [ 'total' => 0 ] ); |
| 173 |
} |
| 174 |
|
| 175 |
/** |
| 176 |
* Read one POST field that the dashboard sent as JSON. |
| 177 |
* |
| 178 |
* @param string $key Field name. |
| 179 |
* @return array |
| 180 |
*/ |
| 181 |
private function post_json( $key ) { |
| 182 |
if ( ! isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() ran first. |
| 183 |
return []; |
| 184 |
} |
| 185 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing |
| 186 |
$decoded = json_decode( wp_unslash( $_POST[ $key ] ), true ); |
| 187 |
return is_array( $decoded ) ? $decoded : []; |
| 188 |
} |
| 189 |
|
| 190 |
// phpcs:enable WordPress.Security.NonceVerification.Missing |
| 191 |
|
| 192 |
/** |
| 193 |
* @param array $input Submitted categories. |
| 194 |
* @param array $defaults Shipped categories. |
| 195 |
* @return array |
| 196 |
*/ |
| 197 |
private function sanitize_categories( $input, $defaults ) { |
| 198 |
if ( empty( $input ) ) { |
| 199 |
return $defaults; |
| 200 |
} |
| 201 |
|
| 202 |
$locked_slugs = wp_list_pluck( array_filter( $defaults, function ( $c ) { |
| 203 |
return ! empty( $c['locked'] ); |
| 204 |
} ), 'slug' ); |
| 205 |
|
| 206 |
$clean = []; |
| 207 |
foreach ( $input as $category ) { |
| 208 |
$slug = isset( $category['slug'] ) ? sanitize_key( $category['slug'] ) : ''; |
| 209 |
if ( '' === $slug ) { |
| 210 |
continue; |
| 211 |
} |
| 212 |
$is_locked = in_array( $slug, $locked_slugs, true ); |
| 213 |
$clean[] = [ |
| 214 |
'slug' => $slug, |
| 215 |
'label' => isset( $category['label'] ) ? sanitize_text_field( $category['label'] ) : $slug, |
| 216 |
'description' => isset( $category['description'] ) ? sanitize_textarea_field( $category['description'] ) : '', |
| 217 |
// The locked flag is not the submitter's to set: a category |
| 218 |
// that cannot be refused is a decision about the site, not a |
| 219 |
// field on a form. |
| 220 |
'locked' => $is_locked, |
| 221 |
'enabled' => $is_locked ? true : ! empty( $category['enabled'] ), |
| 222 |
'cookies' => $this->sanitize_cookies( isset( $category['cookies'] ) ? $category['cookies'] : [] ), |
| 223 |
]; |
| 224 |
} |
| 225 |
|
| 226 |
return $clean ? $clean : $defaults; |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* The per-category cookie disclosure table. |
| 231 |
* |
| 232 |
* Every field is plain text shown to a visitor, so all four go through the |
| 233 |
* same sanitiser. A row with no name is dropped rather than kept as an |
| 234 |
* empty line: the table is a disclosure, and a blank row discloses nothing |
| 235 |
* while looking like it does. |
| 236 |
* |
| 237 |
* The cap is there because this list is printed on every page of the site. |
| 238 |
* |
| 239 |
* @param array $input Submitted cookie rows. |
| 240 |
* @return array |
| 241 |
*/ |
| 242 |
private function sanitize_cookies( $input ) { |
| 243 |
$clean = []; |
| 244 |
foreach ( (array) $input as $cookie ) { |
| 245 |
if ( ! is_array( $cookie ) ) { |
| 246 |
continue; |
| 247 |
} |
| 248 |
$name = isset( $cookie['name'] ) ? sanitize_text_field( $cookie['name'] ) : ''; |
| 249 |
if ( '' === $name ) { |
| 250 |
continue; |
| 251 |
} |
| 252 |
$clean[] = [ |
| 253 |
'name' => $name, |
| 254 |
'provider' => isset( $cookie['provider'] ) ? sanitize_text_field( $cookie['provider'] ) : '', |
| 255 |
'duration' => isset( $cookie['duration'] ) ? sanitize_text_field( $cookie['duration'] ) : '', |
| 256 |
'purpose' => isset( $cookie['purpose'] ) ? sanitize_text_field( $cookie['purpose'] ) : '', |
| 257 |
]; |
| 258 |
if ( count( $clean ) >= 50 ) { |
| 259 |
break; |
| 260 |
} |
| 261 |
} |
| 262 |
return $clean; |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* @param array $input Submitted rules. |
| 267 |
* @return array |
| 268 |
*/ |
| 269 |
/** |
| 270 |
* Embed and pixel overrides: an id, whether it is on, and which category. |
| 271 |
* |
| 272 |
* Nothing else survives. The pattern that decides what a rule matches is |
| 273 |
* not editable and is never read from the request, which is the reason a |
| 274 |
* mis-typed setting here cannot blank a payment iframe. |
| 275 |
* |
| 276 |
* @param array $rules Raw rules from the request. |
| 277 |
* @return array |
| 278 |
*/ |
| 279 |
private function sanitize_provider_rules( $rules ) { |
| 280 |
if ( ! is_array( $rules ) ) { |
| 281 |
return []; |
| 282 |
} |
| 283 |
|
| 284 |
$clean = []; |
| 285 |
foreach ( $rules as $rule ) { |
| 286 |
if ( empty( $rule['id'] ) ) { |
| 287 |
continue; |
| 288 |
} |
| 289 |
$clean[] = [ |
| 290 |
'id' => sanitize_key( $rule['id'] ), |
| 291 |
'enabled' => ! empty( $rule['enabled'] ), |
| 292 |
'category' => isset( $rule['category'] ) ? sanitize_key( $rule['category'] ) : '', |
| 293 |
]; |
| 294 |
} |
| 295 |
|
| 296 |
return $clean; |
| 297 |
} |
| 298 |
|
| 299 |
private function sanitize_rules( $input ) { |
| 300 |
$can_author = Helper::can( 'custom_rules' ); |
| 301 |
$shipped = Helper::shipped_rule_ids(); |
| 302 |
$defaults = []; |
| 303 |
foreach ( Helper::default_rules() as $rule ) { |
| 304 |
$defaults[ $rule['id'] ] = $rule; |
| 305 |
} |
| 306 |
|
| 307 |
$clean = []; |
| 308 |
foreach ( (array) $input as $rule ) { |
| 309 |
$id = isset( $rule['id'] ) ? sanitize_key( $rule['id'] ) : ''; |
| 310 |
if ( '' === $id ) { |
| 311 |
$id = 'rule-' . substr( md5( wp_json_encode( $rule ) ), 0, 8 ); |
| 312 |
} |
| 313 |
|
| 314 |
// Without Pro, only the shipped rules survive, and only their |
| 315 |
// on/off state and category are the submitter's to change. The |
| 316 |
// patterns come back from the defaults, so a crafted request |
| 317 |
// cannot author a matcher the UI does not offer. |
| 318 |
if ( ! $can_author ) { |
| 319 |
if ( ! in_array( $id, $shipped, true ) ) { |
| 320 |
continue; |
| 321 |
} |
| 322 |
$clean[] = array_merge( |
| 323 |
$defaults[ $id ], |
| 324 |
[ |
| 325 |
'category' => isset( $rule['category'] ) ? sanitize_key( $rule['category'] ) : $defaults[ $id ]['category'], |
| 326 |
'enabled' => ! empty( $rule['enabled'] ), |
| 327 |
] |
| 328 |
); |
| 329 |
continue; |
| 330 |
} |
| 331 |
|
| 332 |
$clean[] = [ |
| 333 |
'id' => $id, |
| 334 |
'label' => isset( $rule['label'] ) ? sanitize_text_field( $rule['label'] ) : $id, |
| 335 |
'category' => isset( $rule['category'] ) ? sanitize_key( $rule['category'] ) : '', |
| 336 |
// Patterns are regular expressions, so they cannot be run |
| 337 |
// through a sanitiser that strips punctuation. They are only |
| 338 |
// ever used as the body of a preg_match, never echoed |
| 339 |
// unescaped, and a malformed one fails closed in Gating. |
| 340 |
'src' => isset( $rule['src'] ) ? wp_strip_all_tags( (string) $rule['src'] ) : '', |
| 341 |
'inline' => isset( $rule['inline'] ) ? wp_strip_all_tags( (string) $rule['inline'] ) : '', |
| 342 |
'handles' => isset( $rule['handles'] ) ? sanitize_text_field( $rule['handles'] ) : '', |
| 343 |
'enabled' => ! empty( $rule['enabled'] ), |
| 344 |
]; |
| 345 |
}//end foreach |
| 346 |
return $clean; |
| 347 |
} |
| 348 |
|
| 349 |
/** |
| 350 |
* @param array $input Submitted banner settings. |
| 351 |
* @param array $defaults Shipped banner settings. |
| 352 |
* @return array |
| 353 |
*/ |
| 354 |
private function sanitize_banner( $input, $defaults ) { |
| 355 |
$clean = []; |
| 356 |
|
| 357 |
foreach ( $defaults as $key => $default ) { |
| 358 |
if ( ! array_key_exists( $key, $input ) ) { |
| 359 |
$clean[ $key ] = $default; |
| 360 |
continue; |
| 361 |
} |
| 362 |
$value = $input[ $key ]; |
| 363 |
|
| 364 |
if ( is_bool( $default ) ) { |
| 365 |
$clean[ $key ] = (bool) filter_var( $value, FILTER_VALIDATE_BOOLEAN ); |
| 366 |
} elseif ( is_int( $default ) ) { |
| 367 |
$clean[ $key ] = absint( $value ); |
| 368 |
} elseif ( in_array( $key, [ 'policy_url', 'cookie_policy_url' ], true ) ) { |
| 369 |
$clean[ $key ] = esc_url_raw( $value ); |
| 370 |
} elseif ( in_array( $key, [ 'message', 'prefs_intro' ], true ) ) { |
| 371 |
$clean[ $key ] = wp_kses_post( $value ); |
| 372 |
} else { |
| 373 |
$clean[ $key ] = sanitize_text_field( $value ); |
| 374 |
} |
| 375 |
} |
| 376 |
|
| 377 |
$clean['layout'] = in_array( $clean['layout'], [ 'bar', 'box', 'popup' ], true ) ? $clean['layout'] : 'bar'; |
| 378 |
$clean['position'] = in_array( $clean['position'], [ 'bottom', 'top', 'bottom-left', 'bottom-right', 'center' ], true ) ? $clean['position'] : 'bottom'; |
| 379 |
$clean['reopen_position'] = in_array( $clean['reopen_position'], [ 'bottom-left', 'bottom-right' ], true ) ? $clean['reopen_position'] : 'bottom-left'; |
| 380 |
$clean['prefs_layout'] = in_array( $clean['prefs_layout'], [ 'inline', 'modal' ], true ) ? $clean['prefs_layout'] : 'inline'; |
| 381 |
$clean['settings_style'] = in_array( $clean['settings_style'], [ 'link', 'outline', 'solid' ], true ) ? $clean['settings_style'] : 'link'; |
| 382 |
$clean['close_behaviour'] = in_array( $clean['close_behaviour'], [ 'dismiss', 'reject' ], true ) ? $clean['close_behaviour'] : 'dismiss'; |
| 383 |
$clean['dismiss_days'] = min( 365, (int) $clean['dismiss_days'] ); |
| 384 |
$clean['locked_style'] = in_array( $clean['locked_style'], [ 'text', 'badge' ], true ) ? $clean['locked_style'] : 'text'; |
| 385 |
$clean['switch_style'] = in_array( $clean['switch_style'], [ 'switch', 'checkbox' ], true ) ? $clean['switch_style'] : 'switch'; |
| 386 |
|
| 387 |
// A modal that is narrower than the banner it opens from is a modal |
| 388 |
// nobody asked for; a bar's 1180px is not a dialog width either. |
| 389 |
$clean['prefs_max_width'] = min( 1000, max( 320, (int) $clean['prefs_max_width'] ) ); |
| 390 |
|
| 391 |
return $clean; |
| 392 |
} |
| 393 |
} |
| 394 |
|