| @@ -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 | } |