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/Track.php +260 -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
@@ -24,8 +24,15 @@
24 24 * @var Factory
25 25 */
26 26 private $factory;
27 27
28 + /**
29 + * Conversions read from the cookie, waiting to be written on shutdown.
30 + *
31 + * @var array
32 + */
33 + private $pendingConversions = [];
34 +
28 35 public function __construct($factory = null)
29 36 {
30 37 if (!$factory)
31 38 {
@@ -32,13 +39,21 @@
32 39 $factory = new \FPFramework\Base\Factory();
33 40 }
34 41 $this->factory = $factory;
35 42
36 - $this->trackConversions();
37 -
43 + /**
44 + * The cookie is read and cleared now — deleting it requires headers that have not
45 + * been sent yet — but the inserts it asks for run on shutdown, off the render path.
46 + */
47 + if ($conversions = $this->consumeConversionsCookie())
48 + {
49 + $this->pendingConversions = $conversions;
50 + add_action('shutdown', [$this, 'trackPendingConversions']);
51 + }
52 +
38 53 $this->setupAjax();
39 54 }
40 -
55 +
41 56 /**
42 57 * Setup ajax requests
43 58 *
44 59 * @return void
@@ -51,33 +66,118 @@
51 66
52 67 // FB Track conversion AJAX
53 68 add_action('wp_ajax_firebox_trackconversion', [$this, 'firebox_trackconversion']);
54 69 add_action('wp_ajax_nopriv_firebox_trackconversion', [$this, 'firebox_trackconversion']);
70 +
71 + // FB Refresh nonce AJAX
72 + add_action('wp_ajax_firebox_refresh_nonce', [$this, 'firebox_refresh_nonce']);
73 + add_action('wp_ajax_nopriv_firebox_refresh_nonce', [$this, 'firebox_refresh_nonce']);
55 74 }
56 75
57 76 /**
77 + * Reads the conversions cookie and deletes it.
78 + *
79 + * @return array|false The conversions the cookie asks us to record, false when there are none.
80 + */
81 + protected function consumeConversionsCookie()
82 + {
83 + $key = \FireBox\Core\Helpers\BoxHelper::cookiePrefix() . 'conversions_tracker';
84 +
85 + // Get conversions
86 + $conversions = isset($_COOKIE[$key]) ? json_decode(sanitize_text_field(wp_unslash($_COOKIE[$key])), true) : false;
87 + if (!$conversions || !is_array($conversions))
88 + {
89 + return false;
90 + }
91 +
92 + // Delete cookie
93 + unset($_COOKIE[$key]);
94 +
95 + foreach (\FireBox\Core\Helpers\BoxHelper::cookiePathsToClear() as $path)
96 + {
97 + setcookie($key, '', time() - 3600, $path, \FireBox\Core\Helpers\BoxHelper::cookieDomain());
98 + }
99 +
100 + return $conversions;
101 + }
102 +
103 + /**
104 + * Writes the conversions queued during this request, after the response has been sent.
105 + *
106 + * @return void
107 + */
108 + public function trackPendingConversions()
109 + {
110 + if (!$this->pendingConversions)
111 + {
112 + return;
113 + }
114 +
115 + $conversions = $this->pendingConversions;
116 + $this->pendingConversions = [];
117 +
118 + /**
119 + * Hand the response back to the visitor before writing rows. On other SAPIs the
120 + * work still happens after the page has been fully generated.
121 + */
122 + if (function_exists('fastcgi_finish_request'))
123 + {
124 + fastcgi_finish_request();
125 + }
126 + else if (function_exists('litespeed_finish_request'))
127 + {
128 + litespeed_finish_request();
129 + }
130 +
131 + $this->storeConversions($conversions);
132 + }
133 +
134 + /**
58 135 * Track Conversions
59 - *
136 + *
60 137 * @return void
61 138 */
62 139 public function trackConversions()
63 140 {
64 - $key = 'firebox_conversions_tracker';
65 -
66 - // Get conversions
67 - $conversions = isset($_COOKIE['firebox_conversions_tracker']) ? json_decode(sanitize_text_field(wp_unslash($_COOKIE['firebox_conversions_tracker'])), true) : false;
68 - if (!$conversions)
141 + if (!$conversions = $this->consumeConversionsCookie())
69 142 {
70 143 return;
71 144 }
72 145
73 - // Delete cookie
74 - unset($_COOKIE[$key]);
75 - setcookie($key, '', time() - 3600, "/");
146 + $this->storeConversions($conversions);
147 + }
76 148
149 + /**
150 + * Stores the given conversions in the database.
151 + *
152 + * @param array $conversions
153 + *
154 + * @return void
155 + */
156 + protected function storeConversions($conversions)
157 + {
158 + /**
159 + * The cookie is written by our own JavaScript, which means it is entirely
160 + * client-controlled: its contents are a request for us to write analytics rows,
161 + * not a fact. Cap how many a single request can ask for so a crafted cookie
162 + * cannot insert thousands of rows in one page load.
163 + */
164 + $max_conversions = (int) apply_filters('firebox/track/max_cookie_conversions', 20);
165 + $processed = 0;
166 +
77 167 // Track conversions in db
78 168 foreach ($conversions as $campaign_id => $data)
79 169 {
170 + if ($processed >= $max_conversions)
171 + {
172 + break;
173 + }
174 +
175 + if (!is_array($data))
176 + {
177 + continue;
178 + }
179 +
80 180 if (!isset($data['box_log_id']) || !$data['box_log_id'])
81 181 {
82 182 continue;
83 183 }
@@ -91,23 +191,105 @@
91 191 {
92 192 continue;
93 193 }
94 194
195 + /**
196 + * Only record the conversion when the impression it claims to belong to
197 + * actually exists for this campaign.
198 + */
199 + if (!firebox()->tables->boxlog->belongsToCampaign($data['box_log_id'], $campaign_id))
200 + {
201 + continue;
202 + }
203 +
204 + if (!self::isAllowedEventSource($data['source']))
205 + {
206 + continue;
207 + }
208 +
95 209 $data = [
96 - 'log_id' => $data['box_log_id'],
210 + 'log_id' => (int) $data['box_log_id'],
97 211 'event' => 'conversion',
98 - 'event_source' => $data['source'],
99 - 'event_label' => $data['label'],
212 + 'event_source' => sanitize_text_field($data['source']),
213 + 'event_label' => self::sanitizeEventLabel($data['label']),
100 214 'date' => $this->factory->getDate()->format('Y-m-d H:i:s')
101 215 ];
102 216
103 217 firebox()->tables->boxlogdetails->insert($data);
218 +
219 + $processed++;
104 220 }
105 221 }
222 +
106 223 /**
224 + * Normalises a client-supplied URL before it is stored against an impression.
225 + *
226 + * @param mixed $url
227 + *
228 + * @return string
229 + */
230 + public static function sanitizeTrackedUrl($url)
231 + {
232 + if (!is_scalar($url))
233 + {
234 + return '';
235 + }
236 +
237 + $url = esc_url_raw(trim((string) $url), ['http', 'https']);
238 +
239 + if ($url === '')
240 + {
241 + return '';
242 + }
243 +
244 + return function_exists('mb_substr') ? mb_substr($url, 0, 255) : substr($url, 0, 255);
245 + }
246 +
247 + /**
248 + * Returns whether the given event source is one we recognise.
249 + *
250 + * @param mixed $source
251 + *
252 + * @return bool
253 + */
254 + public static function isAllowedEventSource($source)
255 + {
256 + if (!is_string($source) || $source === '')
257 + {
258 + return false;
259 + }
260 +
261 + /**
262 + * The client tracker sends 'form', 'button', 'image' or the clicked element's
263 + * tag name, so a fixed allowlist would drop legitimate conversions. Accept any
264 + * short slug-shaped token instead; that still rejects markup, URLs and padding.
265 + */
266 + return (bool) preg_match('/^[a-z][a-z0-9_-]{0,29}$/', $source);
267 + }
268 +
269 + /**
270 + * Trims an event label to something safe to store.
271 + *
272 + * @param mixed $label
273 + *
274 + * @return string
275 + */
276 + public static function sanitizeEventLabel($label)
277 + {
278 + if (!is_scalar($label))
279 + {
280 + return '';
281 + }
282 +
283 + $label = sanitize_text_field((string) $label);
284 +
285 + // Keep well inside the column width regardless of what was submitted.
286 + return function_exists('mb_substr') ? mb_substr($label, 0, 200) : substr($label, 0, 200);
287 + }
288 + /**
107 289 * Box Track Event
108 - *
109 - * @return string
290 + *
291 + * @return void
110 292 */
111 293 public function firebox_trackevent()
112 294 {
113 295 $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '';
@@ -114,9 +296,9 @@
114 296
115 297 // verify nonce
116 298 if (!$verify = wp_verify_nonce($nonce, 'fbox_js_nonce'))
117 299 {
118 - return false;
300 + self::dieWithInvalidNonce();
119 301 }
120 302
121 303 $event = isset($_GET['event']) ? sanitize_text_field(wp_unslash($_GET['event'])) : '';
122 304 $box_id = isset($_GET['box']) ? sanitize_text_field(wp_unslash($_GET['box'])) : '';
@@ -156,12 +338,18 @@
156 338 {
157 339 return;
158 340 }
159 341
160 - $page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : '';
161 - $referrer = isset($_GET['referrer']) ? sanitize_text_field(wp_unslash($_GET['referrer'])) : '';
342 + /**
343 + * These end up in the "Top Pages" and "Top Referrers" reports, so bound them
344 + * to something URL-shaped and within the column width rather than storing
345 + * whatever the query string contained.
346 + */
347 + $page = isset($_GET['page']) ? self::sanitizeTrackedUrl(wp_unslash($_GET['page'])) : '';
348 + $referrer = isset($_GET['referrer']) ? self::sanitizeTrackedUrl(wp_unslash($_GET['referrer'])) : '';
162 349 }
163 -
350 +
351 +
164 352 // Load box settings
165 353 if (!$box = firebox()->box->get($box_id))
166 354 {
167 355 return;
@@ -205,10 +393,10 @@
205 393 * @param object $box
206 394 * @param int $box_id
207 395 * @param string $page
208 396 * @param string $referrer
209 - *
210 - * @return void
397 + *
398 + * @return mixed
211 399 */
212 400 protected function handleOpenEvent($box, $box_id, $page, $referrer)
213 401 {
214 402 // Do not track when box is on test mode
@@ -233,9 +421,16 @@
233 421 {
234 422 // Do not track when box is on test mode
235 423 if (!$box->params->get('testmode'))
236 424 {
237 - firebox()->box->logCloseEvent($box_id, $box_log_id);
425 + /**
426 + * box_log_id comes from the client. Only stamp a close time on it when it
427 + * really is an impression of this campaign.
428 + */
429 + if (firebox()->tables->boxlog->belongsToCampaign($box_log_id, $box_id))
430 + {
431 + firebox()->box->logCloseEvent($box_id, $box_log_id);
432 + }
238 433 }
239 434
240 435 // Do not set any cookie if box is on test mode
241 436 if (!$box->params->get('testmode') && !isset($options['temporary']))
@@ -269,10 +464,10 @@
269 464 }
270 465
271 466 /**
272 467 * Box Track Conversion
273 - *
274 - * @return string
468 + *
469 + * @return void
275 470 */
276 471 public function firebox_trackconversion()
277 472 {
278 473 $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
@@ -279,10 +474,47 @@
279 474
280 475 // verify nonce
281 476 if (!$verify = wp_verify_nonce($nonce, 'fbox_js_nonce'))
282 477 {
283 - return false;
478 + self::dieWithInvalidNonce();
284 479 }
285 480
286 481 $this->trackConversions();
482 + }
483 +
484 + /**
485 + * Hands the client a fresh nonce.
486 + *
487 + * Page caches routinely outlive the 12–24h nonce baked into the cached HTML, which
488 + * would otherwise make tracking fail silently. No nonce is required to call this: it
489 + * exposes nothing the page HTML does not already expose.
490 + *
491 + * This is deliberately not throttled. All it does is hand back a token the page HTML
492 + * already carries, so there is nothing here worth rationing — while throttling it
493 + * risks the opposite: on a busy cached site a nonce rollover sends a burst of
494 + * visitors here at once, and turning any of them away loses their tracking silently.
495 + *
496 + * @return void
497 + */
498 + public function firebox_refresh_nonce()
499 + {
500 + echo wp_json_encode([
501 + 'success' => true,
502 + 'nonce' => wp_create_nonce('fbox_js_nonce')
503 + ]);
504 + wp_die();
505 + }
506 +
507 + /**
508 + * Tells the client its nonce is stale, so it can refresh it and replay the request.
509 + *
510 + * @return void
511 + */
512 + protected static function dieWithInvalidNonce()
513 + {
514 + echo wp_json_encode([
515 + 'success' => false,
516 + 'error' => 'invalid_nonce'
517 + ]);
518 + wp_die();
287 519 }
288 520 }