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 +385 -42 3.1.8 → 3.1.14 View file →
@@ -1,8 +1,8 @@
1 1 <?php
2 2 /**
3 3 * @package FireBox
4 - * @version 3.1.8 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
@@ -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,10 +702,30 @@
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)
@@ -571,13 +750,160 @@
571 750 }
572 751 unset($group);
573 752 }
574 753
575 - // Check framework based conditions
576 - 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'];
577 761 }
578 762
579 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 + /**
580 906 * Check if a box passes local conditions
581 907 *
582 908 * @return boolean
583 909 */
@@ -594,30 +920,47 @@
594 920 *
595 921 * @return object
596 922 */
597 923 private function getAssignmentsForMirroring($box_id)
598 - {
599 - $payload = [
600 - 'where' => [
601 - 'ID' => ' = ' . intval($box_id),
602 - 'post_status' => " = 'publish'",
603 - 'post_type' => " = 'firebox'"
604 - ]
605 - ];
606 -
607 - // Load box
608 - 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))
609 932 {
610 - return;
933 + return $cache[$box_id];
611 934 }
612 -
613 - $box = $box[0];
614 -
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 +
615 957 // get meta options for box
616 - $meta = BoxHelper::getMeta($box->ID);
617 - $box->params = new Registry($meta);
958 + $params = new Registry(BoxHelper::getMeta($mirrored->ID));
618 959
619 - return new Registry(['rules' => $box->params->get('rules')]);
960 + $cache[$box_id] = new Registry(['rules' => $params->get('rules')]);
961 +
962 + return $cache[$box_id];
620 963 }
621 964
622 965 /**
623 966 * Prefixes the CSS classes
@@ -705,11 +1048,11 @@
705 1048 * @return array
706 1049 */
707 1050 public function getTotalImpressions($payload)
708 1051 {
709 - $impressions = firebox()->tables->boxlog->getResults($payload);
710 -
711 - 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);
712 1055 }
713 1056
714 1057 /**
715 1058 * Returns the cookie instance.