PluginProbe ʕ •ᴥ•ʔ
ShopEngine Elementor WooCommerce Builder Addon – All in One WooCommerce Solution with eCommerce Templates & Woo Widgets / 4.9.4
ShopEngine Elementor WooCommerce Builder Addon – All in One WooCommerce Solution with eCommerce Templates & Woo Widgets v4.9.4
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 2 weeks ago
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