PluginProbe ʕ •ᴥ•ʔ
ShopEngine Elementor WooCommerce Builder Addon – All in One WooCommerce Solution with eCommerce Templates & Woo Widgets / 4.9.5
ShopEngine Elementor WooCommerce Builder Addon – All in One WooCommerce Solution with eCommerce Templates & Woo Widgets v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 2.0.0 2.1.0 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.1 3.0.0 3.1.0 3.1.1 4.0.0 4.0.1 4.1.0 4.1.1 4.2.0 4.2.1 4.3.0 4.3.1 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.6.6 4.6.7 4.6.8 4.6.9 4.7.0 4.7.1 4.7.2 4.7.3 4.7.4 4.7.5 4.7.6 4.7.7 4.7.8 4.7.9 4.8.0 4.8.1 4.8.2 4.8.3 4.8.4 4.8.5 4.8.6 4.8.7 4.8.8 4.8.9 trunk 0.1.2-beta 0.1.3-beta 0.1.4-beta 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.5.0 1.5.1 1.6.0 1.6.1 1.7.0 1.8.0 1.8.1 1.9.0
shopengine / utils / banner / banner.php
shopengine / utils / banner Last commit date
banner.php 1 week ago
banner.php
490 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 'abbr' => $common,
74 'b' => $common,
75 'br' => [],
76 'div' => $common,
77 'em' => $common,
78 'h1' => $common,
79 'h2' => $common,
80 'h3' => $common,
81 'h4' => $common,
82 'h5' => $common,
83 'h6' => $common,
84 'i' => $common,
85 'img' => array_merge($common, ['src' => [], 'alt' => [], 'width' => [], 'height' => []]),
86 'li' => $common,
87 'ol' => $common,
88 'p' => $common,
89 'small' => $common,
90 'span' => $common,
91 'strong' => $common,
92 'u' => $common,
93 'ul' => $common,
94 ];
95 }
96
97 public function call(){
98 add_action( 'admin_head', [$this, 'display_content'] );
99 }
100
101 public function display_content(){
102 $this->get_data();
103
104 if(empty($this->data)) {
105 return;
106 }
107
108 $screen = get_current_screen();
109
110 if(is_null($screen)) {
111 return;
112 }
113
114 if(!class_exists('\Oxaim\Libs\Notice')) {
115 return;
116 }
117
118 foreach($this->data as $content) {
119
120 if(!empty($this->filter_array) && $this->in_blacklist($content, $this->filter_array)) {
121 continue;
122 }
123
124 if($content->start > time() || time() > $content->end) {
125 continue;
126 }
127
128 if(!$this->is_correct_screen_to_show($content->screen, $screen->id)) {
129 continue;
130 }
131
132 $inline_css = '';
133 $banner_unique_id = ($content->data->unique_key !== '' ? $content->data->unique_key : $content->id);
134
135 if($content->data->style_css !== '') {
136 $inline_css = ' style="' . esc_attr($content->data->style_css) . '"';
137 }
138
139 $instance = \Oxaim\Libs\Notice::instance('wpmet-jhanda', $banner_unique_id)
140 ->set_dismiss('global', (3600 * 24 * 15));
141
142 if($content->type == 'banner'){
143 $this->init_banner($content, $instance, $inline_css);
144 }
145
146 if($content->type == 'notice'){
147 $this->init_notice($content, $instance, $inline_css);
148 }
149 }
150 }
151
152
153 private function init_notice($content, $instance, $inline_css){
154
155 $instance->set_message($content->data->notice_body);
156
157 if($content->data->notice_image !== ''){
158 $instance->set_logo($content->data->notice_image);
159 }
160
161 if($content->data->button_text !== '' && $content->data->button_link !== ''){
162 $instance->set_button([
163 'default_class' => 'button',
164 'class' => 'button-secondary button-small', // button-primary button-secondary button-small button-large button-link
165 'text' => $content->data->button_text,
166 'url' => $content->data->button_link,
167 ]);
168 }
169 $instance->call();
170 }
171
172 private function init_banner($content, $instance, $inline_css){
173
174 if($content->data->banner_link === '' || $content->data->banner_image === ''){
175 return;
176 }
177
178 $html = sprintf(
179 '<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>',
180 $inline_css, // already escaped in display_content()
181 esc_url($content->data->banner_link, self::allowed_protocols()),
182 esc_url($content->data->banner_image, self::allowed_protocols()),
183 esc_attr($content->title)
184 );
185
186 $instance->set_gutter(false)
187 ->set_html($html)
188 ->call();
189 }
190
191
192 private function in_whitelist($conf, $list) {
193
194 $match = $conf->data->whitelist;
195
196 if(empty($match)) {
197 return true;
198 };
199
200 $match_arr = explode(',', $match);
201
202 foreach($list as $word) {
203 if(in_array($word, $match_arr)) {
204 return true;
205 }
206 }
207
208 return false;
209 }
210
211
212 private function in_blacklist($conf, $list) {
213
214 $match = $conf->data->blacklist;
215
216 if(empty($match)) {
217 return false;
218 };
219
220 $match_arr = explode(',', $match);
221
222 foreach($match_arr as $idx => $item) {
223
224 $match_arr[$idx] = trim($item);
225 }
226
227 foreach($list as $word) {
228 if(in_array($word, $match_arr)) {
229 return true;
230 }
231 }
232
233 return false;
234 }
235
236
237 public function is_test($is_test = false) {
238 if($is_test === true){
239 $this->check_interval = 1;
240 }
241
242 return $this;
243 }
244
245
246 public function set_text_domain($text_domain) {
247 $this->text_domain = $text_domain;
248
249 return $this;
250 }
251
252
253 public function set_filter($filter_string) {
254 $this->filter_string = $filter_string;
255 if(!empty($filter_string)) {
256
257 $filter = explode(',', $this->filter_string);
258
259 foreach ($filter as $id => $item) {
260 $this->filter_array[$id] = trim($item);
261 }
262 }
263
264 return $this;
265 }
266
267
268 public function set_api_url($url) {
269 $this->api_url = $url;
270
271 return $this;
272 }
273
274 public function set_plugin_screens($screen) {
275 $this->plugin_screens[] = $screen;
276
277 return $this;
278 }
279
280
281 /**
282 * Normalise one remote feed entry into a known-shape, fully escaped object.
283 *
284 * Everything the feed sends that is not on this list is dropped, so a
285 * poisoned response cannot introduce new fields for later code to trip on.
286 *
287 * @param mixed $content Raw decoded entry.
288 * @return object|null Sanitized entry, or null if it is not renderable.
289 */
290 private function sanitize_content($content) {
291
292 if(!is_object($content) && !is_array($content)) {
293 return null;
294 }
295
296 $content = (object) $content;
297
298 $type = isset($content->type) ? sanitize_key((string) $content->type) : '';
299
300 if(!in_array($type, self::ALLOWED_TYPES, true)) {
301 return null;
302 }
303
304 $data = (isset($content->data) && (is_object($content->data) || is_array($content->data))) ? (object) $content->data : new \stdClass();
305
306 $item = new \stdClass();
307 $item->id = isset($content->id) ? $this->sanitize_id($content->id) : '';
308 $item->title = isset($content->title) ? sanitize_text_field((string) $content->title) : '';
309 $item->type = $type;
310 $item->screen = isset($content->screen) ? sanitize_key((string) $content->screen) : '';
311 $item->start = isset($content->start) ? intval($content->start) : 0;
312 $item->end = isset($content->end) ? intval($content->end) : 0;
313
314 $item->data = (object) [
315 'unique_key' => isset($data->unique_key) ? $this->sanitize_id($data->unique_key) : '',
316 'style_css' => isset($data->style_css) ? $this->sanitize_style($data->style_css) : '',
317 'blacklist' => isset($data->blacklist) ? sanitize_text_field((string) $data->blacklist) : '',
318 'whitelist' => isset($data->whitelist) ? sanitize_text_field((string) $data->whitelist) : '',
319 'banner_link' => isset($data->banner_link) ? $this->sanitize_url($data->banner_link) : '',
320 'banner_image' => isset($data->banner_image) ? $this->sanitize_url($data->banner_image) : '',
321 'notice_body' => isset($data->notice_body) ? $this->sanitize_html($data->notice_body) : '',
322 'notice_image' => isset($data->notice_image) ? $this->sanitize_url($data->notice_image) : '',
323 'button_text' => isset($data->button_text) ? sanitize_text_field((string) $data->button_text) : '',
324 'button_link' => isset($data->button_link) ? $this->sanitize_url($data->button_link) : '',
325 ];
326
327 if($item->id === '' && $item->data->unique_key === '') {
328 return null;
329 }
330
331 return $item;
332 }
333
334
335 /**
336 * Run every entry of a decoded feed through sanitize_content().
337 */
338 private function sanitize_response($response) {
339
340 if(!is_object($response) && !is_array($response)) {
341 return [];
342 }
343
344 $clean = [];
345
346 foreach((array) $response as $content) {
347
348 $item = $this->sanitize_content($content);
349
350 if(!is_null($item)) {
351 $clean[] = $item;
352 }
353 }
354
355 return $clean;
356 }
357
358
359 /**
360 * Values that become HTML ids, transient names and user-meta keys.
361 */
362 private function sanitize_id($value) {
363
364 if(!is_scalar($value)) {
365 return '';
366 }
367
368 return substr(sanitize_key((string) $value), 0, self::MAX_KEY_LENGTH);
369 }
370
371
372 /**
373 * Inline CSS from the feed, filtered through core's CSS property allowlist.
374 */
375 private function sanitize_style($css) {
376
377 if(!is_scalar($css) || (string) $css === '') {
378 return '';
379 }
380
381 return (string) safecss_filter_attr((string) $css);
382 }
383
384
385 /**
386 * Links and image sources from the feed. Anything that is not plain
387 * http(s) -- javascript:, data:, protocol-relative tricks -- comes back
388 * as an empty string and the caller skips rendering it.
389 *
390 * The explicit scheme requirement matters beyond protocol filtering:
391 * esc_url_raw() prepends http:// to a bare string, so an attribute-
392 * breakout attempt like `x" onerror="..."` would otherwise survive as a
393 * loadable URL pointing wherever the feed liked. Every URL the endpoint
394 * actually serves is already absolute https, so nothing legitimate is lost.
395 */
396 private function sanitize_url($url) {
397
398 if(!is_scalar($url)) {
399 return '';
400 }
401
402 $url = trim((string) $url);
403
404 if(!preg_match('#^https?://#i', $url)) {
405 return '';
406 }
407
408 return esc_url_raw($url, self::allowed_protocols());
409 }
410
411
412 /**
413 * Rich-text bodies from the feed.
414 */
415 private function sanitize_html($html) {
416
417 if(!is_scalar($html)) {
418 return '';
419 }
420
421 return wp_kses((string) $html, self::allowed_html(), self::allowed_protocols());
422 }
423
424
425 private function get_data() {
426
427 // Sanitize on read as well as on write: installs that already cached an
428 // unsanitized (or poisoned) payload get cleaned up on the next render
429 // without waiting for the refresh interval.
430 $this->data = $this->sanitize_response(get_option($this->text_domain . '__banner_data'));
431
432 $this->last_check = get_option($this->text_domain . '__banner_last_check');
433 $this->last_check = $this->last_check == '' ? 0 : $this->last_check;
434
435 if(($this->check_interval + $this->last_check) >= time()){
436 return;
437 }
438
439 $response = wp_remote_get( $this->api_url . '/cache/'.$this->text_domain.'.json?nocache='.time(),
440 [
441 'timeout' => 10,
442 'httpversion' => '1.1',
443 ]
444 );
445
446 if(is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)){
447 return;
448 }
449
450 $decoded = json_decode(wp_remote_retrieve_body($response));
451
452 if(JSON_ERROR_NONE !== json_last_error()){
453 return;
454 }
455
456 // An empty-but-valid response is accepted and stored. That is what
457 // makes a bad payload revocable: serving [] from the endpoint clears
458 // it everywhere instead of leaving the last cached copy in place.
459 $this->data = $this->sanitize_response($decoded);
460
461 update_option($this->text_domain . '__banner_last_check', time());
462 update_option($this->text_domain . '__banner_data', $this->data);
463 }
464
465
466 public function is_correct_screen_to_show($b_screen, $screen_id) {
467
468 if(in_array($b_screen, [$screen_id, 'all_page'])) {
469 return true;
470 }
471
472
473 if($b_screen == 'plugin_page') {
474 return in_array($screen_id, (array) $this->plugin_screens);
475 }
476
477 return false;
478 }
479
480 private static $instance;
481
482 public static function instance($text_domain = '') {
483
484 self::$instance = new static();
485 return self::$instance->set_text_domain($text_domain);
486 }
487 }
488
489 endif;
490