1(function () { 2 'use strict'; 3 4 let searchInitialized = false; 5 let index = null; 6 let indexName = ''; 7 let searchTimeout = null; 8 let keyRefreshInFlight = false; 9 10 const HIGHLIGHT_PRE = '__bf-hl__'; 11 const HIGHLIGHT_POST = '__/bf-hl__'; 12 13 const dataEl = document.getElementById( 'algolia-search-data' ); 14 const suggestionTexts = dataEl ? JSON.parse( dataEl.getAttribute( 'data-suggestions' ) ) : []; 15 const noResultsText = dataEl ? dataEl.getAttribute( 'data-no-results' ) : ''; 16 const aiEnabled = dataEl ? dataEl.getAttribute( 'data-ai-enabled' ) === '1' : false; 17 const aiAssistantId = dataEl ? dataEl.getAttribute( 'data-ai-assistant-id' ) : ''; 18 const aiAgentStudio = dataEl ? dataEl.getAttribute( 'data-ai-agent-studio' ) === '1' : true; 19 20 // Algolia SiteSearch Ask AI CDN bundle. Lazy-loaded only when the user clicks 21 // the AI mode button to keep the header free of unused JS for visitors who 22 // never engage with AI search. 23 // Version is pinned (not @latest) to avoid supply-chain risk from a 24 // compromised npm publish landing instantly on all visitors. Bump 25 // intentionally after reviewing the changelog. 26 const ASKAI_VERSION = '1.0.14'; 27 const ASKAI_CSS = 'https://unpkg.com/@algolia/sitesearch@' + ASKAI_VERSION + '/dist/search-askai.min.css'; 28 const ASKAI_JS = 'https://unpkg.com/@algolia/sitesearch@' + ASKAI_VERSION + '/dist/search-askai.min.js'; 29 let askAiLoader = null; 30 let askAiContainerId = null; 31 // Guard: track whether init() has run so we can destroy-then-reinit on 32 // subsequent clicks rather than stacking duplicate React trees. We always 33 // reinit (rather than skip init) because the Algolia bundle accumulates 34 // conversation history in React state; a fresh init resets that state, 35 // preventing the "data-suggestions" schema error on the second search. 36 let askAiInitialized = false; 37 38 const SEARCH_ICON_SVG = 39 '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">' + 40 '<path fill-rule="evenodd" clip-rule="evenodd" d="M6.33 0.583a5.747 5.747 0 1 0 0 11.494 ' + 41 '5.747 5.747 0 0 0 0-11.494ZM1.75 6.33a4.58 4.58 0 1 1 9.16 0 4.58 4.58 0 0 1-9.16 0Z" fill="currentColor"/>' + 42 '<path fill-rule="evenodd" clip-rule="evenodd" d="M10.39 10.39a.583.583 0 0 1 .825 0l2.202 ' + 43 '2.202a.583.583 0 1 1-.825.825l-2.202-2.202a.583.583 0 0 1 0-.825Z" fill="currentColor"/></svg>'; 44 45 /** 46 * Maximum allowed search query length. Legitimate casino/brand names 47 * rarely exceed 60 characters; injection payloads are typically much 48 * longer. This caps queries before they reach Algolia. 49 */ 50 const MAX_QUERY_LENGTH = 100; 51 52 /** 53 * Patterns that indicate bot/injection attacks rather than genuine 54 * search queries. Each regex is case-insensitive. When a query matches 55 * any pattern it is silently dropped â no Algolia API call is made. 56 * 57 * Covers the attack signatures catalogued in BCR-401 / BCR-434: 58 * - SQL injection (MySQL comment bypass, UNION SELECT, CAST, etc.) 59 * - RCE probes (ThinkPHP invokefunction) 60 * - CGI scanner fingerprints (search.cgi, show.pl, etc.) 61 * - XSS probes (<script>, onerror=, javascript:, etc.) 62 * - PHP shell / path traversal patterns 63 */ 64 const MALICIOUS_PATTERNS = [ 65 // SQL injection. 66 /\/\*[!*]/, 67 /union\s+select/i, 68 /\bcast\s*\(/i, 69 /floor\s*\(\s*rand/i, 70 /\bselect\s+.*\bfrom\b/i, 71 /\binsert\s+into\b/i, 72 /\bdrop\s+table\b/i, 73 /\bor\s+1\s*=\s*1/i, 74 /\band\s+1\s*=\s*1/i, 75 /\bwaitfor\s+delay\b/i, 76 /\bbenchmark\s*\(/i, 77 /\bsleep\s*\(/i, 78 /0x[0-9a-f]{8,}/i, 79 80 // RCE probes (ThinkPHP and similar). 81 /invokefunction/i,
82 /\\think\\/i, 83 /thinkphp/i, 84 85 // CGI scanner fingerprints. 86 /search\.cgi/i, 87 /show\.pl\b/i, 88 /results\.stm/i, 89 /webglimpse\.cgi/i, 90 /\.asp\b/i, 91 /\.aspx\b/i, 92 /\.jsp\b/i, 93 94 // XSS probes. 95 /<\s*script/i, 96 /onerror\s*=/i, 97 /onload\s*=/i, 98 /javascript\s*:/i, 99 /'\s*><\s*/, 100 /\balert\s*\(/i, 101 /\bprompt\s*\(/i, 102 /\bconfirm\s*\(/i, 103 /\bdocument\s*\.\s*cookie/i, 104 105 // PHP shell / path traversal / external domain noise. 106 /php-shell/i, 107 /\/etc\/passwd/i, 108 /\.\.\//, 109 /\beval\s*\(/i, 110 /\bexec\s*\(/i, 111 /\bsystem\s*\(/i, 112 /\bpassthru\s*\(/i, 113 /\.php\b/i, 114 ]; 115 116 /** 117 * Returns true when the query looks like a bot/injection probe. 118 * 119 * @param {string} query Trimmed search input. 120 * @return {boolean} 121 */ 122 function isMaliciousQuery(query) { 123 if (query.length > MAX_QUERY_LENGTH) { 124 return true; 125 } 126 for (let i = 0; i < MALICIOUS_PATTERNS.length; i++) { 127 if (MALICIOUS_PATTERNS[i].test(query)) { 128 return true; 129 } 130 } 131 return false; 132 } 133 134 function escapeHtml(str) { 135 const el = document.createElement('span'); 136 el.appendChild(document.createTextNode(str)); 137 return el.innerHTML; 138 } 139 140 function initSearch() { 141 if (searchInitialized) { 142 return; 143 } 144 if ( 145 typeof algoliasearch === 'undefined' || 146 typeof algolia === 'undefined' 147 ) { 148 return; 149 } 150 151 const client = algoliasearch( 152 algolia.application_id, 153 algolia.search_api_key 154 ); 155 indexName = ''; 156 157 if ( 158 algolia.autocomplete && 159 algolia.autocomplete.sources && 160 algolia.autocomplete.sources.length > 0 161 ) { 162 indexName = algolia.autocomplete.sources[0].index_name; 163 } else if (algolia.indices && algolia.indices.searchable_posts) { 164 indexName = algolia.indices.searchable_posts.name; 165 } 166 167 if (!indexName) { 168 return; 169 } 170 171 index = client.initIndex(indexName); 172 searchInitialized = true; 173 174 setupSearchBehavior(); 175 } 176
177 function renderSuggestionsEarly() { 178 const suggestionsList = document.getElementById('search-suggestions'); 179 const searchInput = document.getElementById('search-input'); 180 if (suggestionsList && searchInput) { 181 renderSuggestions(suggestionsList, searchInput); 182 } 183 } 184 185 function loadAskAiBundle() { 186 if ( askAiLoader ) { 187 return askAiLoader; 188 } 189 askAiLoader = new Promise( function ( resolve, reject ) { 190 if ( typeof window.algolia === 'undefined' ) { 191 reject( new Error( 'Algolia config (window.algolia) is missing.' ) ); 192 return; 193 } 194 if ( ! document.querySelector( 'link[data-bf-askai="1"]' ) ) { 195 const link = document.createElement( 'link' ); 196 link.rel = 'stylesheet'; 197 link.href = ASKAI_CSS; 198 link.setAttribute( 'data-bf-askai', '1' ); 199 document.head.appendChild( link ); 200 } 201 if ( typeof window.SiteSearchAskAI !== 'undefined' ) { 202 resolve(); 203 return; 204 } 205 const script = document.createElement( 'script' ); 206 script.src = ASKAI_JS; 207 script.async = true; 208 script.setAttribute( 'data-bf-askai', '1' ); 209 script.onload = function () { resolve(); }; 210 script.onerror = function () { reject( new Error( 'Failed to load Algolia Ask AI bundle.' ) ); }; 211 document.head.appendChild( script ); 212 } ); 213 return askAiLoader; 214 } 215 216 function ensureAskAiContainer() { 217 if ( askAiContainerId && document.getElementById( askAiContainerId ) ) { 218 return askAiContainerId; 219 } 220 askAiContainerId = 'bf-askai-container'; 221 if ( ! document.getElementById( askAiContainerId ) ) { 222 const div = document.createElement( 'div' ); 223 div.id = askAiContainerId; 224 // The bundle renders an "Open search" trigger button inside the 225 // container that we click programmatically â keep it visually 226 // hidden so visitors only see our own header button. 227 div.style.cssText = 'position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;'; 228 document.body.appendChild( div ); 229 } 230 return askAiContainerId; 231 } 232 233 // Poll the DOM for an element, resolving with it or null after the timeout. 234 function waitForElement( selector, timeoutMs ) { 235 return new Promise( function ( resolve ) { 236 const deadline = Date.now() + ( timeoutMs || 2000 ); 237 const tick = function () { 238 const el = document.querySelector( selector ); 239 if ( el ) { resolve( el ); return; } 240 if ( Date.now() > deadline ) { resolve( null ); return; } 241 requestAnimationFrame( tick ); 242 }; 243 tick(); 244 } ); 245 } 246 247 // Drive the SiteSearchAskAI modal into chat mode. The bundle gates chat on 248 // a non-empty trimmed query (see setShowChat call site), so the path that 249 // actually works is: focus the input, set a value, dispatch Enter. 250 function enterChatMode( initialQuery ) { 251 const selector = '.ss-searchbox-form input, .ss-searchbox-form [contenteditable="true"]'; 252 waitForElement( selector, 2500 ).then( function ( firstInput ) { 253 if ( ! firstInput ) { return; } 254 firstInput.focus(); 255 const query = ( initialQuery || '' ).trim(); 256 if ( ! query ) { 257 // Without a query we cannot programmatically enter chat mode â 258 // the bundle refuses. Leave the input focused so the user can 259 // type and the very next Enter puts them straight into chat. 260 return; 261 } 262 263 const proto = window.HTMLInputElement && window.HTMLInputElement.prototype; 264 const setter = proto && Object.getOwnPropertyDescriptor( proto, 'value' ); 265 266 // Set the value via the native setter so React's controlled input 267 // picks up the change (a plain assignment leaves React state empty). 268 function injectValue( el ) { 269 try { 270 if ( setter && setter.set && 'INPUT' === el.tagName ) { 271 setter.set.call( el, query ); 272 } else { 273 el.value = query; 274 } 275 el.dispatchEvent( new Event( 'input', { bubbles: true } ) ); 276 } catch ( e ) { 277 // Any Algolia SDK change to the input/React handler could 278 // break this â degrade to a focused, user-typable modal. 279 console.warn( '[bf] Ask AI value injection failed:', e ); 280 } 281 } 282 283 // The bundle's keydown handler reads the current trimmed value. 284 function submitEnter( el ) { 285 try { 286 el.dispatchEvent( new KeyboardEvent( 'keydown', { 287 key: 'Enter', 288 code: 'Enter', 289 keyCode: 13, 290 which: 13, 291 bubbles: true 292 } ) ); 293 } catch ( e ) { 294 // Synthetic Enter failed â the input is pre-filled; the 295 // user can press Enter manually to submit. 296 console.warn( '[bf] Ask AI chat-mode Enter dispatch failed:', e ); 297 } 298 } 299 300 // On the FIRST open the searchbox is freshly mounted and React's 301 // change handler may not be wired yet, so a single injection is 302 // silently reset to empty â the query is lost and chat opens blank 303 // (works on the 2nd, warm open). Retry until the value sticks, then 304 // submit. Re-query each attempt in case the node was re-rendered. 305 let attempts = 0; 306 const maxAttempts = 15; 307 function attempt() { 308 const el = document.querySelector( selector ) || firstInput; 309 if ( 'INPUT' !== el.tagName ) { 310 // contenteditable / unknown â best-effort single shot. 311 injectValue( el ); 312 submitEnter( el ); 313 return; 314 } 315 el.focus(); 316 injectValue( el ); 317 setTimeout( function () { 318 const current = document.querySelector( selector ) || el; 319 if ( current.value !== query && attempts < maxAttempts ) { 320 attempts++; 321 attempt(); 322 return; 323 } 324 submitEnter( current ); 325 }, 60 ); 326 } 327 attempt(); 328 } ); 329 } 330 331 function openAskAi( opts ) { 332 opts = opts || {}; 333 const assistantId = opts.assistantId || aiAssistantId; 334 const agentStudio = typeof opts.agentStudio === 'boolean' ? opts.agentStudio : aiAgentStudio; 335 if ( ! assistantId ) { 336 console.warn( '[bf] No Algolia Ask AI Assistant ID configured.' ); 337 return; 338 } 339 if ( typeof window.algolia === 'undefined' ) { 340 console.warn( '[bf] window.algolia is not available; cannot open Ask AI.' ); 341 return; 342 } 343 344 let indexName = ''; 345 if ( window.algolia.autocomplete && window.algolia.autocomplete.sources && window.algolia.autocomplete.sources.length > 0 ) { 346 indexName = window.algolia.autocomplete.sources[ 0 ].index_name; 347 } else if ( window.algolia.indices && window.algolia.indices.searchable_posts ) { 348 indexName = window.algolia.indices.searchable_posts.name; 349 } 350 351 loadAskAiBundle().then( function () { 352 if ( typeof window.SiteSearchAskAI === 'undefined' ) { 353 return; 354 } 355 const containerId = ensureAskAiContainer(); 356 357 // Destroy the previous React tree before reinitialising. This is 358 // intentional: the Algolia bundle accumulates conversation history 359 // in React state and re-sends it on subsequent turns. The server's
360 // schema validator rejects the "data-suggestions" part type, 361 // causing a 422 on the second search (see bundle analysis). A 362 // fresh init resets the state tree cleanly. The bundle's exported 363 // destroy() takes the container selector or element. 364 if ( askAiInitialized && typeof window.SiteSearchAskAI.destroy === 'function' ) { 365 try { window.SiteSearchAskAI.destroy( '#' + containerId ); } catch ( e ) { /* ignore */ } 366 } 367 // Reset the loader promise so the next click re-mounts correctly 368 // (the bundle is already in memory; we only reset the Preact tree). 369 askAiInitialized = false; 370 371 window.SiteSearchAskAI.init( { 372 container: '#' + containerId, 373 applicationId: window.algolia.application_id, 374 apiKey: window.algolia.search_api_key, 375 indexName: indexName, 376 assistantId: assistantId, 377 agentStudio: agentStudio, 378 attributes: { 379 primaryText: 'post_title', 380 tertiaryText: 'post_author.display_name', 381 url: 'permalink', 382 image: 'images.thumbnail.url' 383 }, 384 buttonText: 'AI mode' 385 } ); 386 askAiInitialized = true; 387 388 // Click the bundle's own trigger button to open the modal. We wait 389 // for it to mount because init() renders asynchronously. 390 waitForElement( '#' + containerId + ' [aria-label="Open search"]', 2000 ).then( function ( trigger ) { 391 if ( trigger ) { trigger.click(); } 392 enterChatMode( opts.initialQuery ); 393 } ); 394 } ).catch( function ( err ) { 395 console.error( '[bf] Failed to open Algolia Ask AI:', err ); 396 } ); 397 } 398 399 // Expose the opener so it can be invoked from other entry points (e.g. the 400 // InstantSearch results page) without duplicating the loader logic. 401 window.bfOpenAlgoliaAskAi = openAskAi; 402 403 function setupAiToggle() { 404 if ( ! aiEnabled ) { 405 return; 406 } 407 const aiToggle = document.getElementById( 'search-ai-mode-toggle' ); 408 if ( ! aiToggle || aiToggle.dataset.bfBound === '1' ) { 409 return; 410 } 411 aiToggle.dataset.bfBound = '1'; 412 aiToggle.addEventListener( 'click', function ( e ) { 413 e.preventDefault(); 414 const searchInput = document.getElementById( 'search-input' ); 415 openAskAi( { initialQuery: searchInput ? searchInput.value : '' } ); 416 } ); 417 } 418 419 function setupSearchBehavior() { 420 const searchInput = document.getElementById('search-input'); 421 const emptyState = document.getElementById('search-empty-state'); 422 const resultsState = document.getElementById('search-results-state'); 423 const resultsList = document.getElementById('search-results-list'); 424 const resultsCount = document.getElementById('search-results-count'); 425 const searchForm = document.querySelector('.menu-search-form'); 426 427 if (!searchInput || !searchForm) { 428 return; 429 } 430 431 searchInput.addEventListener('input', function () { 432 const query = this.value.trim(); 433 434 clearTimeout(searchTimeout); 435 436 if (query.length === 0) { 437 showEmptyState(emptyState, resultsState); 438 searchForm.classList.add('dropdown-open'); 439 return; 440 } 441 442 if (query.length < 2) { 443 return; 444 } 445 446 searchTimeout = setTimeout(function () { 447 performSearch( 448 query, 449 resultsList, 450 resultsCount, 451 emptyState, 452 resultsState, 453 searchForm 454 ); 455 }, 200); 456 }); 457 } 458 459 function performSearch( 460 query, 461 resultsList, 462 resultsCount, 463 emptyState, 464 resultsState, 465 searchForm 466 ) { 467 if (!index) { 468 return; 469 } 470 471 if (isMaliciousQuery(query)) { 472 return; 473 } 474 475 index 476 .search(query, { 477 hitsPerPage: 20, 478 attributesToSnippet: ['content:10'], 479 highlightPreTag: HIGHLIGHT_PRE, 480 highlightPostTag: HIGHLIGHT_POST, 481 }) 482 .then(function (response) { 483 emptyState.style.display = 'none'; 484 resultsState.style.display = 'block'; 485 const separator = document.querySelector( 486 '.search-dropdown-separator' 487 ); 488 if (separator) { 489 separator.style.display = 'none'; 490 } 491 492 resultsCount.textContent = response.nbHits; 493 resultsList.innerHTML = ''; 494 495 if (response.hits.length === 0) { 496 const emptyLi = document.createElement('li'); 497 emptyLi.className = 'search-no-results'; 498 emptyLi.textContent = 499 noResultsText && 500 noResultsText !== 'HEA_SEARCH_NO_RESULTS' 501 ? noResultsText 502 : 'No results found'; 503 resultsList.appendChild(emptyLi); 504 searchForm.classList.add('dropdown-open'); 505 return; 506 } 507
508 response.hits.forEach(function (hit) { 509 let title = ''; 510 if ( 511 hit._highlightResult && 512 hit._highlightResult.post_title 513 ) { 514 title = escapeHtml( 515 hit._highlightResult.post_title.value 516 ); 517 title = title 518 .replace(/__bf-hl__/g, '<strong>') 519 .replace(/__\/bf-hl__/g, '</strong>'); 520 } else { 521 title = escapeHtml(hit.post_title || hit.name || ''); 522 } 523 524 const li = document.createElement('li'); 525 const a = document.createElement('a'); 526 a.href = hit.permalink || hit.posts_url || '#'; 527 a.innerHTML = title; 528 li.appendChild(a); 529 resultsList.appendChild(li); 530 }); 531 532 searchForm.classList.add('dropdown-open'); 533 }) 534 .catch(function (error) { 535 // Algolia returns a specific message when the secured key 536 // has expired (page served from cache after TTL elapsed). 537 // Fetch a fresh key from the server and retry silently. 538 if ( 539 error && 540 error.message && 541 error.message.indexOf('validUntil') !== -1 && 542 !keyRefreshInFlight && 543 algolia.refresh_key_url 544 ) { 545 keyRefreshInFlight = true; 546 fetch(algolia.refresh_key_url) 547 .then(function (resp) { 548 return resp.json(); 549 }) 550 .then(function (json) { 551 if ( 552 json.success && 553 json.data && 554 json.data.search_api_key 555 ) { 556 algolia.search_api_key = 557 json.data.search_api_key; 558 var newClient = algoliasearch( 559 algolia.application_id, 560 algolia.search_api_key 561 ); 562 index = newClient.initIndex( 563 indexName || 564 (algolia.autocomplete && 565 algolia.autocomplete.sources && 566 algolia.autocomplete.sources[0] && 567 algolia.autocomplete.sources[0] 568 .index_name) || 569 '' 570 ); 571 // Retry the original search with the fresh key. 572 performSearch( 573 query, 574 resultsList, 575 resultsCount, 576 emptyState, 577 resultsState, 578 searchForm 579 ); 580 } 581 }) 582 .catch(function () { 583 // Refresh failed â nothing more we can do. 584 }) 585 .finally(function () { 586 keyRefreshInFlight = false; 587 }); 588 return; 589 } 590 console.error('Algolia search error:', error); 591 }); 592 } 593 594 function showEmptyState(emptyState, resultsState) { 595 const separator = document.querySelector('.search-dropdown-separator'); 596 if (emptyState) { 597 emptyState.style.display = ''; 598 } 599 if (resultsState) { 600 resultsState.style.display = 'none'; 601 } 602 if (separator) { 603 separator.style.display = ''; 604 } 605 } 606
607 function renderSuggestions(container, searchInput) { 608 if (!container) { 609 return; 610 } 611 container.innerHTML = ''; 612 613 suggestionTexts.forEach(function (text) { 614 if (!text) { 615 return; 616 } 617 const li = document.createElement('li'); 618 li.className = 'search-suggestion-item'; 619 li.innerHTML = 620 SEARCH_ICON_SVG + '<span>' + escapeHtml(text) + '</span>'; 621 622 li.addEventListener('mousedown', function (e) { 623 e.preventDefault(); 624 searchInput.value = text; 625 searchInput.dispatchEvent(new Event('input')); 626 }); 627 628 container.appendChild(li); 629 }); 630 } 631 632 renderSuggestionsEarly(); 633 setupAiToggle(); 634 635 window.addEventListener('load', function () { 636 initSearch(); 637 setupAiToggle(); 638 } ); 639 640 if (document.readyState === 'complete') { 641 initSearch(); 642 setupAiToggle(); 643 } 644})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.