1/** 2 * SAE Algolia Bot Guard - frontend guard. 3 * 4 * Runs in the document head, before the theme bundle and before any polyfill, 5 * therefore plain ES5 without Promise or fetch. 6 * 7 * Two jobs: 8 * 1. Debounce the search modal input and drop queries below the minimum length. 9 * 2. Gate every outgoing Algolia request: inject a short lived key, enforce a 10 * session rate limit, deduplicate identical queries and, when the server 11 * quota is exhausted, escalate to a Turnstile challenge. 12 * 13 * Blocked requests are rewritten to a local endpoint that answers with an empty 14 * but well formed Algolia response, so the UI degrades to "no results" instead 15 * of hanging or throwing. 16 */ 17(function (window, document) { 18 'use strict'; 19 20 var cfg = window.saeAlgoliaGuard; 21 22 if (!cfg || !cfg.tokenUrl || !window.XMLHttpRequest) { 23 return; 24 } 25 26 var MIN_LENGTH = int(cfg.minLength, 3); 27 var DEBOUNCE_MS = int(cfg.debounceMs, 350); 28 var SESSION_LIMIT = int(cfg.sessionLimit, 40); 29 var SESSION_WINDOW = int(cfg.sessionWindow, 600) * 1000; 30 var DEDUPE_MS = int(cfg.dedupeMs, 1500); 31 var STORAGE_KEY = 'saeAlgoliaGuardHits'; 32 var DOWN_KEY = 'saeAlgoliaGuardDown'; 33 var GRACE_BUDGET = 5; 34 var EMPTY_MODE = cfg.emptyQueryMode || 'cache'; 35 var TEXT_MODE = cfg.textQueryMode || 'direct'; 36 37 function int(value, fallback) { 38 var parsed = parseInt(value, 10); 39 40 return isNaN(parsed) ? fallback : parsed; 41 } 42 43 function log() { 44 if (cfg.debug && window.console && window.console.log) { 45 window.console.log.apply(window.console, ['[algolia-guard]'].concat([].slice.call(arguments))); 46 } 47 } 48 49 /* ------------------------------------------------------------------ */ 50 /* 1. Debounce and minimum length for the search modal */ 51 /* ------------------------------------------------------------------ */ 52 53 function matches(element, selector) { 54 if (!element || element.nodeType !== 1) { 55 return false; 56 } 57 58 var fn = element.matches || element.msMatchesSelector || element.webkitMatchesSelector; 59 60 return fn ? fn.call(element, selector) : false; 61 } 62 63 function forwardInput(input) { 64 var event; 65 66 try { 67 event = new Event('input', { bubbles: true, cancelable: false }); 68 } catch (e) { 69 event = document.createEvent('Event'); 70 event.initEvent('input', true, false); 71 } 72 73 event.saeGuardPass = true; 74 input.dispatchEvent(event); 75 } 76 77 function installInputGuard() { 78 if (!cfg.inputSelector || (DEBOUNCE_MS <= 0 && MIN_LENGTH <= 0)) { 79 return; 80 } 81 82 // A capture listener on an ancestor always runs before the listeners 83 // bound to the input itself, which is how the theme's handler gets 84 // suppressed without touching the compiled bundle. 85 document.addEventListener( 86 'input', 87 function (event) { 88 var input = event.target; 89 90 if (event.saeGuardPass || !matches(input, cfg.inputSelector)) { 91 return; 92 } 93 94 event.stopPropagation(); 95 96 var value = input.value || ''; 97 98 if (input.saeGuardTimer) { 99 window.clearTimeout(input.saeGuardTimer); 100 input.saeGuardTimer = null; 101 } 102 103 if (value.length === 0) { 104 // Clearing the field must reset the results immediately. 105 forwardInput(input); 106 107 return; 108 } 109 110 if (value.length < MIN_LENGTH) { 111 return; 112 } 113 114 input.saeGuardTimer = window.setTimeout(function () { 115 input.saeGuardTimer = null; 116 forwardInput(input); 117 }, DEBOUNCE_MS); 118 }, 119 true 120 ); 121 } 122 123 /* ------------------------------------------------------------------ */ 124 /* 2. Session rate limit */ 125 /* ------------------------------------------------------------------ */ 126 127 var hits = []; 128 129 function loadHits() { 130 try { 131 var raw = window.sessionStorage.getItem(STORAGE_KEY); 132 var parsed = raw ? JSON.parse(raw) : []; 133 134 if (Object.prototype.toString.call(parsed) === '[object Array]') { 135 hits = parsed; 136 } 137 } catch (e) { 138 hits = []; 139 } 140 } 141 142 function persistHits() { 143 try { 144 window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hits)); 145 } catch (e) { 146 /* storage disabled, the in memory list still applies */ 147 } 148 } 149 150 function pruneHits(now) { 151 var kept = []; 152 153 for (var i = 0; i < hits.length; i++) { 154 if (now - hits[i] < SESSION_WINDOW) { 155 kept.push(hits[i]); 156 } 157 } 158 159 hits = kept; 160 } 161 162 function sessionLimitReached() { 163 var now = new Date().getTime(); 164 165 pruneHits(now); 166 167 return hits.length >= SESSION_LIMIT; 168 } 169 170 function countHit() { 171 hits.push(new Date().getTime()); 172 persistHits(); 173 } 174 175 /* ------------------------------------------------------------------ */ 176 /* 3. Token handling */ 177 /* ------------------------------------------------------------------ */ 178 179 var OriginalXhr = window.XMLHttpRequest; 180 var proto = OriginalXhr.prototype; 181 var origOpen = proto.open; 182 var origSend = proto.send; 183 var origSetHeader = proto.setRequestHeader; 184 var origAbort = proto.abort; 185 186 var token = { 187 key: null, 188 budget: 0, 189 expires: 0, 190 fetching: false,
191 blocked: false, 192 waiters: [] 193 }; 194 195 function fallbackKey() { 196 var key = window.algolia && window.algolia.search_api_key; 197 198 // The placeholder handed to crawlers is not a usable key. 199 if (!key || key === 'sae-guard-cache-only') { 200 return null; 201 } 202 203 return key; 204 } 205 206 function tokenUsable() { 207 return token.key && token.budget > 0 && (!token.expires || new Date().getTime() / 1000 < token.expires); 208 } 209 210 function requestToken(cfToken, callback) { 211 var xhr = new OriginalXhr(); 212 var url = cfg.tokenUrl + (cfg.tokenUrl.indexOf('?') === -1 ? '?' : '&') + '_=' + new Date().getTime(); 213 214 xhr.open('POST', url, true); 215 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); 216 xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); 217 218 xhr.onreadystatechange = function () { 219 if (xhr.readyState !== 4) { 220 return; 221 } 222 223 var payload = null; 224 225 try { 226 payload = JSON.parse(xhr.responseText); 227 } catch (e) { 228 payload = null; 229 } 230 231 callback(xhr.status, payload); 232 }; 233 234 try { 235 xhr.send(cfToken ? 'cf_token=' + encodeURIComponent(cfToken) : null); 236 } catch (e) { 237 callback(0, null); 238 } 239 } 240 241 function flushWaiters(key) { 242 var waiters = token.waiters; 243 token.waiters = []; 244 245 for (var i = 0; i < waiters.length; i++) { 246 waiters[i](key); 247 } 248 } 249 250 function ensureKey(callback) { 251 if (tokenUsable()) { 252 callback(token.key); 253 254 return; 255 } 256 257 if (token.blocked) { 258 callback(null); 259 260 return; 261 } 262 263 token.waiters.push(callback); 264 265 if (token.fetching) { 266 return; 267 } 268 269 token.fetching = true; 270 271 requestToken(null, function (status, payload) { 272 token.fetching = false; 273 handleTokenResponse(status, payload); 274 }); 275 } 276 277 function handleTokenResponse(status, payload) { 278 if (status === 200 && payload && payload.key) { 279 token.key = payload.key; 280 token.budget = int(payload.budget, 1); 281 token.expires = int(payload.expires, 0); 282 token.blocked = false; 283 284 log('token granted, budget', token.budget); 285 flushWaiters(token.key); 286 287 return; 288 } 289 290 if (payload && payload.challenge_required && cfg.turnstileKey) { 291 token.blocked = true; 292 log('quota exceeded, showing challenge'); 293 showChallenge(); 294 flushWaiters(null); 295 296 return; 297 } 298 299 if (status === 429 || status === 403) { 300 token.blocked = true; 301 log('blocked by server, status', status); 302 flushWaiters(null); 303 304 return; 305 } 306 307 // The endpoint is unreachable. Degrade to the key that is already in the 308 // page so the site keeps working, but only for a handful of queries. 309 if (status === 0 || status === 401 || status === 404 || status >= 500) { 310 markRestDown('token', status); 311 } 312 313 var fallback = fallbackKey(); 314 315 if (fallback) { 316 token.key = fallback; 317 token.budget = GRACE_BUDGET; 318 token.expires = 0; 319 log('token endpoint failed, using page key with grace budget'); 320 } 321 322 flushWaiters(token.key); 323 } 324 325 /* ------------------------------------------------------------------ */ 326 /* 4. Turnstile escalation */ 327 /* ------------------------------------------------------------------ */ 328 329 var challengeVisible = false; 330 331 function showChallenge() { 332 if (challengeVisible || !cfg.turnstileKey) { 333 return; 334 } 335 336 challengeVisible = true; 337 338 var overlay = document.createElement('div'); 339 overlay.setAttribute('id', 'sae-algolia-guard-challenge'); 340 overlay.setAttribute( 341 'style', 342 'position:fixed;z-index:2147483000;left:50%;bottom:24px;transform:translateX(-50%);' + 343 'background:#fff;color:#111;padding:16px 20px;border-radius:8px;' + 344 'box-shadow:0 8px 30px rgba(0,0,0,.35);font:14px/1.4 sans-serif;max-width:90vw;' 345 ); 346 347 var text = document.createElement('p'); 348 text.setAttribute('style', 'margin:0 0 12px;'); 349 text.appendChild(document.createTextNode(cfg.challengeText || 'Please confirm that you are not a robot.')); 350 351 var widget = document.createElement('div'); 352 widget.setAttribute('id', 'sae-algolia-guard-widget'); 353 354 overlay.appendChild(text); 355 overlay.appendChild(widget); 356 (document.body || document.documentElement).appendChild(overlay); 357 358 window.saeAlgoliaGuardSolved = function (cfToken) { 359 requestToken(cfToken, function (status, payload) { 360 if (status === 200 && payload && payload.key) {
361 removeChallenge(); 362 token.blocked = false; 363 handleTokenResponse(status, payload); 364 } 365 }); 366 }; 367 368 loadTurnstile(function () { 369 if (window.turnstile && window.turnstile.render) { 370 window.turnstile.render('#sae-algolia-guard-widget', { 371 sitekey: cfg.turnstileKey, 372 callback: window.saeAlgoliaGuardSolved 373 }); 374 } 375 }); 376 } 377 378 function removeChallenge() { 379 var overlay = document.getElementById('sae-algolia-guard-challenge'); 380 381 if (overlay && overlay.parentNode) { 382 overlay.parentNode.removeChild(overlay); 383 } 384 385 challengeVisible = false; 386 } 387 388 function loadTurnstile(onReady) { 389 if (window.turnstile) { 390 onReady(); 391 392 return; 393 } 394 395 var script = document.createElement('script'); 396 script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; 397 script.async = true; 398 script.defer = true; 399 script.onload = onReady; 400 (document.head || document.documentElement).appendChild(script); 401 } 402 403 /* ------------------------------------------------------------------ */ 404 /* 5. Network gate */ 405 /* ------------------------------------------------------------------ */ 406 407 var recent = {}; 408 409 /** 410 * Whether our own endpoints answer at all. 411 * 412 * Every gate in here routes through the REST API, so a WAF, a security 413 * plugin or a locked down /wp-json turns each of them into an error the 414 * Algolia client cannot read, and the page ends up without results. When 415 * that happens the guard steps aside for the rest of the session: requests 416 * go straight to Algolia with the key in the page, which is the behaviour 417 * from before the plugin, and the site keeps working. 418 */ 419 var restDown = false; 420 421 try { 422 restDown = window.sessionStorage.getItem(DOWN_KEY) === '1'; 423 } catch (e) { 424 /* storage disabled */ 425 } 426 427 function looksDown(status) { 428 return status === 0 || status === 401 || status === 403 || status === 404 || status >= 500; 429 } 430 431 function markRestDown(where, status) { 432 if (restDown) { 433 return; 434 } 435 436 restDown = true; 437 log('endpoint', where, 'answered', status, '- standing down for this session'); 438 439 try { 440 window.sessionStorage.setItem(DOWN_KEY, '1'); 441 } catch (e) { 442 /* storage disabled, the in memory flag still applies */ 443 } 444 } 445 446 /** 447 * Watch one of our own requests and stand down when it fails. 448 * 449 * readystatechange fires before the load event the Algolia client listens 450 * on, but the response of this very request can no longer be changed, so 451 * only the requests after it are spared. 452 */ 453 function watchGuardRoute(xhr, where) { 454 if (!xhr.addEventListener) { 455 return; 456 } 457 458 xhr.addEventListener('readystatechange', function () { 459 if (xhr.readyState !== 4 || xhr.saeAborted) { 460 return; 461 } 462 463 if (looksDown(xhr.status)) { 464 markRestDown(where, xhr.status); 465 } 466 }); 467 } 468 469 function isAlgoliaUrl(url) { 470 if (typeof url !== 'string') { 471 return false; 472 } 473 474 return /^https?:\/\/[^/]*(algolia\.net|algolianet\.com)(\/|:|$)/i.test(url); 475 } 476 477 function replaceKeyInUrl(url, key) { 478 if (url.indexOf('x-algolia-api-key=') === -1) { 479 return url; 480 } 481 482 return url.replace(/([?&])x-algolia-api-key=[^&]*/i, '$1x-algolia-api-key=' + encodeURIComponent(key)); 483 } 484 485 function parseBody(body) { 486 if (typeof body !== 'string' || !body) { 487 return null; 488 } 489 490 try { 491 return JSON.parse(body); 492 } catch (e) { 493 return null; 494 } 495 } 496 497 /** 498 * The shortest query text the request asks for, or null when unknown. 499 */ 500 function queryTextOf(parsed) { 501 if (!parsed) { 502 return null; 503 } 504 505 if (typeof parsed.query === 'string') { 506 return parsed.query; 507 } 508 509 if (parsed.requests && parsed.requests.length) { 510 for (var i = 0; i < parsed.requests.length; i++) { 511 var request = parsed.requests[i]; 512 513 if (request && typeof request.query === 'string' && request.query.length) { 514 return request.query; 515 } 516 } 517 518 return ''; 519 } 520 521 return null; 522 } 523 524 /** 525 * Shape the empty response has to imitate for this endpoint. 526 */ 527 function emptyShapeFor(url, parsed) { 528 if (/\/facets\//i.test(url)) { 529 return { shape: 'facets', n: 1 }; 530 } 531 532 if (/\/\*\/queries/i.test(url) || (parsed && parsed.requests)) { 533 return { shape: 'multi', n: parsed && parsed.requests ? parsed.requests.length : 1 }; 534 } 535
536 return { shape: 'search', n: 1 }; 537 } 538 539 function emptyUrlFor(shape) { 540 var separator = cfg.emptyUrl.indexOf('?') === -1 ? '?' : '&'; 541 542 return cfg.emptyUrl + separator + 'shape=' + shape.shape + '&n=' + shape.n; 543 } 544 545 /** 546 * Whether the request asks for the unfiltered default result set. 547 * 548 * The filter pages fire exactly this on every page load, for every visitor, 549 * which is what makes it both the most expensive and the most cacheable 550 * request on the site. 551 */ 552 function isEmptyQuery(parsed) { 553 if (!parsed) { 554 return false; 555 } 556 557 if (parsed.requests && parsed.requests.length) { 558 for (var i = 0; i < parsed.requests.length; i++) { 559 if (queryOf(parsed.requests[i]).length > 0) { 560 return false; 561 } 562 } 563 564 return true; 565 } 566 567 return typeof parsed.query === 'string' && parsed.query.length === 0; 568 } 569 570 /** 571 * Search text of a request, also when it hides in a serialized params string. 572 */ 573 function queryOf(request) { 574 if (!request) { 575 return ''; 576 } 577 578 if (typeof request.query === 'string') { 579 return request.query; 580 } 581 582 if (typeof request.params === 'string') { 583 var match = /(^|&)query=([^&]*)/.exec(request.params); 584 585 if (match) { 586 return decodeURIComponent(match[2].replace(/\+/g, ' ')); 587 } 588 } 589 590 return ''; 591 } 592 593 /** 594 * Whether the answer to an empty query is actually used by the page. 595 * 596 * A filter page restricts by facets or asks for facet counts. Clearing the 597 * modal input sends a bare empty query whose result the theme throws away. 598 */ 599 function hasFilters(parsed) { 600 if (!parsed) { 601 return false; 602 } 603 604 if (parsed.requests && parsed.requests.length) { 605 for (var i = 0; i < parsed.requests.length; i++) { 606 if (hasFilters(expandParams(parsed.requests[i]))) { 607 return true; 608 } 609 } 610 611 return false; 612 } 613 614 var names = ['facetFilters', 'numericFilters', 'filters', 'tagFilters', 'facets']; 615 616 for (var n = 0; n < names.length; n++) { 617 var value = parsed[names[n]]; 618 619 if (!value) { 620 continue; 621 } 622 623 if (typeof value === 'string' && value.length) { 624 return true; 625 } 626 627 if (value.length) { 628 return true; 629 } 630 } 631 632 return false; 633 } 634 635 /** 636 * Merge a serialized params string back into the request object. 637 */ 638 function expandParams(request) { 639 if (!request || typeof request.params !== 'string') { 640 return request; 641 } 642 643 var merged = {}; 644 var name; 645 646 for (name in request) { 647 if (Object.prototype.hasOwnProperty.call(request, name) && name !== 'params') { 648 merged[name] = request[name]; 649 } 650 } 651 652 var pairs = request.params.split('&'); 653 654 for (var i = 0; i < pairs.length; i++) { 655 var pair = pairs[i].split('='); 656 657 if (!pair[0]) { 658 continue; 659 } 660 661 var key = decodeURIComponent(pair[0]); 662 var raw = decodeURIComponent((pair[1] || '').replace(/\+/g, ' ')); 663 664 if (merged[key] === undefined) { 665 merged[key] = raw; 666 } 667 } 668 669 return merged; 670 } 671 672 /** 673 * Index name taken from the Algolia request path. 674 */ 675 function indexFromUrl(url) { 676 var match = /\/1\/indexes\/([^/?]+)\//i.exec(String(url || '')); 677 678 if (!match) { 679 return ''; 680 } 681 682 try { 683 return decodeURIComponent(match[1]); 684 } catch (e) { 685 return match[1]; 686 } 687 } 688 689 /** 690 * Whether empty query handling applies to this endpoint at all. Facet value 691 * searches carry their own query text and are gated normally. 692 */ 693 function handlesEmpty(shape) { 694 if (restDown || EMPTY_MODE === 'passthrough' || shape.shape === 'facets') { 695 return false; 696 } 697 698 return EMPTY_MODE === 'block' || !!cfg.cacheUrl; 699 } 700 701 /** 702 * Whether a typed search may be answered from the server side cache. 703 * 704 * Facet value lookups keep their own path: they carry text as well, but the 705 * cache route only speaks the ordinary query endpoints. 706 */ 707 function handlesText(shape, queryText) { 708 if (restDown || TEXT_MODE !== 'cache' || shape.shape === 'facets' || !cfg.cacheUrl) { 709 return false; 710 } 711 712 return queryText !== null && queryText.length > 0; 713 } 714 715 function cachedUrlFor(url, isMulti) { 716 var separator = cfg.cacheUrl.indexOf('?') === -1 ? '?' : '&'; 717 718 return ( 719 cfg.cacheUrl + 720 separator + 721 'index=' + 722 encodeURIComponent(indexFromUrl(url)) + 723 '&multi=' + 724 (isMulti ? '1' : '0') 725 ); 726 } 727 728 /** 729 * Send an empty query to the server side cache instead of to Algolia. 730 */ 731 function sendCached(xhr, body, isMulti) { 732 if (xhr.saeAborted) { 733 return; 734 } 735 736 try {
737 origOpen.call(xhr, 'POST', cachedUrlFor(xhr.saeUrl, isMulti), true); 738 origSetHeader.call(xhr, 'Content-Type', 'application/json'); 739 watchGuardRoute(xhr, 'cached'); 740 741 return origSend.call(xhr, body); 742 } catch (e) { 743 log('could not reach the cache route', e); 744 } 745 } 746 747 function signatureOf(url, body) { 748 var path = url.replace(/^https?:\/\/[^/]+/i, '').replace(/[?&]x-algolia-api-key=[^&]*/i, ''); 749 750 return path + '|' + (typeof body === 'string' ? body : ''); 751 } 752 753 function isDuplicate(signature) { 754 var now = new Date().getTime(); 755 756 for (var key in recent) { 757 if (Object.prototype.hasOwnProperty.call(recent, key) && now - recent[key] > DEDUPE_MS) { 758 delete recent[key]; 759 } 760 } 761 762 if (recent[signature] && now - recent[signature] < DEDUPE_MS) { 763 return true; 764 } 765 766 recent[signature] = now; 767 768 return false; 769 } 770 771 proto.open = function (method, url) { 772 this.saeAlgolia = isAlgoliaUrl(url); 773 this.saeAborted = false; 774 775 if (this.saeAlgolia) { 776 this.saeMethod = method; 777 this.saeUrl = url; 778 this.saeHeaders = []; 779 } 780 781 return origOpen.apply(this, arguments); 782 }; 783 784 // A request the client gave up on must not be revived by a deferred send. 785 proto.abort = function () { 786 this.saeAborted = true; 787 788 return origAbort.apply(this, arguments); 789 }; 790 791 proto.setRequestHeader = function (name, value) { 792 if (this.saeAlgolia && this.saeHeaders) { 793 this.saeHeaders.push([name, value]); 794 } 795 796 return origSetHeader.apply(this, arguments); 797 }; 798 799 proto.send = function (body) { 800 var xhr = this; 801 802 if (!xhr.saeAlgolia || restDown) { 803 return origSend.apply(xhr, arguments); 804 } 805 806 var parsed = parseBody(body); 807 var queryText = queryTextOf(parsed); 808 var shape = emptyShapeFor(xhr.saeUrl, parsed); 809 810 if (handlesEmpty(shape) && isEmptyQuery(parsed)) { 811 // Identical requests still collapse into one, but an empty query 812 // neither needs a token nor counts against the session limit. 813 if (isDuplicate(signatureOf(xhr.saeUrl, body))) { 814 log('dropped, duplicate empty query'); 815 816 return sendEmpty(xhr, shape); 817 } 818 819 if (EMPTY_MODE === 'block' || !hasFilters(parsed)) { 820 log('empty query answered locally'); 821 822 return sendEmpty(xhr, shape); 823 } 824 825 log('empty query routed to the cache'); 826 827 return sendCached(xhr, body, shape.shape === 'multi'); 828 } 829 830 if (queryText !== null && queryText.length > 0 && queryText.length < MIN_LENGTH) { 831 log('dropped, query too short:', queryText); 832 833 return sendEmpty(xhr, shape); 834 } 835 836 if (isDuplicate(signatureOf(xhr.saeUrl, body))) { 837 log('dropped, duplicate request'); 838 839 return sendEmpty(xhr, shape); 840 } 841 842 if (sessionLimitReached()) { 843 log('dropped, session limit reached'); 844 showChallenge(); 845 846 return sendEmpty(xhr, shape); 847 } 848 849 // A cached term costs nothing at Algolia, but it still counts against 850 // the session limit, because the limit is there to slow down scraping. 851 if (handlesText(shape, queryText)) { 852 countHit(); 853 log('text query routed to the cache'); 854 855 return sendCached(xhr, body, shape.shape === 'multi'); 856 } 857 858 ensureKey(function (key) { 859 if (xhr.saeAborted) { 860 return; 861 } 862 863 if (!key) { 864 sendEmpty(xhr, shape); 865 866 return; 867 } 868 869 token.budget--; 870 countHit(); 871 872 var url = replaceKeyInUrl(xhr.saeUrl, key); 873 874 try { 875 origOpen.call(xhr, xhr.saeMethod, url, true); 876 replayHeaders(xhr, url === xhr.saeUrl ? key : null); 877 origSend.call(xhr, body); 878 } catch (e) { 879 log('could not re-issue request', e); 880 } 881 }); 882 }; 883 884 /** 885 * Re-apply the headers the Algolia client set before we reopened the request. 886 * 887 * @param {XMLHttpRequest} xhr The request. 888 * @param {string|null} headerKey Key to inject when it does not travel in the query string. 889 */ 890 function replayHeaders(xhr, headerKey) { 891 var headers = xhr.saeHeaders || []; 892 var seenKeyHeader = false;
893 894 for (var i = 0; i < headers.length; i++) { 895 var name = headers[i][0]; 896 var value = headers[i][1]; 897 898 if (headerKey && String(name).toLowerCase() === 'x-algolia-api-key') { 899 value = headerKey; 900 seenKeyHeader = true; 901 } 902 903 try { 904 origSetHeader.call(xhr, name, value); 905 } catch (e) { 906 /* forbidden header, ignore */ 907 } 908 } 909 910 if (headerKey && !seenKeyHeader) { 911 try { 912 origSetHeader.call(xhr, 'x-algolia-api-key', headerKey); 913 } catch (e) { 914 /* ignore */ 915 } 916 } 917 } 918 919 /** 920 * Answer a blocked request from the local endpoint. 921 */ 922 function sendEmpty(xhr, shape) { 923 if (xhr.saeAborted) { 924 return; 925 } 926 927 try { 928 origOpen.call(xhr, 'GET', emptyUrlFor(shape), true); 929 watchGuardRoute(xhr, 'empty'); 930 931 return origSend.call(xhr, null); 932 } catch (e) { 933 log('could not serve empty response', e); 934 } 935 } 936 937 /* ------------------------------------------------------------------ */ 938 /* 6. fetch, in case a future client stops using XHR */ 939 /* ------------------------------------------------------------------ */ 940 941 function installFetchGuard() { 942 if (!window.fetch) { 943 return; 944 } 945 946 var origFetch = window.fetch; 947 948 function watched(promise, where) { 949 return promise.then( 950 function (response) { 951 if (response && looksDown(response.status)) { 952 markRestDown(where, response.status); 953 } 954 955 return response; 956 }, 957 function (error) { 958 markRestDown(where, 0); 959 960 throw error; 961 } 962 ); 963 } 964 965 window.fetch = function (input, init) { 966 var url = typeof input === 'string' ? input : input && input.url; 967 968 if (!isAlgoliaUrl(url) || restDown) { 969 return origFetch.apply(window, arguments); 970 } 971 972 var body = init && typeof init.body === 'string' ? init.body : null; 973 var parsed = parseBody(body); 974 var shape = emptyShapeFor(url, parsed); 975 var queryText = queryTextOf(parsed); 976 977 if (handlesEmpty(shape) && isEmptyQuery(parsed)) { 978 if (isDuplicate(signatureOf(url, body)) || EMPTY_MODE === 'block' || !hasFilters(parsed)) { 979 return watched(origFetch.call(window, emptyUrlFor(shape)), 'empty'); 980 } 981 982 return watched( 983 origFetch.call(window, cachedUrlFor(url, shape.shape === 'multi'), { 984 method: 'POST', 985 headers: { 'Content-Type': 'application/json' }, 986 body: body 987 }), 988 'cached' 989 ); 990 } 991 992 if ( 993 (queryText !== null && queryText.length > 0 && queryText.length < MIN_LENGTH) || 994 isDuplicate(signatureOf(url, body)) || 995 sessionLimitReached() 996 ) { 997 return watched(origFetch.call(window, emptyUrlFor(shape)), 'empty'); 998 } 999 1000 if (handlesText(shape, queryText)) { 1001 countHit(); 1002 1003 return watched( 1004 origFetch.call(window, cachedUrlFor(url, shape.shape === 'multi'), { 1005 method: 'POST', 1006 headers: { 'Content-Type': 'application/json' }, 1007 body: body 1008 }), 1009 'cached' 1010 ); 1011 } 1012 1013 var args = arguments; 1014 1015 return new window.Promise(function (resolve, reject) { 1016 ensureKey(function (key) { 1017 if (!key) { 1018 watched(origFetch.call(window, emptyUrlFor(shape)), 'empty').then(resolve, reject); 1019 1020 return; 1021 } 1022 1023 token.budget--; 1024 countHit(); 1025 1026 if (typeof input === 'string') { 1027 args[0] = replaceKeyInUrl(url, key); 1028 } 1029 1030 origFetch.apply(window, args).then(resolve, reject); 1031 }); 1032 }); 1033 }; 1034 } 1035 1036 loadHits(); 1037 installInputGuard(); 1038 1039 if (window.Promise) { 1040 installFetchGuard(); 1041 } 1042})(window, document);
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.