banner.php
489 lines
| 1 | <?php |
| 2 | namespace Wpmet\Libs; |
| 3 | |
| 4 | defined( 'ABSPATH' ) || exit; |
| 5 | |
| 6 | if(!class_exists('\Wpmet\Libs\Banner')): |
| 7 | |
| 8 | class Banner { |
| 9 | |
| 10 | protected $script_version = '2.2.0'; |
| 11 | |
| 12 | protected $key = 'wpmet_banner'; |
| 13 | protected $data; |
| 14 | protected $last_check; |
| 15 | protected $check_interval = (3600 * 6); |
| 16 | |
| 17 | protected $plugin_screens; |
| 18 | |
| 19 | protected $text_domain; |
| 20 | protected $filter_string; |
| 21 | protected $filter_array = []; |
| 22 | protected $api_url; |
| 23 | |
| 24 | /** |
| 25 | * Content types the remote feed is allowed to ask us to render. |
| 26 | */ |
| 27 | const ALLOWED_TYPES = ['banner', 'notice']; |
| 28 | |
| 29 | /** |
| 30 | * Max length for values that end up as option/transient/user-meta keys. |
| 31 | */ |
| 32 | const MAX_KEY_LENGTH = 64; |
| 33 | |
| 34 | |
| 35 | public function get_version(){ |
| 36 | return $this->script_version; |
| 37 | } |
| 38 | |
| 39 | public function get_script_location(){ |
| 40 | return __FILE__; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * URL schemes accepted from the remote feed. |
| 45 | * |
| 46 | * Deliberately narrower than wp_allowed_protocols() -- a promo banner has |
| 47 | * no reason to emit mailto:, tel: or feed: links. |
| 48 | */ |
| 49 | public static function allowed_protocols() { |
| 50 | return ['http', 'https']; |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * HTML the remote feed is allowed to emit. |
| 55 | * |
| 56 | * Intentionally tighter than \ShopEngine\Utils\Helper::get_kses_array(): |
| 57 | * no iframe, no form elements, no data-* passthrough. Everything here |
| 58 | * arrives from a remote endpoint, so it is untrusted input -- if that |
| 59 | * endpoint is ever compromised the worst it can produce is broken markup, |
| 60 | * never script execution. Event handler attributes (on*) are dropped |
| 61 | * automatically because wp_kses() strips every attribute not listed. |
| 62 | */ |
| 63 | public static function allowed_html() { |
| 64 | |
| 65 | $common = [ |
| 66 | 'class' => [], |
| 67 | 'style' => [], |
| 68 | 'title' => [], |
| 69 | ]; |
| 70 | |
| 71 | return [ |
| 72 | 'a' => array_merge($common, ['href' => [], 'target' => [], 'rel' => []]), |
| 73 | 'b' => $common, |
| 74 | 'br' => [], |
| 75 | 'div' => $common, |
| 76 | 'em' => $common, |
| 77 | 'h1' => $common, |
| 78 | 'h2' => $common, |
| 79 | 'h3' => $common, |
| 80 | 'h4' => $common, |
| 81 | 'h5' => $common, |
| 82 | 'h6' => $common, |
| 83 | 'i' => $common, |
| 84 | 'img' => array_merge($common, ['src' => [], 'alt' => [], 'width' => [], 'height' => []]), |
| 85 | 'li' => $common, |
| 86 | 'ol' => $common, |
| 87 | 'p' => $common, |
| 88 | 'small' => $common, |
| 89 | 'span' => $common, |
| 90 | 'strong' => $common, |
| 91 | 'u' => $common, |
| 92 | 'ul' => $common, |
| 93 | ]; |
| 94 | } |
| 95 | |
| 96 | public function call(){ |
| 97 | add_action( 'admin_head', [$this, 'display_content'] ); |
| 98 | } |
| 99 | |
| 100 | public function display_content(){ |
| 101 | $this->get_data(); |
| 102 | |
| 103 | if(empty($this->data)) { |
| 104 | return; |
| 105 | } |
| 106 | |
| 107 | $screen = get_current_screen(); |
| 108 | |
| 109 | if(is_null($screen)) { |
| 110 | return; |
| 111 | } |
| 112 | |
| 113 | if(!class_exists('\Oxaim\Libs\Notice')) { |
| 114 | return; |
| 115 | } |
| 116 | |
| 117 | foreach($this->data as $content) { |
| 118 | |
| 119 | if(!empty($this->filter_array) && $this->in_blacklist($content, $this->filter_array)) { |
| 120 | continue; |
| 121 | } |
| 122 | |
| 123 | if($content->start > time() || time() > $content->end) { |
| 124 | continue; |
| 125 | } |
| 126 | |
| 127 | if(!$this->is_correct_screen_to_show($content->screen, $screen->id)) { |
| 128 | continue; |
| 129 | } |
| 130 | |
| 131 | $inline_css = ''; |
| 132 | $banner_unique_id = ($content->data->unique_key !== '' ? $content->data->unique_key : $content->id); |
| 133 | |
| 134 | if($content->data->style_css !== '') { |
| 135 | $inline_css = ' style="' . esc_attr($content->data->style_css) . '"'; |
| 136 | } |
| 137 | |
| 138 | $instance = \Oxaim\Libs\Notice::instance('wpmet-jhanda', $banner_unique_id) |
| 139 | ->set_dismiss('global', (3600 * 24 * 15)); |
| 140 | |
| 141 | if($content->type == 'banner'){ |
| 142 | $this->init_banner($content, $instance, $inline_css); |
| 143 | } |
| 144 | |
| 145 | if($content->type == 'notice'){ |
| 146 | $this->init_notice($content, $instance, $inline_css); |
| 147 | } |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | |
| 152 | private function init_notice($content, $instance, $inline_css){ |
| 153 | |
| 154 | $instance->set_message($content->data->notice_body); |
| 155 | |
| 156 | if($content->data->notice_image !== ''){ |
| 157 | $instance->set_logo($content->data->notice_image); |
| 158 | } |
| 159 | |
| 160 | if($content->data->button_text !== '' && $content->data->button_link !== ''){ |
| 161 | $instance->set_button([ |
| 162 | 'default_class' => 'button', |
| 163 | 'class' => 'button-secondary button-small', // button-primary button-secondary button-small button-large button-link |
| 164 | 'text' => $content->data->button_text, |
| 165 | 'url' => $content->data->button_link, |
| 166 | ]); |
| 167 | } |
| 168 | $instance->call(); |
| 169 | } |
| 170 | |
| 171 | private function init_banner($content, $instance, $inline_css){ |
| 172 | |
| 173 | if($content->data->banner_link === '' || $content->data->banner_image === ''){ |
| 174 | return; |
| 175 | } |
| 176 | |
| 177 | $html = sprintf( |
| 178 | '<a target="_blank" rel="noopener noreferrer"%1$s class="wpmet-jhanda-href" href="%2$s"><img style="display: block;margin: 0 auto;" src="%3$s" alt="%4$s" /></a>', |
| 179 | $inline_css, // already escaped in display_content() |
| 180 | esc_url($content->data->banner_link, self::allowed_protocols()), |
| 181 | esc_url($content->data->banner_image, self::allowed_protocols()), |
| 182 | esc_attr($content->title) |
| 183 | ); |
| 184 | |
| 185 | $instance->set_gutter(false) |
| 186 | ->set_html($html) |
| 187 | ->call(); |
| 188 | } |
| 189 | |
| 190 | |
| 191 | private function in_whitelist($conf, $list) { |
| 192 | |
| 193 | $match = $conf->data->whitelist; |
| 194 | |
| 195 | if(empty($match)) { |
| 196 | return true; |
| 197 | }; |
| 198 | |
| 199 | $match_arr = explode(',', $match); |
| 200 | |
| 201 | foreach($list as $word) { |
| 202 | if(in_array($word, $match_arr)) { |
| 203 | return true; |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | return false; |
| 208 | } |
| 209 | |
| 210 | |
| 211 | private function in_blacklist($conf, $list) { |
| 212 | |
| 213 | $match = $conf->data->blacklist; |
| 214 | |
| 215 | if(empty($match)) { |
| 216 | return false; |
| 217 | }; |
| 218 | |
| 219 | $match_arr = explode(',', $match); |
| 220 | |
| 221 | foreach($match_arr as $idx => $item) { |
| 222 | |
| 223 | $match_arr[$idx] = trim($item); |
| 224 | } |
| 225 | |
| 226 | foreach($list as $word) { |
| 227 | if(in_array($word, $match_arr)) { |
| 228 | return true; |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | return false; |
| 233 | } |
| 234 | |
| 235 | |
| 236 | public function is_test($is_test = false) { |
| 237 | if($is_test === true){ |
| 238 | $this->check_interval = 1; |
| 239 | } |
| 240 | |
| 241 | return $this; |
| 242 | } |
| 243 | |
| 244 | |
| 245 | public function set_text_domain($text_domain) { |
| 246 | $this->text_domain = $text_domain; |
| 247 | |
| 248 | return $this; |
| 249 | } |
| 250 | |
| 251 | |
| 252 | public function set_filter($filter_string) { |
| 253 | $this->filter_string = $filter_string; |
| 254 | if(!empty($filter_string)) { |
| 255 | |
| 256 | $filter = explode(',', $this->filter_string); |
| 257 | |
| 258 | foreach ($filter as $id => $item) { |
| 259 | $this->filter_array[$id] = trim($item); |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | return $this; |
| 264 | } |
| 265 | |
| 266 | |
| 267 | public function set_api_url($url) { |
| 268 | $this->api_url = $url; |
| 269 | |
| 270 | return $this; |
| 271 | } |
| 272 | |
| 273 | public function set_plugin_screens($screen) { |
| 274 | $this->plugin_screens[] = $screen; |
| 275 | |
| 276 | return $this; |
| 277 | } |
| 278 | |
| 279 | |
| 280 | /** |
| 281 | * Normalise one remote feed entry into a known-shape, fully escaped object. |
| 282 | * |
| 283 | * Everything the feed sends that is not on this list is dropped, so a |
| 284 | * poisoned response cannot introduce new fields for later code to trip on. |
| 285 | * |
| 286 | * @param mixed $content Raw decoded entry. |
| 287 | * @return object|null Sanitized entry, or null if it is not renderable. |
| 288 | */ |
| 289 | private function sanitize_content($content) { |
| 290 | |
| 291 | if(!is_object($content) && !is_array($content)) { |
| 292 | return null; |
| 293 | } |
| 294 | |
| 295 | $content = (object) $content; |
| 296 | |
| 297 | $type = isset($content->type) ? sanitize_key((string) $content->type) : ''; |
| 298 | |
| 299 | if(!in_array($type, self::ALLOWED_TYPES, true)) { |
| 300 | return null; |
| 301 | } |
| 302 | |
| 303 | $data = (isset($content->data) && (is_object($content->data) || is_array($content->data))) ? (object) $content->data : new \stdClass(); |
| 304 | |
| 305 | $item = new \stdClass(); |
| 306 | $item->id = isset($content->id) ? $this->sanitize_id($content->id) : ''; |
| 307 | $item->title = isset($content->title) ? sanitize_text_field((string) $content->title) : ''; |
| 308 | $item->type = $type; |
| 309 | $item->screen = isset($content->screen) ? sanitize_key((string) $content->screen) : ''; |
| 310 | $item->start = isset($content->start) ? intval($content->start) : 0; |
| 311 | $item->end = isset($content->end) ? intval($content->end) : 0; |
| 312 | |
| 313 | $item->data = (object) [ |
| 314 | 'unique_key' => isset($data->unique_key) ? $this->sanitize_id($data->unique_key) : '', |
| 315 | 'style_css' => isset($data->style_css) ? $this->sanitize_style($data->style_css) : '', |
| 316 | 'blacklist' => isset($data->blacklist) ? sanitize_text_field((string) $data->blacklist) : '', |
| 317 | 'whitelist' => isset($data->whitelist) ? sanitize_text_field((string) $data->whitelist) : '', |
| 318 | 'banner_link' => isset($data->banner_link) ? $this->sanitize_url($data->banner_link) : '', |
| 319 | 'banner_image' => isset($data->banner_image) ? $this->sanitize_url($data->banner_image) : '', |
| 320 | 'notice_body' => isset($data->notice_body) ? $this->sanitize_html($data->notice_body) : '', |
| 321 | 'notice_image' => isset($data->notice_image) ? $this->sanitize_url($data->notice_image) : '', |
| 322 | 'button_text' => isset($data->button_text) ? sanitize_text_field((string) $data->button_text) : '', |
| 323 | 'button_link' => isset($data->button_link) ? $this->sanitize_url($data->button_link) : '', |
| 324 | ]; |
| 325 | |
| 326 | if($item->id === '' && $item->data->unique_key === '') { |
| 327 | return null; |
| 328 | } |
| 329 | |
| 330 | return $item; |
| 331 | } |
| 332 | |
| 333 | |
| 334 | /** |
| 335 | * Run every entry of a decoded feed through sanitize_content(). |
| 336 | */ |
| 337 | private function sanitize_response($response) { |
| 338 | |
| 339 | if(!is_object($response) && !is_array($response)) { |
| 340 | return []; |
| 341 | } |
| 342 | |
| 343 | $clean = []; |
| 344 | |
| 345 | foreach((array) $response as $content) { |
| 346 | |
| 347 | $item = $this->sanitize_content($content); |
| 348 | |
| 349 | if(!is_null($item)) { |
| 350 | $clean[] = $item; |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | return $clean; |
| 355 | } |
| 356 | |
| 357 | |
| 358 | /** |
| 359 | * Values that become HTML ids, transient names and user-meta keys. |
| 360 | */ |
| 361 | private function sanitize_id($value) { |
| 362 | |
| 363 | if(!is_scalar($value)) { |
| 364 | return ''; |
| 365 | } |
| 366 | |
| 367 | return substr(sanitize_key((string) $value), 0, self::MAX_KEY_LENGTH); |
| 368 | } |
| 369 | |
| 370 | |
| 371 | /** |
| 372 | * Inline CSS from the feed, filtered through core's CSS property allowlist. |
| 373 | */ |
| 374 | private function sanitize_style($css) { |
| 375 | |
| 376 | if(!is_scalar($css) || (string) $css === '') { |
| 377 | return ''; |
| 378 | } |
| 379 | |
| 380 | return (string) safecss_filter_attr((string) $css); |
| 381 | } |
| 382 | |
| 383 | |
| 384 | /** |
| 385 | * Links and image sources from the feed. Anything that is not plain |
| 386 | * http(s) -- javascript:, data:, protocol-relative tricks -- comes back |
| 387 | * as an empty string and the caller skips rendering it. |
| 388 | * |
| 389 | * The explicit scheme requirement matters beyond protocol filtering: |
| 390 | * esc_url_raw() prepends http:// to a bare string, so an attribute- |
| 391 | * breakout attempt like `x" onerror="..."` would otherwise survive as a |
| 392 | * loadable URL pointing wherever the feed liked. Every URL the endpoint |
| 393 | * actually serves is already absolute https, so nothing legitimate is lost. |
| 394 | */ |
| 395 | private function sanitize_url($url) { |
| 396 | |
| 397 | if(!is_scalar($url)) { |
| 398 | return ''; |
| 399 | } |
| 400 | |
| 401 | $url = trim((string) $url); |
| 402 | |
| 403 | if(!preg_match('#^https?://#i', $url)) { |
| 404 | return ''; |
| 405 | } |
| 406 | |
| 407 | return esc_url_raw($url, self::allowed_protocols()); |
| 408 | } |
| 409 | |
| 410 | |
| 411 | /** |
| 412 | * Rich-text bodies from the feed. |
| 413 | */ |
| 414 | private function sanitize_html($html) { |
| 415 | |
| 416 | if(!is_scalar($html)) { |
| 417 | return ''; |
| 418 | } |
| 419 | |
| 420 | return wp_kses((string) $html, self::allowed_html(), self::allowed_protocols()); |
| 421 | } |
| 422 | |
| 423 | |
| 424 | private function get_data() { |
| 425 | |
| 426 | // Sanitize on read as well as on write: installs that already cached an |
| 427 | // unsanitized (or poisoned) payload get cleaned up on the next render |
| 428 | // without waiting for the refresh interval. |
| 429 | $this->data = $this->sanitize_response(get_option($this->text_domain . '__banner_data')); |
| 430 | |
| 431 | $this->last_check = get_option($this->text_domain . '__banner_last_check'); |
| 432 | $this->last_check = $this->last_check == '' ? 0 : $this->last_check; |
| 433 | |
| 434 | if(($this->check_interval + $this->last_check) >= time()){ |
| 435 | return; |
| 436 | } |
| 437 | |
| 438 | $response = wp_remote_get( $this->api_url . '/cache/'.$this->text_domain.'.json?nocache='.time(), |
| 439 | [ |
| 440 | 'timeout' => 10, |
| 441 | 'httpversion' => '1.1', |
| 442 | ] |
| 443 | ); |
| 444 | |
| 445 | if(is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)){ |
| 446 | return; |
| 447 | } |
| 448 | |
| 449 | $decoded = json_decode(wp_remote_retrieve_body($response)); |
| 450 | |
| 451 | if(JSON_ERROR_NONE !== json_last_error()){ |
| 452 | return; |
| 453 | } |
| 454 | |
| 455 | // An empty-but-valid response is accepted and stored. That is what |
| 456 | // makes a bad payload revocable: serving [] from the endpoint clears |
| 457 | // it everywhere instead of leaving the last cached copy in place. |
| 458 | $this->data = $this->sanitize_response($decoded); |
| 459 | |
| 460 | update_option($this->text_domain . '__banner_last_check', time()); |
| 461 | update_option($this->text_domain . '__banner_data', $this->data); |
| 462 | } |
| 463 | |
| 464 | |
| 465 | public function is_correct_screen_to_show($b_screen, $screen_id) { |
| 466 | |
| 467 | if(in_array($b_screen, [$screen_id, 'all_page'])) { |
| 468 | return true; |
| 469 | } |
| 470 | |
| 471 | |
| 472 | if($b_screen == 'plugin_page') { |
| 473 | return in_array($screen_id, (array) $this->plugin_screens); |
| 474 | } |
| 475 | |
| 476 | return false; |
| 477 | } |
| 478 | |
| 479 | private static $instance; |
| 480 | |
| 481 | public static function instance($text_domain = '') { |
| 482 | |
| 483 | self::$instance = new static(); |
| 484 | return self::$instance->set_text_domain($text_domain); |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | endif; |
| 489 |