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 +355 -30 1.0.7 → 3.1.14 View file →
@@ -1,12 +1,12 @@
1 1 <?php
2 2 /**
3 3 * @package FireBox
4 - * @version 1.0.7 Free
4 + * @version 3.1.14 Free
5 5 *
6 6 * @author FirePlugins <[email protected]>
7 7 * @link https://www.fireplugins.com
8 - * @copyright Copyright © 2021 FirePlugins All Rights Reserved
8 + * @copyright Copyright © 2026 FirePlugins All Rights Reserved
9 9 * @license GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
10 10 */
11 11
12 12 namespace FireBox\Core\FB;
@@ -17,13 +17,43 @@
17 17 }
18 18
19 19 class Track
20 20 {
21 - public function __construct()
22 - {
21 + /**
22 + * Factory
23 + *
24 + * @var Factory
25 + */
26 + private $factory;
27 +
28 + /**
29 + * Conversions read from the cookie, waiting to be written on shutdown.
30 + *
31 + * @var array
32 + */
33 + private $pendingConversions = [];
34 +
35 + public function __construct($factory = null)
36 + {
37 + if (!$factory)
38 + {
39 + $factory = new \FPFramework\Base\Factory();
40 + }
41 + $this->factory = $factory;
42 +
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 +
23 53 $this->setupAjax();
24 54 }
25 -
55 +
26 56 /**
27 57 * Setup ajax requests
28 58 *
29 59 * @return void
@@ -30,29 +60,249 @@
30 60 */
31 61 public function setupAjax()
32 62 {
33 63 // FB Track event AJAX
34 - add_action('wp_ajax_fb_trackevent', [$this, 'fb_trackevent']);
35 - add_action('wp_ajax_nopriv_fb_trackevent', [$this, 'fb_trackevent']);
64 + add_action('wp_ajax_firebox_trackevent', [$this, 'firebox_trackevent']);
65 + add_action('wp_ajax_nopriv_firebox_trackevent', [$this, 'firebox_trackevent']);
66 +
67 + // FB Track conversion AJAX
68 + add_action('wp_ajax_firebox_trackconversion', [$this, 'firebox_trackconversion']);
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']);
36 74 }
37 -
75 +
38 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 + /**
135 + * Track Conversions
136 + *
137 + * @return void
138 + */
139 + public function trackConversions()
140 + {
141 + if (!$conversions = $this->consumeConversionsCookie())
142 + {
143 + return;
144 + }
145 +
146 + $this->storeConversions($conversions);
147 + }
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 +
167 + // Track conversions in db
168 + foreach ($conversions as $campaign_id => $data)
169 + {
170 + if ($processed >= $max_conversions)
171 + {
172 + break;
173 + }
174 +
175 + if (!is_array($data))
176 + {
177 + continue;
178 + }
179 +
180 + if (!isset($data['box_log_id']) || !$data['box_log_id'])
181 + {
182 + continue;
183 + }
184 +
185 + if (!isset($data['source']))
186 + {
187 + continue;
188 + }
189 +
190 + if (!isset($data['label']))
191 + {
192 + continue;
193 + }
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 +
209 + $data = [
210 + 'log_id' => (int) $data['box_log_id'],
211 + 'event' => 'conversion',
212 + 'event_source' => sanitize_text_field($data['source']),
213 + 'event_label' => self::sanitizeEventLabel($data['label']),
214 + 'date' => $this->factory->getDate()->format('Y-m-d H:i:s')
215 + ];
216 +
217 + firebox()->tables->boxlogdetails->insert($data);
218 +
219 + $processed++;
220 + }
221 + }
222 +
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 + /**
39 289 * Box Track Event
40 - *
41 - * @return string
290 + *
291 + * @return void
42 292 */
43 - public function fb_trackevent()
293 + public function firebox_trackevent()
44 294 {
45 - $nonce = isset($_GET['nonce']) ? sanitize_text_field($_GET['nonce']) : '';
295 + $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '';
46 296
47 297 // verify nonce
48 298 if (!$verify = wp_verify_nonce($nonce, 'fbox_js_nonce'))
49 299 {
50 - return false;
300 + self::dieWithInvalidNonce();
51 301 }
52 302
53 - $event = isset($_GET['event']) ? sanitize_text_field($_GET['event']) : '';
54 - $box_id = isset($_GET['box']) ? sanitize_text_field($_GET['box']) : '';
303 + $event = isset($_GET['event']) ? sanitize_text_field(wp_unslash($_GET['event'])) : '';
304 + $box_id = isset($_GET['box']) ? sanitize_text_field(wp_unslash($_GET['box'])) : '';
55 305 $page = '';
56 306 $referrer = '';
57 307 $box_log_id = '';
58 308
@@ -60,9 +310,9 @@
60 310
61 311 // ensure non-empty values
62 312 if (empty($event) || empty($box_id))
63 313 {
64 - echo json_encode($response);
314 + echo wp_json_encode($response);
65 315 wp_die();
66 316 }
67 317
68 318 // ensure valid event
@@ -67,9 +317,9 @@
67 317
68 318 // ensure valid event
69 319 if (!in_array($event, ['open', 'close']))
70 320 {
71 - echo json_encode($response);
321 + echo wp_json_encode($response);
72 322 wp_die();
73 323 }
74 324
75 325 // If its a close event, it should also provide the box log ID in order to add close date of box
@@ -79,9 +329,9 @@
79 329 {
80 330 return;
81 331 }
82 332
83 - $box_log_id = sanitize_text_field($_GET['box_log_id']);
333 + $box_log_id = sanitize_text_field(wp_unslash($_GET['box_log_id']));
84 334 }
85 335 else if ($event == 'open')
86 336 {
87 337 if (!isset($_GET['page']) && !isset($_GET['referrer']))
@@ -88,12 +338,18 @@
88 338 {
89 339 return;
90 340 }
91 341
92 - $page = sanitize_text_field($_GET['page']);
93 - $referrer = sanitize_text_field($_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'])) : '';
94 349 }
95 -
350 +
351 +
96 352 // Load box settings
97 353 if (!$box = firebox()->box->get($box_id))
98 354 {
99 355 return;
@@ -98,18 +354,21 @@
98 354 {
99 355 return;
100 356 }
101 357
358 + // Don't track events on unpublished campaigns
359 + $track_unpublished = apply_filters('firebox/track_unpublished', false);
360 + if (!$track_unpublished && $box->post_status !== 'publish')
361 + {
362 + return;
363 + }
364 +
102 365 /**
103 366 * Trigger Open & Close Event.
104 367 */
105 368 do_action('firebox/box/on_' . $event, $box);
106 369
107 - // get options
108 - $options = isset($_GET['options']) ? wp_unslash($_GET['options']) : [];
109 -
110 370 $response['success'] = true;
111 -
112 371 if ($event == 'open')
113 372 {
114 373 $response['box_log_id'] = $this->handleOpenEvent($box, $box_id, $page, $referrer);
115 374 }
@@ -116,12 +375,16 @@
116 375
117 376 // Log impression in the database
118 377 if ($event == 'close')
119 378 {
379 + // get options
380 + $options = (isset($_GET['options']) && is_array($_GET['options']))
381 + ? array_map('sanitize_text_field', wp_unslash($_GET['options']))
382 + : [];
120 383 $this->handleCloseEvent($box, $box_id, $box_log_id, $options, $response);
121 384 }
122 385
123 - echo json_encode($response);
386 + echo wp_json_encode($response);
124 387 wp_die();
125 388 }
126 389
127 390 /**
@@ -130,10 +393,10 @@
130 393 * @param object $box
131 394 * @param int $box_id
132 395 * @param string $page
133 396 * @param string $referrer
134 - *
135 - * @return void
397 + *
398 + * @return mixed
136 399 */
137 400 protected function handleOpenEvent($box, $box_id, $page, $referrer)
138 401 {
139 402 // Do not track when box is on test mode
@@ -158,9 +421,16 @@
158 421 {
159 422 // Do not track when box is on test mode
160 423 if (!$box->params->get('testmode'))
161 424 {
162 - 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 + }
163 433 }
164 434
165 435 // Do not set any cookie if box is on test mode
166 436 if (!$box->params->get('testmode') && !isset($options['temporary']))
@@ -186,10 +456,65 @@
186 456 * @return bool
187 457 */
188 458 protected function cookieExist($box_id)
189 459 {
190 - $cookie = new \FireBox\Core\FB\Cookie($box_id);
460 + $cookie = new \FireBox\Core\FB\Cookie(firebox()->box->get($box_id));
191 461 $cookie->set();
192 462
193 463 return $cookie->exist();
464 + }
465 +
466 + /**
467 + * Box Track Conversion
468 + *
469 + * @return void
470 + */
471 + public function firebox_trackconversion()
472 + {
473 + $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
474 +
475 + // verify nonce
476 + if (!$verify = wp_verify_nonce($nonce, 'fbox_js_nonce'))
477 + {
478 + self::dieWithInvalidNonce();
479 + }
480 +
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();
194 519 }
195 520 }