PluginProbe
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment / 3.1.13
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment v3.1.13
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 1.1.1 All 122 releases
firebox / Inc / Core / FB / Track.php

Track.php in FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment 3.1.13, at Inc/Core/FB/Track.php

520 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package FireBox
4 * @version 3.1.13 Free
5 *
6 * @author FirePlugins <info@fireplugins.com>
7 * @link https://www.fireplugins.com
8 * @copyright Copyright © 2026 FirePlugins All Rights Reserved
9 * @license GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
10 */
11
12 namespace FireBox\Core\FB;
13
14 if (!defined('ABSPATH'))
15 {
16 exit; // Exit if accessed directly.
17 }
18
19 class Track
20 {
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
53 $this->setupAjax();
54 }
55
56 /**
57 * Setup ajax requests
58 *
59 * @return void
60 */
61 public function setupAjax()
62 {
63 // FB Track event AJAX
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']);
74 }
75
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 /**
289 * Box Track Event
290 *
291 * @return void
292 */
293 public function firebox_trackevent()
294 {
295 $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '';
296
297 // verify nonce
298 if (!$verify = wp_verify_nonce($nonce, 'fbox_js_nonce'))
299 {
300 self::dieWithInvalidNonce();
301 }
302
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'])) : '';
305 $page = '';
306 $referrer = '';
307 $box_log_id = '';
308
309 $response['success'] = false;
310
311 // ensure non-empty values
312 if (empty($event) || empty($box_id))
313 {
314 echo wp_json_encode($response);
315 wp_die();
316 }
317
318 // ensure valid event
319 if (!in_array($event, ['open', 'close']))
320 {
321 echo wp_json_encode($response);
322 wp_die();
323 }
324
325 // If its a close event, it should also provide the box log ID in order to add close date of box
326 if ($event == 'close')
327 {
328 if (!isset($_GET['box_log_id']))
329 {
330 return;
331 }
332
333 $box_log_id = sanitize_text_field(wp_unslash($_GET['box_log_id']));
334 }
335 else if ($event == 'open')
336 {
337 if (!isset($_GET['page']) && !isset($_GET['referrer']))
338 {
339 return;
340 }
341
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'])) : '';
349 }
350
351
352 // Load box settings
353 if (!$box = firebox()->box->get($box_id))
354 {
355 return;
356 }
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
365 /**
366 * Trigger Open & Close Event.
367 */
368 do_action('firebox/box/on_' . $event, $box);
369
370 $response['success'] = true;
371 if ($event == 'open')
372 {
373 $response['box_log_id'] = $this->handleOpenEvent($box, $box_id, $page, $referrer);
374 }
375
376 // Log impression in the database
377 if ($event == 'close')
378 {
379 // get options
380 $options = (isset($_GET['options']) && is_array($_GET['options']))
381 ? array_map('sanitize_text_field', wp_unslash($_GET['options']))
382 : [];
383 $this->handleCloseEvent($box, $box_id, $box_log_id, $options, $response);
384 }
385
386 echo wp_json_encode($response);
387 wp_die();
388 }
389
390 /**
391 * Handle Open Event
392 *
393 * @param object $box
394 * @param int $box_id
395 * @param string $page
396 * @param string $referrer
397 *
398 * @return mixed
399 */
400 protected function handleOpenEvent($box, $box_id, $page, $referrer)
401 {
402 // Do not track when box is on test mode
403 if (!$box->params->get('testmode'))
404 {
405 return firebox()->box->logOpenEvent($box_id, $page, $referrer);
406 }
407 }
408
409 /**
410 * Handle Close Event
411 *
412 * @param object $box
413 * @param int $box_id
414 * @param int $box_log_id
415 * @param array $options
416 * @param array $response
417 *
418 * @return void
419 */
420 protected function handleCloseEvent($box, $box_id, $box_log_id, $options, &$response)
421 {
422 // Do not track when box is on test mode
423 if (!$box->params->get('testmode'))
424 {
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 }
433 }
434
435 // Do not set any cookie if box is on test mode
436 if (!$box->params->get('testmode') && !isset($options['temporary']))
437 {
438 // allow to prevent cookie from being set
439 if (!apply_filters('firebox/box/close/cookie_set', true, $box_id))
440 {
441 return;
442 }
443
444 if ($this->cookieExist($box_id))
445 {
446 $response['action'] = 'stop';
447 }
448 }
449 }
450
451 /**
452 * Check whether the cookie exists.
453 *
454 * @param int $box_id
455 *
456 * @return bool
457 */
458 protected function cookieExist($box_id)
459 {
460 $cookie = new \FireBox\Core\FB\Cookie(firebox()->box->get($box_id));
461 $cookie->set();
462
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();
519 }
520 }