PluginProbe
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment / 3.1.14
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment v3.1.14
3.1.14 3.1.13 3.1.12 3.1.11 3.1.10 3.1.9 3.1.8 3.1.7 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 All 123 releases
← All changes | Inc/Core/FB/Box.php +408 -49 3.1.7 → 3.1.14 View file →
@@ -1,8 +1,8 @@
1 1 <?php
2 2 /**
3 3 * @package FireBox
4 - * @version 3.1.7 Free
4 + * @version 3.1.14 Free
5 5 *
6 6 * @author FirePlugins <[email protected]>
7 7 * @link https://www.fireplugins.com
8 8 * @copyright Copyright © 2026 FirePlugins All Rights Reserved
@@ -52,14 +52,23 @@
52 52 private $params = null;
53 53
54 54 /**
55 55 * Popup CSS.
56 - *
56 + *
57 57 * @var CSS
58 58 */
59 59 public $css = null;
60 60
61 61 /**
62 + * Display condition groups that must be resolved in the browser before the
63 + * campaign may open (session-dependent rules like Time on Site/Pageviews).
64 + * Populated by pass(), shipped to the frontend runtime via prepare().
65 + *
66 + * @var array
67 + */
68 + private $clientRuleGroups = [];
69 +
70 + /**
62 71 * Constructor.
63 72 *
64 73 * @param object $box
65 74 * @param object $factory
@@ -101,19 +110,19 @@
101 110 }
102 111
103 112 /**
104 113 * Get a box.
105 - *
114 + *
106 115 * @param int $id
107 116 * @param string $status
108 - *
109 - * @return object
117 + *
118 + * @return object|null
110 119 */
111 120 public function get($id = null, $status = null)
112 121 {
113 122 if (!$id)
114 123 {
115 - return;
124 + return null;
116 125 }
117 126
118 127 $payload = [
119 128 'where' => [
@@ -126,17 +135,17 @@
126 135 if ($status)
127 136 {
128 137 $payload['where']['post_status'] = ' = \'' . sanitize_key($status) . '\'';
129 138 }
130 -
139 +
131 140 if (!$box = firebox()->tables->box->getResults($payload))
132 141 {
133 - return [];
142 + return null;
134 143 }
135 144
136 145 if (!isset($box[0]))
137 146 {
138 - return [];
147 + return null;
139 148 }
140 149
141 150 $this->box = $box[0];
142 151
@@ -261,12 +270,61 @@
261 270 * @return string
262 271 */
263 272 public function getCustomCSS()
264 273 {
265 - return $this->box->params->get('customcss', '');
274 + if (!is_object($this->box) || !isset($this->box->params))
275 + {
276 + return '';
277 + }
278 +
279 + $css = $this->box->params->get('customcss', '');
280 +
281 + if (!is_string($css))
282 + {
283 + return '';
284 + }
285 +
286 + /**
287 + * The CSS lands inside an inline <style> element, where a literal "</style>"
288 + * would end the element and let markup (e.g. a <script>) escape into the page.
289 + * Strip tags the same way core's wp_custom_css_cb() does — legitimate CSS
290 + * contains no tags, so this only neuters breakout attempts.
291 + */
292 + return wp_strip_all_tags($css);
266 293 }
267 294
268 295 /**
296 + * Recursively casts an object/array tree to a plain array, without the
297 + * json_decode(wp_json_encode()) round-trip.
298 + *
299 + * @param mixed $data
300 + *
301 + * @return array
302 + */
303 + private static function recursiveToArray($data)
304 + {
305 + if (is_object($data))
306 + {
307 + $data = get_object_vars($data);
308 + }
309 +
310 + if (!is_array($data))
311 + {
312 + return [];
313 + }
314 +
315 + foreach ($data as $key => $value)
316 + {
317 + if (is_object($value) || is_array($value))
318 + {
319 + $data[$key] = self::recursiveToArray($value);
320 + }
321 + }
322 +
323 + return $data;
324 + }
325 +
326 + /**
269 327 * Send a helpful object to JavaScript files
270 328 *
271 329 * @return void
272 330 */
@@ -277,13 +335,31 @@
277 335 return;
278 336 }
279 337 self::$loadedLocalizedScript = true;
280 338
339 + /**
340 + * The referrer is deliberately not included here. On a full-page-cached site this
341 + * HTML is written once and served to everyone, so a baked-in referrer would report
342 + * whoever warmed the cache. The runtime reads `document.referrer` instead.
343 + */
281 344 $data = [
282 345 'ajax_url' => admin_url('admin-ajax.php'),
283 346 'nonce' => wp_create_nonce('fbox_js_nonce'),
284 347 'site_url' => site_url('/'),
285 - 'referrer' => isset($_SERVER['HTTP_REFERER']) ? sanitize_url(wp_unslash($_SERVER['HTTP_REFERER'])) : ''
348 +
349 + // Shown when a form submission never gets a usable response back (network error,
350 + // non-JSON body, PHP fatal). The form messages themselves come from the block.
351 + 'form_submit_failed' => firebox()->_('FB_FORM_SUBMIT_FAILED'),
352 +
353 + /**
354 + * The scripts write cookies PHP has to read back, so they need the
355 + * same name, path and domain it uses: the prefix keeps the sites of
356 + * a subdirectory network apart, the path is the site's own corner of
357 + * the domain, and the domain is whatever COOKIE_DOMAIN says.
358 + */
359 + 'cookie_prefix' => \FireBox\Core\Helpers\BoxHelper::cookiePrefix(),
360 + 'cookie_path' => \FireBox\Core\Helpers\BoxHelper::cookiePath(),
361 + 'cookie_domain' => \FireBox\Core\Helpers\BoxHelper::cookieDomain()
286 362 ];
287 363
288 364 wp_add_inline_script('firebox-main', 'const fbox_js_object = ' . wp_json_encode($data), 'before');
289 365 }
@@ -296,14 +372,9 @@
296 372 public function loadBoxMedia($box)
297 373 {
298 374 $box = new Registry($box);
299 375
300 - wp_enqueue_style(
301 - 'firebox-animations',
302 - FBOX_MEDIA_PUBLIC_URL . 'css/vendor/animate.min.css',
303 - [],
304 - FBOX_VERSION
305 - );
376 + $this->loadAnimationsMedia($box);
306 377
307 378 /**
308 379 * FireBox JS
309 380 */
@@ -310,14 +381,22 @@
310 381 wp_enqueue_script('firebox-main');
311 382
312 383
313 384
314 - // Add Custom Javascript
385 + /**
386 + * Add Custom Javascript.
387 + *
388 + * Emitted verbatim into every visitor's page, so it is gated on the author
389 + * holding unfiltered_html — the campaign meta is writable by any
390 + * edit_fireboxes user (editor, REST, import) and must not be a way to run
391 + * script that user could not write elsewhere.
392 + */
315 393 $custom_code = $box->get('params.data.customcode', '');
316 - if (is_string($custom_code) && !empty($custom_code))
394 + if (is_string($custom_code) && !empty($custom_code)
395 + && \FireBox\Core\Helpers\CustomCode::isAllowedForCampaign($box->get('ID')))
317 396 {
318 397 $custom_code = html_entity_decode(stripslashes($custom_code));
319 - wp_add_inline_script('firebox-main', $custom_code, 'after');
398 + BoxHelper::addInlineScript($custom_code);
320 399 }
321 400
322 401 // run above the main JS script to run only once
323 402 self::setJSObject();
@@ -336,14 +415,19 @@
336 415 * Page Slide mode JS
337 416 */
338 417 if ($box->get('params.data.mode') == 'pageslide')
339 418 {
419 + // This script moves the campaign instances into the page-slide wrapper after
420 + // firebox-main creates them. Deferring is safe: deferred scripts execute in
421 + // document order and firebox-main is declared as a dependency, so it always
422 + // runs first. All inline scripts on firebox-main are attached in the "before"
423 + // position, which keeps its defer strategy intact.
340 424 wp_enqueue_script(
341 425 'firebox-pageslide-mode',
342 426 FBOX_MEDIA_PUBLIC_URL . 'js/pageslide_mode.js',
343 427 ['firebox-main'],
344 428 FBOX_VERSION,
345 - true
429 + ['in_footer' => true, 'strategy' => 'defer']
346 430 );
347 431 }
348 432
349 433
@@ -349,10 +433,81 @@
349 433
350 434 }
351 435
352 436 /**
437 + * Enqueues only the Animate.css animations the campaign actually uses.
438 + *
439 + * The full library is 71 KB of render-blocking CSS for what is almost always a
440 + * single fade, so it is split at build time (gulp build-animations) into one file
441 + * per animation plus a shared base. Campaigns rendering on the same page union
442 + * their animations naturally, since each gets its own handle.
443 + *
444 + * @param Registry $box
445 + *
446 + * @return void
447 + */
448 + private function loadAnimationsMedia($box)
449 + {
450 + /**
451 + * Escape hatch for campaigns whose custom code applies arbitrary
452 + * firebox__animate__* classes that we cannot see server-side.
453 + */
454 + if (apply_filters('firebox/box/load_full_animations', false))
455 + {
456 + wp_enqueue_style(
457 + 'firebox-animations',
458 + FBOX_MEDIA_PUBLIC_URL . 'css/vendor/animate.min.css',
459 + [],
460 + FBOX_VERSION
461 + );
462 +
463 + return;
464 + }
465 +
466 + $animations = array_filter([
467 + $box->get('params.data.animationin', ''),
468 + $box->get('params.data.animationout', '')
469 + ], 'is_string');
470 +
471 + $animations = array_unique(array_filter(array_map('trim', $animations)));
472 +
473 + if (!$animations)
474 + {
475 + return;
476 + }
477 +
478 + $dir = FBOX_PLUGIN_DIR . 'media/public/css/vendor/animations/';
479 + $url = FBOX_MEDIA_PUBLIC_URL . 'css/vendor/animations/';
480 +
481 + $enqueued = false;
482 +
483 + foreach ($animations as $animation)
484 + {
485 + // Animation names are a fixed camelCase slug set; anything else is either a
486 + // stale value or a name we no longer ship, and must not reach the filesystem.
487 + if (!preg_match('#^[A-Za-z0-9]+$#', $animation) || !file_exists($dir . $animation . '.css'))
488 + {
489 + continue;
490 + }
491 +
492 + if (!$enqueued)
493 + {
494 + wp_enqueue_style('firebox-animations-base', $url . 'base.css', [], FBOX_VERSION);
495 + $enqueued = true;
496 + }
497 +
498 + wp_enqueue_style(
499 + 'firebox-animation-' . $animation,
500 + $url . $animation . '.css',
501 + ['firebox-animations-base'],
502 + FBOX_VERSION
503 + );
504 + }
505 + }
506 +
507 + /**
353 508 * Prepares the box before rendering
354 - *
509 + *
355 510 * @return void
356 511 */
357 512 public function prepare()
358 513 {
@@ -450,9 +605,11 @@
450 605 'disable_page_scroll' => (bool) $this->box->params->get('preventpagescroll'),
451 606 'test_mode' => (bool) $this->box->params->get('testmode'),
452 607 'debug' => (bool) $cParam->get('debug', false),
453 608 'auto_focus' => (bool) $this->box->params->get('autofocus', false),
454 - 'mode' => $this->box->params->get('mode')
609 + 'mode' => $this->box->params->get('mode'),
610 + // Session-dependent display conditions resolved in the browser (see pass())
611 + 'client_rules' => $this->clientRuleGroups
455 612 ];
456 613
457 614 $this->css = new Styling\CSS($this->box);
458 615
@@ -496,10 +653,10 @@
496 653 }
497 654
498 655 /**
499 656 * Replaces all box smart tags
500 - *
501 - * @return object
657 + *
658 + * @return void
502 659 */
503 660 public function replaceBoxSmartTags()
504 661 {
505 662 $tags = new \FPFramework\Base\SmartTags\SmartTags();
@@ -516,8 +673,10 @@
516 673 * @return boolean
517 674 */
518 675 public function pass()
519 676 {
677 + $this->clientRuleGroups = [];
678 +
520 679 if (!$this->box || !is_object($this->box))
521 680 {
522 681 return false;
523 682 }
@@ -543,15 +702,40 @@
543 702 }
544 703
545 704 // Get a recursive array of all rules
546 705 $rules = $this->box->params->get('rules', []);
547 - $rules = is_string($rules) ? json_decode($rules, true) : json_decode(wp_json_encode($rules), true);
706 + $rules = is_string($rules) ? json_decode($rules, true) : self::recursiveToArray($rules);
548 707
708 + // Normalize to an array; an empty or invalid-JSON "rules" string decodes to null.
709 + $rules = is_array($rules) ? $rules : [];
710 +
711 + // The "PHP" display condition runs campaign-authored PHP server-side on every
712 + // visitor request (Conditions\PHP -> Executer). Evaluate it only when the campaign
713 + // author holds firebox_execute_php; otherwise treat the PHP condition as unmet
714 + // rather than running it — an "all" group that requires it fails, an "any" group
715 + // falls back to its other conditions. If every group depended on PHP, there is
716 + // nothing left to match and the campaign does not show. Runs after the mirror merge
717 + // above, so it also covers PHP rules pulled in from a mirrored box.
718 + if (self::hasPHPRule($rules) && !\FireBox\Core\Helpers\PHPExecution::isAllowedForCampaign($this->box->ID))
719 + {
720 + $rules = self::withoutPHPRules($rules);
721 +
722 + if (empty($rules))
723 + {
724 + return false;
725 + }
726 + }
727 +
549 728 // If testmode is enabled disable the User Groups condition
550 729 if ($this->box->params->get('testmode'))
551 730 {
552 731 foreach ($rules as $key => &$group)
553 732 {
733 + if (!isset($group['rules']) || !is_array($group['rules']))
734 + {
735 + continue;
736 + }
737 +
554 738 foreach ($group['rules'] as $_key => &$rule)
555 739 {
556 740 if (!isset($rule['name']) || empty($rule['name']))
557 741 {
@@ -563,15 +747,163 @@
563 747 unset($group['rules'][$_key]);
564 748 }
565 749 }
566 750 }
751 + unset($group);
567 752 }
568 753
569 - // Check framework based conditions
570 - return \FPFramework\Base\Conditions\ConditionBuilder::pass($rules, $this->factory);
754 + // Check framework based conditions. Session-dependent (client-side) rules are
755 + // deferred: the box renders hidden and the frontend runtime resolves them.
756 + $result = \FPFramework\Base\Conditions\ConditionBuilder::passWithClientRules($rules, $this->factory);
757 +
758 + $this->clientRuleGroups = $result['client_groups'];
759 +
760 + return $result['pass'];
571 761 }
572 762
573 763 /**
764 + * Display condition groups deferred to the frontend runtime by pass().
765 + *
766 + * @return array
767 + */
768 + public function getClientRuleGroups()
769 + {
770 + return $this->clientRuleGroups;
771 + }
772 +
773 + /**
774 + * Whether a single rule is a "PHP" condition that carries code to run.
775 + *
776 + * The name is matched case-insensitively: PHP class names resolve regardless
777 + * of case, so ConditionsHelper::getCondition() would load the PHP condition for
778 + * 'php', 'Php', etc. An empty value is ignored: there is nothing to run.
779 + *
780 + * @param array $rule A single display-condition rule.
781 + *
782 + * @return bool
783 + */
784 + private static function isPHPRule($rule)
785 + {
786 + if (!is_array($rule) || !isset($rule['name']) || !is_string($rule['name']))
787 + {
788 + return false;
789 + }
790 +
791 + if (strtolower(trim($rule['name'])) !== 'php')
792 + {
793 + return false;
794 + }
795 +
796 + // Only a non-empty string value can be executed as PHP.
797 + return isset($rule['value']) && is_string($rule['value']) && trim($rule['value']) !== '';
798 + }
799 +
800 + /**
801 + * Whether any rule in the given groups is a "PHP" condition that carries code.
802 + *
803 + * @param array $rules Recursive display-condition groups.
804 + *
805 + * @return bool
806 + */
807 + private static function hasPHPRule($rules)
808 + {
809 + if (!is_array($rules))
810 + {
811 + return false;
812 + }
813 +
814 + foreach ($rules as $group)
815 + {
816 + if (!isset($group['rules']) || !is_array($group['rules']))
817 + {
818 + continue;
819 + }
820 +
821 + foreach ($group['rules'] as $rule)
822 + {
823 + if (self::isPHPRule($rule))
824 + {
825 + return true;
826 + }
827 + }
828 + }
829 +
830 + return false;
831 + }
832 +
833 + /**
834 + * Returns the rule groups with runnable "PHP" conditions treated as unmet, for a
835 + * campaign whose author may not run PHP. A group's matching_method decides how:
836 + *
837 + * - "all" (every rule must pass): the PHP condition can never be satisfied, so the
838 + * whole group fails and is dropped.
839 + * - "any" (one rule is enough): the PHP condition is removed so it cannot be the
840 + * reason the group passes, but the group's other conditions still evaluate. A group
841 + * left with no other conditions is dropped.
842 + *
843 + * Groups without a PHP condition are returned untouched.
844 + *
845 + * @param array $rules Recursive display-condition groups.
846 + *
847 + * @return array
848 + */
849 + private static function withoutPHPRules($rules)
850 + {
851 + if (!is_array($rules))
852 + {
853 + return [];
854 + }
855 +
856 + $filtered = [];
857 +
858 + foreach ($rules as $group)
859 + {
860 + if (!isset($group['rules']) || !is_array($group['rules']))
861 + {
862 + $filtered[] = $group;
863 + continue;
864 + }
865 +
866 + $kept = [];
867 + $hasPHP = false;
868 +
869 + foreach ($group['rules'] as $rule)
870 + {
871 + if (self::isPHPRule($rule))
872 + {
873 + $hasPHP = true;
874 + continue;
875 + }
876 +
877 + $kept[] = $rule;
878 + }
879 +
880 + if (!$hasPHP)
881 + {
882 + $filtered[] = $group;
883 + continue;
884 + }
885 +
886 + $method = isset($group['matching_method']) ? $group['matching_method'] : 'all';
887 +
888 + // "all": a required PHP condition is unmet, so the group can never pass.
889 + if ($method === 'all')
890 + {
891 + continue;
892 + }
893 +
894 + // "any": keep the remaining alternatives, unless PHP was the only one.
895 + if (!empty($kept))
896 + {
897 + $group['rules'] = $kept;
898 + $filtered[] = $group;
899 + }
900 + }
901 +
902 + return $filtered;
903 + }
904 +
905 + /**
574 906 * Check if a box passes local conditions
575 907 *
576 908 * @return boolean
577 909 */
@@ -588,30 +920,47 @@
588 920 *
589 921 * @return object
590 922 */
591 923 private function getAssignmentsForMirroring($box_id)
592 - {
593 - $payload = [
594 - 'where' => [
595 - 'ID' => ' = ' . intval($box_id),
596 - 'post_status' => " = 'publish'",
597 - 'post_type' => " = 'firebox'"
598 - ]
599 - ];
600 -
601 - // Load box
602 - if (!$box = firebox()->tables->box->getResults($payload))
924 + {
925 + // Several campaigns commonly mirror the same source, and every campaign is
926 + // evaluated on every page view, so resolve each source at most once per request.
927 + static $cache = [];
928 +
929 + $box_id = intval($box_id);
930 +
931 + if (array_key_exists($box_id, $cache))
603 932 {
604 - return;
933 + return $cache[$box_id];
605 934 }
606 -
607 - $box = $box[0];
608 -
935 +
936 + $cache[$box_id] = null;
937 +
938 + // The published campaigns are already loaded (and their meta primed) for this
939 + // request, so read the mirrored campaign from that list instead of querying again.
940 + $boxes = BoxHelper::getAllBoxes();
941 + $mirrored = null;
942 +
943 + foreach ($boxes->posts as $box)
944 + {
945 + if ((int) $box->ID === $box_id)
946 + {
947 + $mirrored = $box;
948 + break;
949 + }
950 + }
951 +
952 + if (!$mirrored)
953 + {
954 + return $cache[$box_id];
955 + }
956 +
609 957 // get meta options for box
610 - $meta = BoxHelper::getMeta($box->ID);
611 - $box->params = new Registry($meta);
958 + $params = new Registry(BoxHelper::getMeta($mirrored->ID));
612 959
613 - return new Registry(['rules' => $box->params->get('rules')]);
960 + $cache[$box_id] = new Registry(['rules' => $params->get('rules')]);
961 +
962 + return $cache[$box_id];
614 963 }
615 964
616 965 /**
617 966 * Prefixes the CSS classes
@@ -648,8 +997,13 @@
648 997 public function logOpenEvent($box_id, $page = null, $referrer = null)
649 998 {
650 999 $box = $this->get($box_id);
651 1000
1001 + if (!is_object($box) || !isset($box->params))
1002 + {
1003 + return;
1004 + }
1005 +
652 1006 // Do not track if statistics option is disabled
653 1007 $track_open_event = (bool) (is_null($box->params->get('stats', null)) ? true : $box->params->get('stats'));
654 1008 if (!$track_open_event)
655 1009 {
@@ -670,8 +1024,13 @@
670 1024 public function logCloseEvent($box_id, $box_log_id)
671 1025 {
672 1026 $box = $this->get($box_id);
673 1027
1028 + if (!is_object($box) || !isset($box->params))
1029 + {
1030 + return null;
1031 + }
1032 +
674 1033 // Do not track if statistics option is disabled
675 1034 $track_open_event = (bool) (is_null($box->params->get('stats', null)) ? true : $box->params->get('stats'));
676 1035 if (!$track_open_event)
677 1036 {
@@ -689,11 +1048,11 @@
689 1048 * @return array
690 1049 */
691 1050 public function getTotalImpressions($payload)
692 1051 {
693 - $impressions = firebox()->tables->boxlog->getResults($payload);
694 -
695 - return count($impressions);
1052 + // Cached: the same campaign/period is counted once per request even when
1053 + // several conditions ask for it.
1054 + return firebox()->tables->boxlog->getResults($payload, true, true);
696 1055 }
697 1056
698 1057 /**
699 1058 * Returns the cookie instance.