diff --git a/public/locales/en-US/dashboard.json b/public/locales/en-US/dashboard.json index 3d64b92..11afab6 100644 --- a/public/locales/en-US/dashboard.json +++ b/public/locales/en-US/dashboard.json @@ -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.", + "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", diff --git a/public/locales/ja-JP/dashboard.json b/public/locales/ja-JP/dashboard.json index 8f14dbc..3dd170c 100644 --- a/public/locales/ja-JP/dashboard.json +++ b/public/locales/ja-JP/dashboard.json @@ -426,6 +426,13 @@ "turnstile": "Cloudflare Turnstile", "turnstileSiteKey": "サイトキー", "turnstileSiteKSecret": "キー", + "cap": "Cap", + "capInstanceURL": "インスタンス URL", + "capInstanceURLDes": "自身でホストしている Cap サーバーの URL。詳細については、<0>スタンドアロンモードドキュメント を参照してください。", + "capKeyID": "キー ID", + "capKeyIDDes": "Cap サーバーダッシュボードから取得したキー ID。", + "capKeySecret": "キーシークレット", + "capKeySecretDes": "Cap サーバーダッシュボードから取得したキーシークレット。", "captchaProvider": "認証コードタイプ", "captchaWidth": "幅", "captchaHeight": "高さ", diff --git a/public/locales/zh-CN/dashboard.json b/public/locales/zh-CN/dashboard.json index 053cd28..1007607 100644 --- a/public/locales/zh-CN/dashboard.json +++ b/public/locales/zh-CN/dashboard.json @@ -426,6 +426,13 @@ "turnstile": "Cloudflare Turnstile", "turnstileSiteKey": "站点密钥", "turnstileSiteKSecret": "密钥", + "cap": "Cap", + "capInstanceURL": "实例 URL", + "capInstanceURLDes": "自部署 Cap 服务器的 URL 地址。详细信息请参考 <0>独立模式文档。", + "capKeyID": "密钥 ID", + "capKeyIDDes": "从 Cap 服务器控制面板获取的密钥 ID。", + "capKeySecret": "密钥密码", + "capKeySecretDes": "从 Cap 服务器控制面板获取的密钥密码。", "captchaProvider": "验证码类型", "captchaWidth": "宽度", "captchaHeight": "高度", diff --git a/public/locales/zh-TW/dashboard.json b/public/locales/zh-TW/dashboard.json index 30d724f..9182cf4 100644 --- a/public/locales/zh-TW/dashboard.json +++ b/public/locales/zh-TW/dashboard.json @@ -423,6 +423,13 @@ "turnstile": "Cloudflare Turnstile", "turnstileSiteKey": "站點金鑰", "turnstileSiteKSecret": "金鑰", + "cap": "Cap", + "capInstanceURL": "實例 URL", + "capInstanceURLDes": "自部署 Cap 伺服器的 URL 地址。詳細資訊請參考 <0>獨立模式文檔。", + "capKeyID": "金鑰 ID", + "capKeyIDDes": "從 Cap 伺服器控制面板獲取的金鑰 ID。", + "capKeySecret": "金鑰密碼", + "capKeySecretDes": "從 Cap 伺服器控制面板獲取的金鑰密碼。", "captchaProvider": "驗證碼型別", "captchaWidth": "寬度", "captchaHeight": "高度", diff --git a/src/api/site.ts b/src/api/site.ts index 331bf4a..aecb4fd 100644 --- a/src/api/site.ts +++ b/src/api/site.ts @@ -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; diff --git a/src/component/Admin/Settings/Captcha/CapCaptcha.tsx b/src/component/Admin/Settings/Captcha/CapCaptcha.tsx new file mode 100644 index 0000000..4b8eedf --- /dev/null +++ b/src/component/Admin/Settings/Captcha/CapCaptcha.tsx @@ -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 ( + + + + + setSettings({ + captcha_cap_instance_url: e.target.value, + }) + } + placeholder="https://cap.example.com" + required + /> + + , + ]} + /> + + + + + + + setSettings({ + captcha_cap_key_id: e.target.value, + }) + } + required + /> + + , + ]} + /> + + + + + + + setSettings({ + captcha_cap_key_secret: e.target.value, + }) + } + type="password" + required + /> + + , + ]} + /> + + + + + ); +}; + +export default CapCaptcha; \ No newline at end of file diff --git a/src/component/Admin/Settings/Captcha/Captcha.tsx b/src/component/Admin/Settings/Captcha/Captcha.tsx index 1a8b134..1dc78b0 100644 --- a/src/component/Admin/Settings/Captcha/Captcha.tsx +++ b/src/component/Admin/Settings/Captcha/Captcha.tsx @@ -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")} + + + {t("settings.cap")} + + {t("settings.captchaTypeDes")} @@ -160,6 +168,12 @@ const Captcha = () => { > + + + diff --git a/src/component/Admin/Settings/Settings.tsx b/src/component/Admin/Settings/Settings.tsx index 2c63551..0ab3c4f 100644 --- a/src/component/Admin/Settings/Settings.tsx +++ b/src/component/Admin/Settings/Settings.tsx @@ -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", ]} > diff --git a/src/component/Common/Captcha/CapCaptcha.tsx b/src/component/Common/Captcha/CapCaptcha.tsx new file mode 100644 index 0000000..2667cf8 --- /dev/null +++ b/src/component/Common/Captcha/CapCaptcha.tsx @@ -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(null); + const widgetRef = useRef(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 ( + +
+ + ); +}; + +export default CapCaptcha; \ No newline at end of file diff --git a/src/component/Common/Captcha/Captcha.tsx b/src/component/Common/Captcha/Captcha.tsx index c0ea5a3..c440a5c 100644 --- a/src/component/Common/Captcha/Captcha.tsx +++ b/src/component/Common/Captcha/Captcha.tsx @@ -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 ; case CaptchaType.TURNSTILE: return ; + case CaptchaType.CAP: + return ; // case "tcaptcha": // return { ...tcaptcha, captchaRefreshRef, captchaLoading }; default: