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 documentation0>.",
+ "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>スタンドアロンモードドキュメント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>独立模式文档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>獨立模式文檔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: