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/Helpers/BoxHelper.php +321 -14 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
@@ -20,12 +20,97 @@
20 20
21 21 class BoxHelper
22 22 {
23 23 /**
24 + * Option holding the cached campaign lists, keyed by status/limit.
25 + *
26 + * An autoloaded option rather than a transient on purpose: a transient with an expiry is
27 + * stored non-autoloaded, so reading it costs 2 queries per request on stock WordPress —
28 + * exactly what the campaign query it replaces costs. Autoloaded means WordPress has
29 + * already loaded it by the time Boxes::render() runs. There is no TTL either: the option
30 + * is deleted on every campaign write, so the cache is always current.
31 + *
32 + * @var string
33 + */
34 + const BOXES_CACHE_OPTION = 'firebox_boxes_cache';
35 +
36 + /**
37 + * Returns the prefix every FireBox cookie name is built from.
38 + *
39 + * Cookies are set with path=/, so on a subdirectory network every site of
40 + * example.com/store, example.com/blog … shares one cookie namespace. Post
41 + * IDs restart per site, so campaign 42 exists on both and one campaign's
42 + * "already seen" cookie would suppress an unrelated campaign on another
43 + * site. The blog ID in the name is what keeps them apart.
44 + *
45 + * Single sites — and the main site of a network, which owns the unprefixed
46 + * namespace already — keep the original prefix, so upgrading does not show
47 + * a campaign again to visitors who had dismissed it.
48 + *
49 + * @return string
50 + */
51 + public static function cookiePrefix()
52 + {
53 + if (!is_multisite() || is_main_site())
54 + {
55 + return 'firebox_';
56 + }
57 +
58 + return 'firebox_' . get_current_blog_id() . '_';
59 + }
60 +
61 + /**
62 + * The path FireBox cookies are written to.
63 + *
64 + * WordPress derives COOKIEPATH from the site's home URL, so it already
65 + * points at exactly the part of the domain this site occupies — the whole
66 + * domain for a normal install, /store/ for that site of a subdirectory
67 + * network. It is what WooCommerce and AffiliateWP scope their cookies with,
68 + * and it keeps a network's sites from writing over each other.
69 + *
70 + * @return string
71 + */
72 + public static function cookiePath()
73 + {
74 + return \FPFramework\Helpers\CookieScope::path();
75 + }
76 +
77 + /**
78 + * The domain FireBox cookies are written to.
79 + *
80 + * Empty unless the site defines COOKIE_DOMAIN, which is how a site tells
81 + * WordPress its cookies belong to something other than the current host —
82 + * shared between www and non-www, or across the subdomains of a network.
83 + * Ignoring it means writing a cookie the site cannot read back.
84 + *
85 + * @return string
86 + */
87 + public static function cookieDomain()
88 + {
89 + return \FPFramework\Helpers\CookieScope::domain();
90 + }
91 +
92 + /**
93 + * Paths a cookie of ours may have been written to before.
94 + *
95 + * Cookies are keyed by name *and* path, so a cookie written at '/' is a
96 + * different cookie from the same name at '/store/'. FireBox wrote every
97 + * cookie to '/' until it started honouring COOKIEPATH, and expiring only
98 + * the new path would leave the old one behind — still sent, still
99 + * suppressing the campaign it belongs to.
100 + *
101 + * @return array
102 + */
103 + public static function cookiePathsToClear()
104 + {
105 + return \FPFramework\Helpers\CookieScope::pathsToClear();
106 + }
107 +
108 + /**
24 109 * Get box meta
25 - *
110 + *
26 111 * @param int $id
27 - *
112 + *
28 113 * @return array
29 114 */
30 115 public static function getMeta($id)
31 116 {
@@ -35,8 +120,39 @@
35 120 return $meta;
36 121 }
37 122
38 123 /**
124 + * Attaches inline JavaScript that runs once the FireBox runtime has created all
125 + * campaign instances.
126 + *
127 + * The script is attached in the "before" position on purpose: WordPress downgrades a
128 + * defer-strategy script to a blocking one as soon as it carries an "after" inline
129 + * script, so a single campaign with custom code would make the whole runtime
130 + * parse-blocking. Printing before the runtime keeps defer intact.
131 + *
132 + * Waiting for DOMContentLoaded is what makes that safe: firebox-main is deferred, so
133 + * the browser executes it once parsing is done — at which point FireBox.onReady()
134 + * runs immediately (readyState is already "interactive") and builds every campaign
135 + * instance. DOMContentLoaded fires after all deferred scripts, so by the time this
136 + * code runs the instances exist.
137 + *
138 + * @param string $js
139 + *
140 + * @return void
141 + */
142 + public static function addInlineScript($js)
143 + {
144 + if (!is_string($js) || trim($js) === '')
145 + {
146 + return;
147 + }
148 +
149 + $wrapped = 'document.addEventListener("DOMContentLoaded", function(){' . $js . '});';
150 +
151 + wp_add_inline_script('firebox-main', $wrapped, 'before');
152 + }
153 +
154 + /**
39 155 * Whether any of the given campaigns uses session-dependent behavior:
40 156 * the Pageviews/Time on Site display conditions, or an impressions
41 157 * limit scoped to the visitor's session (fpfsid).
42 158 *
@@ -50,8 +166,112 @@
50 166 * @return bool
51 167 */
52 168 public static function sessionConditionsInUse($boxes)
53 169 {
170 + // The answer only changes when a campaign is written to, so keep it in an
171 + // autoloaded option instead of re-scanning every campaign's meta per page view.
172 + $cached = get_option('firebox_session_conditions_in_use');
173 +
174 + if ($cached === 'yes' || $cached === 'no')
175 + {
176 + return $cached === 'yes';
177 + }
178 +
179 + $inUse = self::computeSessionConditionsInUse($boxes);
180 +
181 + update_option('firebox_session_conditions_in_use', $inUse ? 'yes' : 'no', true);
182 +
183 + return $inUse;
184 + }
185 +
186 + /**
187 + * Drops the cached session-conditions flag so it is recomputed on the next
188 + * frontend request. Hooked on every write path that can change the answer.
189 + *
190 + * @return void
191 + */
192 + public static function invalidateSessionConditionsFlag()
193 + {
194 + delete_option('firebox_session_conditions_in_use');
195 + }
196 +
197 + /**
198 + * Drops every campaign-derived cache: the session-conditions flag and the
199 + * cached box list. Hooked on each write path that can change either answer.
200 + *
201 + * @return void
202 + */
203 + public static function invalidateCaches()
204 + {
205 + self::invalidateSessionConditionsFlag();
206 + self::invalidateBoxesCache();
207 + }
208 +
209 + /**
210 + * Registers the invalidation hooks for the campaign-derived caches.
211 + *
212 + * @return void
213 + */
214 + public static function registerCacheInvalidation()
215 + {
216 + add_action('save_post_firebox', [self::class, 'invalidateCaches']);
217 +
218 + // These fire for every post type, so only react to campaigns — otherwise
219 + // ordinary content edits would keep busting the campaign caches.
220 + $onPostChange = function($post_id, $post = null)
221 + {
222 + if (self::isCampaign($post_id, $post))
223 + {
224 + self::invalidateCaches();
225 + }
226 + };
227 +
228 + add_action('deleted_post', $onPostChange, 10, 2);
229 + add_action('trashed_post', $onPostChange, 10, 1);
230 + add_action('untrashed_post', $onPostChange, 10, 1);
231 +
232 + // firebox_meta is also written outside the save_post flow (Gutenberg REST meta
233 + // updates, the campaign importer, duplication).
234 + $onMetaWrite = function($meta_id, $post_id, $meta_key)
235 + {
236 + if ($meta_key === 'firebox_meta')
237 + {
238 + self::invalidateCaches();
239 + }
240 + };
241 +
242 + add_action('added_postmeta', $onMetaWrite, 10, 3);
243 + add_action('updated_postmeta', $onMetaWrite, 10, 3);
244 + }
245 +
246 + /**
247 + * Whether the given post is a campaign. `deleted_post` passes the post object
248 + * along because the row is already gone by the time it fires.
249 + *
250 + * @param int $post_id
251 + * @param \WP_Post $post
252 + *
253 + * @return bool
254 + */
255 + private static function isCampaign($post_id, $post = null)
256 + {
257 + if ($post instanceof \WP_Post)
258 + {
259 + return $post->post_type === 'firebox';
260 + }
261 +
262 + return get_post_type($post_id) === 'firebox';
263 + }
264 +
265 + /**
266 + * Scans the given campaigns for session-dependent behavior.
267 + *
268 + * @param \WP_Query $boxes
269 + *
270 + * @return bool
271 + */
272 + private static function computeSessionConditionsInUse($boxes)
273 + {
54 274 if (!$boxes || empty($boxes->posts))
55 275 {
56 276 return false;
57 277 }
@@ -92,23 +312,32 @@
92 312
93 313 /**
94 314 * Gets all Boxes.
95 315 *
316 + * Only the campaign IDs are cached, in an autoloaded option so the list also
317 + * survives across requests on hosts without a persistent object cache — where
318 + * wp_cache is per-request and the query therefore ran on every page view. The
319 + * autoloaded options are already in memory by the time this runs, so a cache hit
320 + * costs no query of its own.
321 + *
322 + * There is no TTL: the cache is dropped on every campaign write, which also means
323 + * editors see their changes immediately.
324 + *
96 325 * @param array $status
97 326 * @param int $limit
98 327 *
99 - * @return array
328 + * @return \WP_Query
100 329 */
101 330 public static function getAllBoxes($status = ['publish'], $limit = -1)
102 331 {
103 - // cache key
104 - $hash = md5('firebox_getAllBoxes_' . implode(',', $status) . '_' . (int) $limit);
332 + $key = self::getBoxesCacheKey($status, $limit);
333 + $cache = get_option(self::BOXES_CACHE_OPTION, []);
334 + $cache = is_array($cache) ? $cache : [];
105 335
106 - // check cache
107 - if ($data = wp_cache_get($hash, 'firebox'))
336 + if (isset($cache[$key]) && is_array($cache[$key]))
108 337 {
109 - return $data;
110 - }
338 + return self::hydrateBoxesQuery($cache[$key]);
339 + }
111 340
112 341 $args = [
113 342 'post_status' => $status,
114 343 'post_type' => 'firebox',
@@ -122,15 +351,74 @@
122 351 $query = new \WP_Query($args);
123 352
124 353 wp_reset_postdata();
125 354
126 - // set cache (short TTL: box publish state changes should reflect reasonably promptly)
127 - wp_cache_set($hash, $query, 'firebox', 5 * MINUTE_IN_SECONDS);
355 + $cache[$key] = wp_list_pluck($query->posts, 'ID');
128 356
357 + update_option(self::BOXES_CACHE_OPTION, $cache, true);
358 +
129 359 return $query;
130 360 }
131 361
132 362 /**
363 + * Rebuilds a WP_Query from a cached list of campaign IDs.
364 + *
365 + * @param array $ids
366 + *
367 + * @return \WP_Query
368 + */
369 + private static function hydrateBoxesQuery($ids)
370 + {
371 + $query = new \WP_Query();
372 +
373 + // A hand-built query has no query vars of its own, and the loop reads several
374 + // while setting up each post. "fields => all" states that $posts already holds
375 + // full post objects, which is what it takes for the loop to use them as-is.
376 + $query->query_vars = $query->fill_query_vars(['fields' => 'all']);
377 +
378 + $posts = [];
379 +
380 + if ($ids)
381 + {
382 + // Two queries, both by primary key, priming the same post and postmeta
383 + // caches the original WP_Query did.
384 + _prime_post_caches($ids, false, true);
385 +
386 + // array_filter drops campaigns deleted since the cache was written.
387 + $posts = array_values(array_filter(array_map('get_post', $ids)));
388 + }
389 +
390 + $query->posts = $posts;
391 + $query->post_count = count($posts);
392 + $query->found_posts = $query->post_count;
393 +
394 + return $query;
395 + }
396 +
397 + /**
398 + * The cache entry a box list is stored under.
399 + *
400 + * @param array $status
401 + * @param int $limit
402 + *
403 + * @return string
404 + */
405 + private static function getBoxesCacheKey($status, $limit)
406 + {
407 + return md5(implode(',', (array) $status) . '_' . (int) $limit);
408 + }
409 +
410 + /**
411 + * Drops every cached box list.
412 + *
413 + * @return void
414 + */
415 + public static function invalidateBoxesCache()
416 + {
417 + delete_option(self::BOXES_CACHE_OPTION);
418 + }
419 +
420 + /**
133 421 * Retrieves all boxes in a key => value array of ID => title
134 422 *
135 423 * @return array
136 424 */
@@ -338,8 +626,17 @@
338 626 $box->ID = '';
339 627 $box->post_title = 'Copy of ' . $box->post_title;
340 628 $box->post_status = 'draft';
341 629
630 + /**
631 + * The copy belongs to whoever duplicates, never the source author — the same
632 + * rule the importer applies. The PHP and custom-JavaScript gates are a
633 + * function of the post author, so keeping the source author would let a copy
634 + * of a higher-privileged user's campaign keep running code its new owner is
635 + * not allowed to write.
636 + */
637 + $box->post_author = get_current_user_id();
638 +
342 639 $factory = new \FPFramework\Base\Factory();
343 640
344 641 $tz = wp_timezone();
345 642 $date_without_tz = $factory->getDate();
@@ -373,8 +670,16 @@
373 670 * @return void
374 671 */
375 672 public static function resetBoxStats($box_ids)
376 673 {
674 + // These ids are concatenated into raw IN() clauses below, so cast them here rather
675 + // than trusting every caller to do it.
676 + $box_ids = array_filter(array_map('intval', (array) $box_ids));
677 + if (empty($box_ids))
678 + {
679 + return;
680 + }
681 +
377 682 $logs_table = firebox()->tables->boxlog->getFullTableName();
378 683 $logs_details_table = firebox()->tables->boxlogdetails->getFullTableName();
379 684
380 685 // delete box logs details
@@ -417,10 +722,12 @@
417 722 // name for 1 box
418 723 if (count($boxes) == 1)
419 724 {
420 725 $name = mb_strtolower(html_entity_decode($boxes['0']->post_title));
421 - $name = preg_replace('#[^a-z0-9_-]#', '_', $name);
422 - $name = trim(preg_replace('#__+#', '_', $name), '_-');
726 + // preg_replace() returns null when PCRE fails (backtrack limit, invalid UTF-8),
727 + // and passing null on is deprecated since PHP 8.1. Keep the previous value.
728 + $name = preg_replace('#[^a-z0-9_-]#', '_', $name) ?? $name;
729 + $name = trim(preg_replace('#__+#', '_', $name) ?? $name, '_-');
423 730
424 731 $filename = firebox()->_('FB_PLUGIN_NAME') . ' Item (' . $name . ')';
425 732 }
426 733