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