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 +310 -32 3.1.9 → 3.1.14 View file →
@@ -1,8 +1,8 @@
1 1 <?php
2 2 /**
3 3 * @package FireBox
4 - * @version 3.1.9 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
@@ -275,9 +275,22 @@
275 275 {
276 276 return '';
277 277 }
278 278
279 - return $this->box->params->get('customcss', '');
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);
280 293 }
281 294
282 295 /**
283 296 * Recursively casts an object/array tree to a plain array, without the
@@ -322,13 +335,31 @@
322 335 return;
323 336 }
324 337 self::$loadedLocalizedScript = true;
325 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 + */
326 344 $data = [
327 345 'ajax_url' => admin_url('admin-ajax.php'),
328 346 'nonce' => wp_create_nonce('fbox_js_nonce'),
329 347 'site_url' => site_url('/'),
330 - '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()
331 362 ];
332 363
333 364 wp_add_inline_script('firebox-main', 'const fbox_js_object = ' . wp_json_encode($data), 'before');
334 365 }
@@ -341,14 +372,9 @@
341 372 public function loadBoxMedia($box)
342 373 {
343 374 $box = new Registry($box);
344 375
345 - wp_enqueue_style(
346 - 'firebox-animations',
347 - FBOX_MEDIA_PUBLIC_URL . 'css/vendor/animate.min.css',
348 - [],
349 - FBOX_VERSION
350 - );
376 + $this->loadAnimationsMedia($box);
351 377
352 378 /**
353 379 * FireBox JS
354 380 */
@@ -355,14 +381,22 @@
355 381 wp_enqueue_script('firebox-main');
356 382
357 383
358 384
359 - // 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 + */
360 393 $custom_code = $box->get('params.data.customcode', '');
361 - 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')))
362 396 {
363 397 $custom_code = html_entity_decode(stripslashes($custom_code));
364 - wp_add_inline_script('firebox-main', $custom_code, 'after');
398 + BoxHelper::addInlineScript($custom_code);
365 399 }
366 400
367 401 // run above the main JS script to run only once
368 402 self::setJSObject();
@@ -381,8 +415,13 @@
381 415 * Page Slide mode JS
382 416 */
383 417 if ($box->get('params.data.mode') == 'pageslide')
384 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.
385 424 wp_enqueue_script(
386 425 'firebox-pageslide-mode',
387 426 FBOX_MEDIA_PUBLIC_URL . 'js/pageslide_mode.js',
388 427 ['firebox-main'],
@@ -394,10 +433,81 @@
394 433
395 434 }
396 435
397 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 + /**
398 508 * Prepares the box before rendering
399 - *
509 + *
400 510 * @return void
401 511 */
402 512 public function prepare()
403 513 {
@@ -597,8 +707,25 @@
597 707
598 708 // Normalize to an array; an empty or invalid-JSON "rules" string decodes to null.
599 709 $rules = is_array($rules) ? $rules : [];
600 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 +
601 728 // If testmode is enabled disable the User Groups condition
602 729 if ($this->box->params->get('testmode'))
603 730 {
604 731 foreach ($rules as $key => &$group)
@@ -643,8 +770,140 @@
643 770 return $this->clientRuleGroups;
644 771 }
645 772
646 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 + /**
647 906 * Check if a box passes local conditions
648 907 *
649 908 * @return boolean
650 909 */
@@ -661,30 +920,47 @@
661 920 *
662 921 * @return object
663 922 */
664 923 private function getAssignmentsForMirroring($box_id)
665 - {
666 - $payload = [
667 - 'where' => [
668 - 'ID' => ' = ' . intval($box_id),
669 - 'post_status' => " = 'publish'",
670 - 'post_type' => " = 'firebox'"
671 - ]
672 - ];
673 -
674 - // Load box
675 - 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))
676 932 {
677 - return;
933 + return $cache[$box_id];
678 934 }
679 -
680 - $box = $box[0];
681 -
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 +
682 957 // get meta options for box
683 - $meta = BoxHelper::getMeta($box->ID);
684 - $box->params = new Registry($meta);
958 + $params = new Registry(BoxHelper::getMeta($mirrored->ID));
685 959
686 - return new Registry(['rules' => $box->params->get('rules')]);
960 + $cache[$box_id] = new Registry(['rules' => $params->get('rules')]);
961 +
962 + return $cache[$box_id];
687 963 }
688 964
689 965 /**
690 966 * Prefixes the CSS classes
@@ -772,9 +1048,11 @@
772 1048 * @return array
773 1049 */
774 1050 public function getTotalImpressions($payload)
775 1051 {
776 - return firebox()->tables->boxlog->getResults($payload, false, true);
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);
777 1055 }
778 1056
779 1057 /**
780 1058 * Returns the cookie instance.