docusaurus-openai-search
Version:
AI-powered search plugin for Docusaurus - extends Algolia search with intelligent keyword generation and RAG-based answers
248 lines (247 loc) • 9.35 kB
JavaScript
/**
* Google reCAPTCHA v3 integration for frontend
*/
let recaptchaLoaded = false;
let recaptchaLoadPromise = null;
let recaptchaScript = null;
let currentSiteKey = null;
let loadAttempts = 0;
const MAX_LOAD_ATTEMPTS = 3;
/**
* P2-002: Cleanup reCAPTCHA resources and reset state
*/
export function cleanupRecaptcha() {
try {
// Reset global state
recaptchaLoaded = false;
recaptchaLoadPromise = null;
currentSiteKey = null;
loadAttempts = 0;
// Remove script element if it exists
if (recaptchaScript && recaptchaScript.parentNode) {
recaptchaScript.parentNode.removeChild(recaptchaScript);
recaptchaScript = null;
}
// Clear global callback
if (window.onRecaptchaLoad) {
delete window.onRecaptchaLoad;
}
// Clear grecaptcha if possible (note: this may not fully unload the reCAPTCHA)
if (window.grecaptcha) {
try {
// Some cleanup attempts (though reCAPTCHA doesn't officially support full cleanup)
delete window.grecaptcha;
}
catch (error) {
// Ignore errors during cleanup
console.debug('Could not fully cleanup grecaptcha:', error);
}
}
console.debug('[reCAPTCHA] Cleanup completed');
}
catch (error) {
console.error('[reCAPTCHA] Error during cleanup:', error);
}
}
/**
* P2-002: Enhanced reCAPTCHA script loading with multiple initialization handling and error recovery
*/
export async function loadRecaptcha(siteKey) {
// P2-002: Handle multiple initializations - check if already loaded with same site key
if (recaptchaLoaded && currentSiteKey === siteKey) {
return;
}
// P2-002: Handle site key changes - cleanup if different site key
if (recaptchaLoaded && currentSiteKey !== siteKey) {
console.debug('[reCAPTCHA] Site key changed, cleaning up previous instance');
cleanupRecaptcha();
}
// P2-002: Return existing promise if loading in progress
if (recaptchaLoadPromise) {
return recaptchaLoadPromise;
}
// P2-002: Check maximum load attempts for error recovery
if (loadAttempts >= MAX_LOAD_ATTEMPTS) {
throw new Error(`Failed to load reCAPTCHA after ${MAX_LOAD_ATTEMPTS} attempts`);
}
loadAttempts++;
currentSiteKey = siteKey;
recaptchaLoadPromise = new Promise((resolve, reject) => {
// Check if already loaded
if (window.grecaptcha && recaptchaLoaded) {
resolve();
return;
}
// P2-002: Enhanced callback with error handling
window.onRecaptchaLoad = () => {
try {
if (window.grecaptcha) {
recaptchaLoaded = true;
console.debug('[reCAPTCHA] Successfully loaded');
resolve();
}
else {
throw new Error('reCAPTCHA loaded but grecaptcha not available');
}
}
catch (error) {
console.error('[reCAPTCHA] Error in load callback:', error);
recaptchaLoadPromise = null;
reject(error);
}
};
// P2-002: Enhanced script creation with better error handling
try {
const script = document.createElement('script');
script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}&onload=onRecaptchaLoad`;
script.async = true;
script.defer = true;
// P2-002: Enhanced error handling with retry logic
script.onerror = () => {
console.error(`[reCAPTCHA] Failed to load script (attempt ${loadAttempts}/${MAX_LOAD_ATTEMPTS})`);
recaptchaLoadPromise = null;
// Remove failed script
if (script.parentNode) {
script.parentNode.removeChild(script);
}
const error = new Error(`Failed to load reCAPTCHA script (attempt ${loadAttempts}/${MAX_LOAD_ATTEMPTS})`);
reject(error);
};
// P2-002: Add timeout for loading
const timeout = setTimeout(() => {
console.error(`[reCAPTCHA] Load timeout (attempt ${loadAttempts}/${MAX_LOAD_ATTEMPTS})`);
recaptchaLoadPromise = null;
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error(`reCAPTCHA load timeout (attempt ${loadAttempts}/${MAX_LOAD_ATTEMPTS})`));
}, 10000); // 10 second timeout
// Clear timeout on successful load
const originalCallback = window.onRecaptchaLoad;
window.onRecaptchaLoad = () => {
clearTimeout(timeout);
if (originalCallback)
originalCallback();
};
// Store script reference for cleanup
recaptchaScript = script;
document.head.appendChild(script);
console.debug(`[reCAPTCHA] Loading script (attempt ${loadAttempts}/${MAX_LOAD_ATTEMPTS})`);
}
catch (error) {
console.error('[reCAPTCHA] Error creating script:', error);
recaptchaLoadPromise = null;
reject(error);
}
});
return recaptchaLoadPromise;
}
/**
* P2-002: Enhanced reCAPTCHA token execution with better error recovery
*/
export async function getRecaptchaToken(siteKey, action = 'submit') {
let retryCount = 0;
const maxRetries = 2;
while (retryCount <= maxRetries) {
try {
// P2-002: Ensure reCAPTCHA is loaded with enhanced error handling
await loadRecaptcha(siteKey);
if (!window.grecaptcha) {
throw new Error('reCAPTCHA not loaded after loadRecaptcha');
}
// P2-002: Validate that reCAPTCHA is ready
if (typeof window.grecaptcha.execute !== 'function') {
throw new Error('reCAPTCHA execute function not available');
}
// Execute reCAPTCHA with timeout
const executePromise = window.grecaptcha.execute(siteKey, { action });
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('reCAPTCHA execute timeout')), 5000);
});
const token = await Promise.race([executePromise, timeoutPromise]);
if (!token || typeof token !== 'string') {
throw new Error('Invalid token received from reCAPTCHA');
}
console.debug(`[reCAPTCHA] Token obtained successfully for action: ${action}`);
return token;
}
catch (error) {
retryCount++;
console.error(`[reCAPTCHA] Error getting token (attempt ${retryCount}/${maxRetries + 1}):`, error);
// P2-002: If it's a loading issue and we have retries left, cleanup and retry
if (retryCount <= maxRetries) {
if (error instanceof Error &&
(error.message.includes('not loaded') ||
error.message.includes('not available') ||
error.message.includes('timeout'))) {
console.debug('[reCAPTCHA] Attempting cleanup and retry');
cleanupRecaptcha();
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
continue;
}
}
// If we've exhausted retries or it's not a retryable error
if (retryCount > maxRetries) {
console.error('[reCAPTCHA] All retry attempts exhausted');
}
return null;
}
}
return null;
}
/**
* Add reCAPTCHA token to fetch headers
*/
export async function addRecaptchaHeader(headers, siteKey, action = 'api_request') {
if (!siteKey) {
return headers;
}
const token = await getRecaptchaToken(siteKey, action);
if (token) {
return {
...headers,
'X-Recaptcha-Token': token
};
}
return headers;
}
/**
* P2-002: Additional utility functions for reCAPTCHA management
*/
/**
* Check if reCAPTCHA is loaded and ready
*/
export function isRecaptchaReady() {
return recaptchaLoaded &&
window.grecaptcha &&
typeof window.grecaptcha.execute === 'function';
}
/**
* Get current reCAPTCHA state for debugging
*/
export function getRecaptchaState() {
return {
loaded: recaptchaLoaded,
siteKey: currentSiteKey,
attempts: loadAttempts,
scriptPresent: !!recaptchaScript,
grecaptchaAvailable: !!window.grecaptcha
};
}
/**
* Reset load attempts (useful for testing or manual recovery)
*/
export function resetLoadAttempts() {
loadAttempts = 0;
console.debug('[reCAPTCHA] Load attempts reset');
}
/**
* Force reload reCAPTCHA (cleanup and reload)
*/
export async function forceReloadRecaptcha(siteKey) {
console.debug('[reCAPTCHA] Force reloading reCAPTCHA');
cleanupRecaptcha();
resetLoadAttempts();
return loadRecaptcha(siteKey);
}