diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index 834093e48bc7..1806123cbd7b 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -529,6 +529,7 @@ export class CaptchaStage const template = iframeTemplate(captchaElement, { challengeURL: challengeURL.toString(), theme: this.activeTheme, + scriptOnLoad: !(controller instanceof TurnstileController), }); if ( diff --git a/web/src/flow/stages/captcha/controllers/turnstile.ts b/web/src/flow/stages/captcha/controllers/turnstile.ts index eb5e21bdc44d..79ceafce7cce 100644 --- a/web/src/flow/stages/captcha/controllers/turnstile.ts +++ b/web/src/flow/stages/captcha/controllers/turnstile.ts @@ -1,7 +1,5 @@ /* eslint-disable @typescript-eslint/triple-slash-reference */ /// -import { ifPresent } from "#elements/utils/attributes"; - import { CaptchaController } from "#flow/stages/captcha/controllers/CaptchaController"; import { TurnstileObject } from "turnstile-types"; @@ -20,7 +18,16 @@ export class TurnstileController extends CaptchaController { public prepareURL = (): URL | null => { const input = this.host.challenge?.jsUrl; - return input && URL.canParse(input) ? new URL(input) : null; + if (!input || !URL.canParse(input)) return null; + + const url = new URL(input); + + // Use explicit rendering to prevent Turnstile's 3-hour self-upgrade + // from calling implicitRenderAll() and duplicating widgets. + url.searchParams.set("render", "explicit"); + url.searchParams.set("onload", "onTurnstileReady"); + + return url; }; /** @@ -33,25 +40,34 @@ export class TurnstileController extends CaptchaController { /** * Renders the Turnstile captcha frame. * + * Uses explicit rendering to avoid Turnstile's self-upgrade mechanism + * (every ~3 hours) from calling `implicitRenderAll()` and duplicating widgets. + * * @remarks * - * Turnstile will log a warning if the `data-language` attribute + * Turnstile will log a warning if the `language` option * is not in lower-case format. * * @see {@link https://developers.cloudflare.com/turnstile/reference/supported-languages/ Turnstile Supported Languages} */ public interactive = () => { - const languageTag = this.host.activeLanguageTag.toLowerCase(); + const siteKey = this.host.challenge?.siteKey ?? ""; + const theme = this.host.activeTheme; + const language = this.host.activeLanguageTag.toLowerCase(); - return html`
`; + return html`
+ `; }; public refreshInteractive = async () => { diff --git a/web/src/flow/stages/captcha/shared.ts b/web/src/flow/stages/captcha/shared.ts index 850347909e6a..5245809e7f72 100644 --- a/web/src/flow/stages/captcha/shared.ts +++ b/web/src/flow/stages/captcha/shared.ts @@ -25,6 +25,11 @@ export function themeMeta(theme: ResolvedUITheme) { export interface IFrameTemplateInit { challengeURL: URL | string; theme: ResolvedUITheme; + /** + * If `true`, the script element will fire `loadListener()` on load. + * Defaults to `true`. + */ + scriptOnLoad?: boolean; } /** @@ -37,7 +42,7 @@ export interface IFrameTemplateInit { */ export function iframeTemplate( children: TemplateResult, - { challengeURL, theme }: IFrameTemplateInit, + { challengeURL, theme, scriptOnLoad = true }: IFrameTemplateInit, ) { return createDocumentTemplate({ head: html` @@ -90,7 +95,10 @@ export function iframeTemplate( } ${children} - + `, }); }