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 +415 -28 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
@@ -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,26 +120,225 @@
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 + /**
155 + * Whether any of the given campaigns uses session-dependent behavior:
156 + * the Pageviews/Time on Site display conditions, or an impressions
157 + * limit scoped to the visitor's session (fpfsid).
158 + *
159 + * These are stamped and evaluated against JS-set session cookies, so
160 + * when in use the frontend runtime must be enqueued on every page.
161 + * Skipped entirely when no campaign needs them, so other sites load no
162 + * extra JS or cookies.
163 + *
164 + * @param \WP_Query $boxes
165 + *
166 + * @return bool
167 + */
168 + public static function sessionConditionsInUse($boxes)
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 + {
274 + if (!$boxes || empty($boxes->posts))
275 + {
276 + return false;
277 + }
278 +
279 + foreach ($boxes->posts as $box)
280 + {
281 + $meta = self::getMeta($box->ID);
282 +
283 + if (!$meta)
284 + {
285 + continue;
286 + }
287 +
288 + // Impressions limit scoped to the browser session
289 + $impressionsPeriod = isset($meta['assign_impressions_param_type']) ? $meta['assign_impressions_param_type'] : '';
290 +
291 + if ($impressionsPeriod === 'custom')
292 + {
293 + $impressionsPeriod = isset($meta['assign_impressions_param_custom_period']) ? $meta['assign_impressions_param_custom_period'] : '';
294 + }
295 +
296 + if ($impressionsPeriod === 'session')
297 + {
298 + return true;
299 + }
300 +
301 + // Session-dependent display conditions
302 + $metaJson = wp_json_encode($meta);
303 +
304 + if (strpos($metaJson, 'Pageviews') !== false || strpos($metaJson, 'TimeOnSite') !== false)
305 + {
306 + return true;
307 + }
308 + }
309 +
310 + return false;
311 + }
312 +
313 + /**
39 314 * Gets all Boxes.
40 - *
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 + *
41 325 * @param array $status
42 326 * @param int $limit
43 - *
44 - * @return array
327 + *
328 + * @return \WP_Query
45 329 */
46 330 public static function getAllBoxes($status = ['publish'], $limit = -1)
47 331 {
48 - // cache key
49 - $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 : [];
50 335
51 - // check cache
52 - if ($data = wp_cache_get($hash))
336 + if (isset($cache[$key]) && is_array($cache[$key]))
53 337 {
54 - return $data;
55 - }
56 -
338 + return self::hydrateBoxesQuery($cache[$key]);
339 + }
340 +
57 341 $args = [
58 342 'post_status' => $status,
59 343 'post_type' => 'firebox',
60 344 'posts_per_page' => $limit,
@@ -61,21 +345,80 @@
61 345 'no_found_rows' => true,
62 346 'update_post_term_cache' => false,
63 347 'cache_results' => true
64 348 ];
65 -
349 +
66 350 // Get the query.
67 351 $query = new \WP_Query($args);
68 352
69 353 wp_reset_postdata();
70 354
71 - // set cache
72 - wp_cache_set($hash, $query);
355 + $cache[$key] = wp_list_pluck($query->posts, 'ID');
73 356
357 + update_option(self::BOXES_CACHE_OPTION, $cache, true);
358 +
74 359 return $query;
75 360 }
76 361
77 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 + /**
78 421 * Retrieves all boxes in a key => value array of ID => title
79 422 *
80 423 * @return array
81 424 */
@@ -239,9 +582,9 @@
239 582 // cache key
240 583 $cache_key = md5('fboxSettings');
241 584
242 585 // check cache
243 - if ($params = wp_cache_get($cache_key))
586 + if ($params = wp_cache_get($cache_key, 'firebox'))
244 587 {
245 588 return $params;
246 589 }
247 590
@@ -248,9 +591,9 @@
248 591 // get params
249 592 $params = get_option('firebox_settings');
250 593
251 594 // set cache
252 - wp_cache_set($cache_key, $params);
595 + wp_cache_set($cache_key, $params, 'firebox', 5 * MINUTE_IN_SECONDS);
253 596
254 597 return $params;
255 598 }
256 599
@@ -283,8 +626,17 @@
283 626 $box->ID = '';
284 627 $box->post_title = 'Copy of ' . $box->post_title;
285 628 $box->post_status = 'draft';
286 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 +
287 639 $factory = new \FPFramework\Base\Factory();
288 640
289 641 $tz = wp_timezone();
290 642 $date_without_tz = $factory->getDate();
@@ -306,9 +658,9 @@
306 658 $checkMeta = (array) $meta;
307 659 $meta_key = isset($checkMeta['width']) ? 'firebox_meta' : 'fpframework_meta_settings';
308 660 update_post_meta($new_box_id, $meta_key, wp_slash($meta));
309 661
310 - return true;
662 + return $new_box_id;
311 663 }
312 664
313 665 /**
314 666 * Reset Box Stats
@@ -318,8 +670,16 @@
318 670 * @return void
319 671 */
320 672 public static function resetBoxStats($box_ids)
321 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 +
322 682 $logs_table = firebox()->tables->boxlog->getFullTableName();
323 683 $logs_details_table = firebox()->tables->boxlogdetails->getFullTableName();
324 684
325 685 // delete box logs details
@@ -329,15 +689,17 @@
329 689 firebox()->tables->boxlog->deleteRaw('WHERE box IN (' . implode(',', $box_ids) . ')');
330 690 }
331 691
332 692 /**
333 - * Exports boxes
334 - *
693 + * Builds the export payload (boxes + meta) and a suggested filename for the given box ids,
694 + * without emitting any headers or output. Shared by exportBoxes() (legacy file-download
695 + * action) and the Campaigns REST export endpoint.
696 + *
335 697 * @param array $box_ids
336 - *
337 - * @return string
698 + *
699 + * @return array|null ['filename' => string, 'exported' => array] or null if no boxes matched
338 700 */
339 - public static function exportBoxes($box_ids)
701 + public static function getExportPayload($box_ids)
340 702 {
341 703 // get boxes
342 704 $boxes = firebox()->tables->box->getResults([
343 705 'where' => [
@@ -349,13 +711,13 @@
349 711 $boxes = (array) $boxes;
350 712
351 713 if (!count($boxes))
352 714 {
353 - return;
715 + return null;
354 716 }
355 717
356 718 $exported = [];
357 -
719 +
358 720 $filename = firebox()->_('FB_PLUGIN_NAME') . ' Items';
359 721
360 722 // name for 1 box
361 723 if (count($boxes) == 1)
@@ -360,10 +722,12 @@
360 722 // name for 1 box
361 723 if (count($boxes) == 1)
362 724 {
363 725 $name = mb_strtolower(html_entity_decode($boxes['0']->post_title));
364 - $name = preg_replace('#[^a-z0-9_-]#', '_', $name);
365 - $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, '_-');
366 730
367 731 $filename = firebox()->_('FB_PLUGIN_NAME') . ' Item (' . $name . ')';
368 732 }
369 733
@@ -375,8 +739,31 @@
375 739 'box' => $box,
376 740 'meta' => $meta
377 741 ];
378 742 }
743 +
744 + return [
745 + 'filename' => $filename,
746 + 'exported' => $exported,
747 + ];
748 + }
749 +
750 + /**
751 + * Exports boxes
752 + *
753 + * @param array $box_ids
754 + *
755 + * @return string
756 + */
757 + public static function exportBoxes($box_ids)
758 + {
759 + if (!$payload = self::getExportPayload($box_ids))
760 + {
761 + return;
762 + }
763 +
764 + $filename = $payload['filename'];
765 + $exported = $payload['exported'];
379 766
380 767 // SET DOCUMENT HEADER
381 768 $userAgent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
382 769 if (preg_match('#Opera(/| )([0-9].[0-9]{1,2})#', $userAgent))