feat: Add Cap captcha support (#260)

* feat: Add Cap captcha support

* Update the translation

* Update the frontend

* fix: CapCaptcha not rendering after route switch
This commit is contained in:
WittF 2025-06-19 11:30:51 +08:00 committed by GitHub
parent 1f75c1c59e
commit 62e70ea6a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 283 additions and 0 deletions

View File

@ -428,6 +428,13 @@
"turnstile": "Cloudflare Turnstile",
"turnstileSiteKey": "Site Key",
"turnstileSiteKSecret": "Secret",
"cap": "Cap",
"capInstanceURL": "Instance URL",
"capInstanceURLDes": "The URL of your self-hosted Cap server. For more details, see the <0>standalone mode documentation</0>.",
"capKeyID": "Key ID",
"capKeyIDDes": "The key ID from your Cap server dashboard.",
"capKeySecret": "Key Secret",
"capKeySecretDes": "The key secret from your Cap server dashboard.",
"captchaProvider": "Captcha provider",
"captchaWidth": "Width",
"captchaHeight": "Height",

View File

@ -426,6 +426,13 @@
"turnstile": "Cloudflare Turnstile",
"turnstileSiteKey": "サイトキー",
"turnstileSiteKSecret": "キー",
"cap": "Cap",
"capInstanceURL": "インスタンス URL",
"capInstanceURLDes": "自身でホストしている Cap サーバーの URL。詳細については、<0>スタンドアロンモードドキュメント</0> を参照してください。",
"capKeyID": "キー ID",
"capKeyIDDes": "Cap サーバーダッシュボードから取得したキー ID。",
"capKeySecret": "キーシークレット",
"capKeySecretDes": "Cap サーバーダッシュボードから取得したキーシークレット。",
"captchaProvider": "認証コードタイプ",
"captchaWidth": "幅",
"captchaHeight": "高さ",

View File

@ -426,6 +426,13 @@
"turnstile": "Cloudflare Turnstile",
"turnstileSiteKey": "站点密钥",
"turnstileSiteKSecret": "密钥",
"cap": "Cap",
"capInstanceURL": "实例 URL",
"capInstanceURLDes": "自部署 Cap 服务器的 URL 地址。详细信息请参考 <0>独立模式文档</0>。",
"capKeyID": "密钥 ID",
"capKeyIDDes": "从 Cap 服务器控制面板获取的密钥 ID。",
"capKeySecret": "密钥密码",
"capKeySecretDes": "从 Cap 服务器控制面板获取的密钥密码。",
"captchaProvider": "验证码类型",
"captchaWidth": "宽度",
"captchaHeight": "高度",

View File

@ -423,6 +423,13 @@
"turnstile": "Cloudflare Turnstile",
"turnstileSiteKey": "站點金鑰",
"turnstileSiteKSecret": "金鑰",
"cap": "Cap",
"capInstanceURL": "實例 URL",
"capInstanceURLDes": "自部署 Cap 伺服器的 URL 地址。詳細資訊請參考 <0>獨立模式文檔</0>。",
"capKeyID": "金鑰 ID",
"capKeyIDDes": "從 Cap 伺服器控制面板獲取的金鑰 ID。",
"capKeySecret": "金鑰密碼",
"capKeySecretDes": "從 Cap 伺服器控制面板獲取的金鑰密碼。",
"captchaProvider": "驗證碼型別",
"captchaWidth": "寬度",
"captchaHeight": "高度",

View File

@ -7,6 +7,7 @@ export enum CaptchaType {
// Deprecated
TCAPTCHA = "tcaptcha",
TURNSTILE = "turnstile",
CAP = "cap",
}
export interface SiteConfig {
@ -22,6 +23,9 @@ export interface SiteConfig {
captcha_ReCaptchaKey?: string;
captcha_type?: CaptchaType;
turnstile_site_id?: string;
captcha_cap_instance_url?: string;
captcha_cap_key_id?: string;
captcha_cap_key_secret?: string;
register_enabled?: boolean;
logo?: string;
logo_light?: string;

View File

@ -0,0 +1,103 @@
import { Trans, useTranslation } from "react-i18next";
import { FormControl, Link, Stack } from "@mui/material";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import * as React from "react";
import { NoMarginHelperText } from "../Settings.tsx";
export interface CapCaptchaProps {
values: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
}
const CapCaptcha = ({ values, setSettings }: CapCaptchaProps) => {
const { t } = useTranslation("dashboard");
return (
<Stack spacing={3}>
<SettingForm title={t("settings.capInstanceURL")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_cap_instance_url}
onChange={(e) =>
setSettings({
captcha_cap_instance_url: e.target.value,
})
}
placeholder="https://cap.example.com"
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.capInstanceURLDes"
ns={"dashboard"}
components={[
<Link
key={0}
href={"https://capjs.js.org/guide/standalone.html"}
target={"_blank"}
/>,
]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.capKeyID")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_cap_key_id}
onChange={(e) =>
setSettings({
captcha_cap_key_id: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.capKeyIDDes"
ns={"dashboard"}
components={[
<Link
key={0}
href={"https://capjs.js.org/guide/standalone.html"}
target={"_blank"}
/>,
]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.capKeySecret")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_cap_key_secret}
onChange={(e) =>
setSettings({
captcha_cap_key_secret: e.target.value,
})
}
type="password"
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.capKeySecretDes"
ns={"dashboard"}
components={[
<Link
key={0}
href={"https://capjs.js.org/guide/standalone.html"}
target={"_blank"}
/>,
]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
);
};
export default CapCaptcha;

View File

@ -25,6 +25,7 @@ import { CaptchaType } from "../../../../api/site.ts";
import GraphicCaptcha from "./GraphicCaptcha.tsx";
import ReCaptcha from "./ReCaptcha.tsx";
import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
import CapCaptcha from "./CapCaptcha.tsx";
const Captcha = () => {
const { t } = useTranslation("dashboard");
@ -136,6 +137,13 @@ const Captcha = () => {
{t("settings.turnstile")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={CaptchaType.CAP}>
<ListItemText slotProps={{
primary: { variant: "body2" }
}}>
{t("settings.cap")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>
{t("settings.captchaTypeDes")}
@ -160,6 +168,12 @@ const Captcha = () => {
>
<TurnstileCaptcha setSettings={setSettings} values={values} />
</Collapse>
<Collapse
in={values.captcha_type === CaptchaType.CAP}
unmountOnExit
>
<CapCaptcha setSettings={setSettings} values={values} />
</Collapse>
</SettingSectionContent>
</SettingSection>
</Stack>

View File

@ -221,6 +221,9 @@ const Settings = () => {
"captcha_ReCaptchaSecret",
"captcha_turnstile_site_key",
"captcha_turnstile_site_secret",
"captcha_cap_instance_url",
"captcha_cap_key_id",
"captcha_cap_key_secret",
]}
>
<Captcha />

View File

@ -0,0 +1,128 @@
import { useEffect, useRef } from "react";
import { useAppSelector } from "../../../redux/hooks.ts";
import { CaptchaParams } from "./Captcha.tsx";
import { Box } from "@mui/material";
export interface CapProps {
onStateChange: (state: CaptchaParams) => void;
generation: number;
}
const CapCaptcha = ({ onStateChange, generation, ...rest }: CapProps) => {
const captchaRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<any>(null);
const onStateChangeRef = useRef(onStateChange);
const scriptLoadedRef = useRef(false);
const capInstanceURL = useAppSelector(
(state) => state.siteConfig.basic.config.captcha_cap_instance_url,
);
const capKeyID = useAppSelector(
(state) => state.siteConfig.basic.config.captcha_cap_key_id,
);
// Keep callback reference up to date
useEffect(() => {
onStateChangeRef.current = onStateChange;
}, [onStateChange]);
const createWidget = () => {
if (!captchaRef.current || !capInstanceURL || !capKeyID) {
return;
}
// Clean up existing widget
if (widgetRef.current) {
widgetRef.current.remove?.();
widgetRef.current = null;
}
// Clear container
captchaRef.current.innerHTML = "";
if (typeof window !== "undefined" && (window as any).Cap) {
const widget = document.createElement("cap-widget");
widget.setAttribute("data-cap-api-endpoint", `${capInstanceURL.replace(/\/$/, "")}/${capKeyID}/api/`);
widget.id = "cap-widget";
captchaRef.current.appendChild(widget);
widget.addEventListener("solve", (e: any) => {
const token = e.detail.token;
if (token) {
onStateChangeRef.current({ ticket: token });
}
});
widgetRef.current = widget;
}
};
const refreshCaptcha = () => {
createWidget();
};
useEffect(() => {
if (generation > 0) {
refreshCaptcha();
}
}, [generation]);
useEffect(() => {
if (!capInstanceURL || !capKeyID) {
return;
}
const scriptId = "cap-widget-script";
let script = document.getElementById(scriptId) as HTMLScriptElement;
const initWidget = () => {
scriptLoadedRef.current = true;
// Add a small delay to ensure DOM is ready
setTimeout(() => {
createWidget();
}, 100);
};
if (!script) {
script = document.createElement("script");
script.id = scriptId;
script.src = `${capInstanceURL.replace(/\/$/, "")}/assets/widget.js`;
script.async = true;
script.onload = initWidget;
script.onerror = () => {
console.error("Failed to load Cap widget script");
};
document.head.appendChild(script);
} else if (scriptLoadedRef.current || (window as any).Cap) {
// Script already loaded
initWidget();
} else {
// Script exists but not loaded yet
script.onload = initWidget;
}
return () => {
// Clean up widget but keep script for reuse
if (widgetRef.current) {
widgetRef.current.remove?.();
widgetRef.current = null;
}
if (captchaRef.current) {
captchaRef.current.innerHTML = "";
}
};
}, [capInstanceURL, capKeyID]);
if (!capInstanceURL || !capKeyID) {
return null;
}
return (
<Box sx={{ textAlign: "center" }}>
<div ref={captchaRef} {...rest} />
</Box>
);
};
export default CapCaptcha;

View File

@ -3,6 +3,7 @@ import { CaptchaType } from "../../../api/site.ts";
import DefaultCaptcha from "./DefaultCaptcha.tsx";
import ReCaptchaV2 from "./ReCaptchaV2.tsx";
import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
import CapCaptcha from "./CapCaptcha.tsx";
export interface CaptchaProps {
onStateChange: (state: CaptchaParams) => void;
@ -27,6 +28,8 @@ export const Captcha = (props: CaptchaProps) => {
return <ReCaptchaV2 {...props} />;
case CaptchaType.TURNSTILE:
return <TurnstileCaptcha {...props} />;
case CaptchaType.CAP:
return <CapCaptcha {...props} />;
// case "tcaptcha":
// return { ...tcaptcha, captchaRefreshRef, captchaLoading };
default: