diff --git a/ROADMAP.md b/ROADMAP.md
index 949266f2..8ab3b4c9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -222,6 +222,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Storage policy overflow chain (upstream #2178 item 4) — `PolicySetting.OverflowPolicyID` links a fallback policy; `overflowChain` walks hops with cycle guard + hop cap, skipping suspended members and resolving load-balance members to weighted children; `PrepareUpload` spills to the first member with headroom for the file size so name/size/extension rules apply to the landing policy; `PreValidateUpload` checks aggregate chain headroom since batches may split across members; admin policy editor gains an Overflow policy select, en+zh locales
- [x] Thumbnail generation controls (upstream #2178 items 7+8) — `PolicySetting.ThumbForceProxy` skips the backend's native thumbnail API even when supported, implying the local proxy pipeline; `PolicySetting.ThumbStoragePolicyID` redirects generated thumb entities to a designated policy (honored via `PreferredStoragePolicy` for thumbnail uploads in `PrepareUpload`); Thumbnails section gains both controls, en+zh locales
- [x] Per-user blob relocation (upstream #2729/misc) — `RelocateEntityService` gains a third scope `src_user_id` (mutually exclusive with `entity_ids`/`src_policy_id`); `NewRelocateUserTask` selects entities by `created_by` with the same cursor-resumable transfer path; admin user editor gains a "Relocate files" button opening the relocate dialog prefilled with the user scope; en+zh locales
+- [x] Tencent Captcha (upstream #2178) — `captcha_type=tcaptcha` now performs real verification: `pkg/tcaptcha` calls Tencent Cloud `DescribeCaptchaResult` with full TC3-HMAC-SHA256 request signing (CaptchaAppId/AppSecretKey + SecretId/SecretKey, CaptchaType 9, client IP propagated); login/register/forgot-password flows emit `{ticket, randstr}` from the TCaptcha.js popup widget via a new `TCaptcha` verify-button component; admin Captcha section gains the provider option + four credential fields; en+zh locales
## 6. Phase D — desktop, all platforms
diff --git a/frontend/public/locales/en-US/common.json b/frontend/public/locales/en-US/common.json
index b72bb7de..3df7a55d 100644
--- a/frontend/public/locales/en-US/common.json
+++ b/frontend/public/locales/en-US/common.json
@@ -34,7 +34,9 @@
"human": "I'm a human",
"verifying": "Verifying...",
"verified": "You're a human"
- }
+ },
+ "verify": "Click to verify",
+ "verified": "Verified"
},
"errors": {
"401": "Please login.",
diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json
index cd614ecd..852d03a8 100644
--- a/frontend/public/locales/en-US/dashboard.json
+++ b/frontend/public/locales/en-US/dashboard.json
@@ -543,6 +543,13 @@
"plainCaptcha": "Plain graphic",
"reCaptchaV2": "reCAPTCHA V2",
"turnstile": "Cloudflare Turnstile",
+ "tcaptcha": "Tencent Captcha",
+ "tcaptchaAppId": "CaptchaAppId",
+ "tcaptchaAppSecretKey": "AppSecretKey",
+ "tcaptchaSecretId": "SecretId",
+ "tcaptchaSecretKey": "SecretKey",
+ "tcaptchaDes": "Get your CaptchaAppId and AppSecretKey from the <0>Tencent Captcha console0>.",
+ "tcaptchaSecretDes": "SecretId and SecretKey of a Tencent Cloud API credential used to call DescribeCaptchaResult.",
"turnstileSiteKey": "Site Key",
"turnstileSiteKSecret": "Secret",
"cap": "Cap",
diff --git a/frontend/public/locales/zh-CN/common.json b/frontend/public/locales/zh-CN/common.json
index 3166c66b..7e1da725 100644
--- a/frontend/public/locales/zh-CN/common.json
+++ b/frontend/public/locales/zh-CN/common.json
@@ -34,7 +34,9 @@
"human": "我是人类",
"verifying": "验证中…",
"verified": "您通过了验证"
- }
+ },
+ "verify": "点击验证",
+ "verified": "已验证"
},
"errors": {
"401": "请先登录",
diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json
index a94e1cd3..5ce2b8e0 100644
--- a/frontend/public/locales/zh-CN/dashboard.json
+++ b/frontend/public/locales/zh-CN/dashboard.json
@@ -543,6 +543,13 @@
"plainCaptcha": "图形",
"reCaptchaV2": "reCAPTCHA V2",
"turnstile": "Cloudflare Turnstile",
+ "tcaptcha": "腾讯验证码",
+ "tcaptchaAppId": "CaptchaAppId",
+ "tcaptchaAppSecretKey": "AppSecretKey",
+ "tcaptchaSecretId": "SecretId",
+ "tcaptchaSecretKey": "SecretKey",
+ "tcaptchaDes": "在 <0>腾讯验证码控制台0> 获取 CaptchaAppId 和 AppSecretKey。",
+ "tcaptchaSecretDes": "用于调用 DescribeCaptchaResult 接口的腾讯云 API 密钥(SecretId / SecretKey)。",
"turnstileSiteKey": "站点密钥",
"turnstileSiteKSecret": "密钥",
"cap": "Cap",
diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts
index fdc2238c..697ea048 100644
--- a/frontend/src/api/site.ts
+++ b/frontend/src/api/site.ts
@@ -4,7 +4,6 @@ import { User } from "./user.ts";
export enum CaptchaType {
NORMAL = "normal",
RECAPTCHA = "recaptcha",
- // Deprecated
TCAPTCHA = "tcaptcha",
TURNSTILE = "turnstile",
CAP = "cap",
@@ -22,6 +21,7 @@ export interface SiteConfig {
user?: User;
captcha_ReCaptchaKey?: string;
captcha_type?: CaptchaType;
+ tcaptcha_app_id?: string;
turnstile_site_id?: string;
captcha_cap_instance_url?: string;
captcha_cap_site_key?: string;
diff --git a/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx b/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx
index e2771f3e..63a67aac 100644
--- a/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx
+++ b/frontend/src/component/Admin/Settings/Captcha/Captcha.tsx
@@ -11,6 +11,7 @@ import { SettingContext } from "../SettingWrapper.tsx";
import CapCaptcha from "./CapCaptcha.tsx";
import GraphicCaptcha from "./GraphicCaptcha.tsx";
import ReCaptcha from "./ReCaptcha.tsx";
+import TCaptcha from "./TCaptcha.tsx";
import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
const Captcha = () => {
@@ -132,6 +133,15 @@ const Captcha = () => {
{t("settings.reCaptchaV2")}
+
+
+ {t("settings.tcaptcha")}
+
+
{
+
+
+
diff --git a/frontend/src/component/Admin/Settings/Captcha/TCaptcha.tsx b/frontend/src/component/Admin/Settings/Captcha/TCaptcha.tsx
new file mode 100644
index 00000000..9c53b28e
--- /dev/null
+++ b/frontend/src/component/Admin/Settings/Captcha/TCaptcha.tsx
@@ -0,0 +1,66 @@
+import { FormControl, Stack } from "@mui/material";
+import { Trans, useTranslation } from "react-i18next";
+import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
+import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
+import { NoMarginHelperText } from "../Settings.tsx";
+
+export interface TCaptchaProps {
+ values: {
+ [key: string]: string;
+ };
+ setSettings: (settings: { [key: string]: string }) => void;
+}
+
+const TCaptcha = ({ values, setSettings }: TCaptchaProps) => {
+ const { t } = useTranslation("dashboard");
+ return (
+
+
+
+ setSettings({ captcha_TCaptcha_CaptchaAppId: e.target.value })}
+ required
+ />
+
+ ]}
+ />
+
+
+
+
+
+ setSettings({ captcha_TCaptcha_AppSecretKey: e.target.value })}
+ required
+ />
+
+
+
+
+ setSettings({ captcha_TCaptcha_SecretId: e.target.value })}
+ required
+ />
+ {t("settings.tcaptchaSecretDes")}
+
+
+
+
+ setSettings({ captcha_TCaptcha_SecretKey: e.target.value })}
+ required
+ />
+
+
+
+ );
+};
+
+export default TCaptcha;
diff --git a/frontend/src/component/Admin/Settings/Settings.tsx b/frontend/src/component/Admin/Settings/Settings.tsx
index 7cd858f5..f7c46a27 100644
--- a/frontend/src/component/Admin/Settings/Settings.tsx
+++ b/frontend/src/component/Admin/Settings/Settings.tsx
@@ -234,6 +234,10 @@ const Settings = () => {
"captcha_CaptchaLen",
"captcha_ReCaptchaKey",
"captcha_ReCaptchaSecret",
+ "captcha_TCaptcha_CaptchaAppId",
+ "captcha_TCaptcha_AppSecretKey",
+ "captcha_TCaptcha_SecretId",
+ "captcha_TCaptcha_SecretKey",
"captcha_turnstile_site_key",
"captcha_turnstile_site_secret",
"captcha_cap_instance_url",
diff --git a/frontend/src/component/Common/Captcha/Captcha.tsx b/frontend/src/component/Common/Captcha/Captcha.tsx
index 49325653..2c7b1ff6 100644
--- a/frontend/src/component/Common/Captcha/Captcha.tsx
+++ b/frontend/src/component/Common/Captcha/Captcha.tsx
@@ -3,6 +3,7 @@ import { useAppSelector } from "../../../redux/hooks.ts";
import CapCaptcha from "./CapCaptcha.tsx";
import DefaultCaptcha from "./DefaultCaptcha.tsx";
import ReCaptchaV2 from "./ReCaptchaV2.tsx";
+import TCaptcha from "./TCaptcha.tsx";
import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
export interface CaptchaProps {
@@ -19,18 +20,15 @@ export interface CaptchaParams {
export const Captcha = (props: CaptchaProps) => {
const captchaType = useAppSelector((state) => state.siteConfig.basic.config.captcha_type);
- // const recaptcha = useRecaptcha(setCaptchaLoading);
- // const tcaptcha = useTCaptcha(setCaptchaLoading);
-
switch (captchaType) {
case CaptchaType.RECAPTCHA:
return ;
+ case CaptchaType.TCAPTCHA:
+ return ;
case CaptchaType.TURNSTILE:
return ;
case CaptchaType.CAP:
return ;
- // case "tcaptcha":
- // return { ...tcaptcha, captchaRefreshRef, captchaLoading };
default:
return ;
}
diff --git a/frontend/src/component/Common/Captcha/TCaptcha.tsx b/frontend/src/component/Common/Captcha/TCaptcha.tsx
new file mode 100644
index 00000000..c137fc72
--- /dev/null
+++ b/frontend/src/component/Common/Captcha/TCaptcha.tsx
@@ -0,0 +1,111 @@
+import { Box } from "@mui/material";
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useAppSelector } from "../../../redux/hooks.ts";
+import { SecondaryButton } from "../StyledComponents.tsx";
+import { CaptchaParams } from "./Captcha.tsx";
+
+const TCAPTCHA_SCRIPT = "https://turing.captcha.qcloud.com/TCaptcha.js";
+const TCAPTCHA_SCRIPT_ID = "tcaptcha-script";
+
+export interface TCaptchaProps {
+ onStateChange: (state: CaptchaParams) => void;
+ generation: number;
+ fullWidth?: boolean;
+ [x: string]: unknown;
+}
+
+declare global {
+ interface Window {
+ TencentCaptcha?: new (
+ appId: string,
+ callback: (res: { ret: number; ticket: string; randstr: string }) => void,
+ options?: Record,
+ ) => { show: () => void; destroy?: () => void };
+ }
+}
+
+// TCaptcha renders a verify button opening Tencent's captcha popup. On
+// success the widget yields { ticket, randstr }, which the backend verifies
+// via DescribeCaptchaResult.
+const TCaptcha = ({ onStateChange, generation, fullWidth, ...rest }: TCaptchaProps) => {
+ const { t } = useTranslation("common");
+ const appId = useAppSelector((state) => state.siteConfig.basic.config.tcaptcha_app_id);
+ const [ready, setReady] = useState(false);
+ const [verified, setVerified] = useState(false);
+ const captchaRef = useRef<{ show: () => void; destroy?: () => void } | null>(null);
+ const onStateChangeRef = useRef(onStateChange);
+
+ useEffect(() => {
+ onStateChangeRef.current = onStateChange;
+ }, [onStateChange]);
+
+ useEffect(() => {
+ if (!appId) {
+ return;
+ }
+
+ let script = document.getElementById(TCAPTCHA_SCRIPT_ID) as HTMLScriptElement | null;
+ const onLoad = () => setReady(true);
+ if (!script) {
+ script = document.createElement("script");
+ script.id = TCAPTCHA_SCRIPT_ID;
+ script.src = TCAPTCHA_SCRIPT;
+ script.async = true;
+ script.onload = onLoad;
+ document.head.appendChild(script);
+ } else if (window.TencentCaptcha) {
+ setReady(true);
+ } else {
+ script.onload = onLoad;
+ }
+ }, [appId]);
+
+ // Regeneration invalidates a previous solve.
+ useEffect(() => {
+ if (generation > 0) {
+ setVerified(false);
+ captchaRef.current = null;
+ }
+ }, [generation]);
+
+ const openCaptcha = () => {
+ if (!window.TencentCaptcha || !appId) {
+ return;
+ }
+
+ captchaRef.current?.destroy?.();
+ const instance = new window.TencentCaptcha(
+ appId,
+ (res) => {
+ if (res.ret === 0) {
+ setVerified(true);
+ onStateChangeRef.current({ ticket: res.ticket, randstr: res.randstr });
+ }
+ },
+ { needFeedBack: false },
+ );
+ captchaRef.current = instance;
+ instance.show();
+ };
+
+ if (!appId) {
+ return null;
+ }
+
+ return (
+
+
+ {verified ? t("captcha.verified") : t("captcha.verify")}
+
+
+ );
+};
+
+export default TCaptcha;
diff --git a/inventory/setting.go b/inventory/setting.go
index 18e1f004..53bd11fa 100644
--- a/inventory/setting.go
+++ b/inventory/setting.go
@@ -591,6 +591,10 @@ var DefaultSettings = map[string]string{
"captcha_CaptchaLen": "6",
"captcha_ReCaptchaKey": "defaultKey",
"captcha_ReCaptchaSecret": "defaultSecret",
+ "captcha_TCaptcha_CaptchaAppId": "",
+ "captcha_TCaptcha_AppSecretKey": "",
+ "captcha_TCaptcha_SecretId": "",
+ "captcha_TCaptcha_SecretKey": "",
"captcha_turnstile_site_key": "",
"captcha_turnstile_site_secret": "",
"captcha_cap_instance_url": "",
diff --git a/middleware/captcha.go b/middleware/captcha.go
index 9169d5f7..82ce0969 100644
--- a/middleware/captcha.go
+++ b/middleware/captcha.go
@@ -15,6 +15,7 @@ import (
request2 "github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
+ "github.com/cloudreve/Cloudreve/v4/pkg/tcaptcha"
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
)
@@ -71,13 +72,48 @@ func CaptchaRequired(enabled func(c *gin.Context) bool) gin.HandlerFunc {
c.Request.Body = io.NopCloser(bytes.NewReader(bodyData))
switch settings.CaptchaType(c) {
- case setting.CaptchaNormal, setting.CaptchaTcaptcha:
+ case setting.CaptchaNormal:
if service.Ticket == "" || !base64Captcha.VerifyCaptcha(service.Ticket, service.Captcha) {
c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, err))
c.Abort()
return
}
+ break
+ case setting.CaptchaTcaptcha:
+ captchaSetting := settings.TcCaptcha(c)
+ if captchaSetting.AppID == "" || captchaSetting.AppSecretKey == "" ||
+ captchaSetting.SecretID == "" || captchaSetting.SecretKey == "" {
+ l.Warning("TCaptcha verification failed: missing configuration")
+ c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha configuration error", nil))
+ c.Abort()
+ return
+ }
+
+ if service.Ticket == "" || service.Randstr == "" {
+ c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaNotMatch, nil))
+ c.Abort()
+ return
+ }
+
+ r := dep.RequestClient(
+ request2.WithContext(c),
+ request2.WithLogger(logging.FromContext(c)),
+ )
+ ok, err := tcaptcha.Verify(c, r, captchaSetting, service.Ticket, service.Randstr, c.ClientIP())
+ if err != nil {
+ l.Warning("TCaptcha verification failed: %s", err)
+ c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, "Captcha validation failed", err))
+ c.Abort()
+ return
+ }
+
+ if !ok {
+ c.JSON(200, serializer.ErrWithDetails(c, serializer.CodeCaptchaError, captchaRefresh, nil))
+ c.Abort()
+ return
+ }
+
break
case setting.CaptchaReCaptcha:
captchaSetting := settings.ReCaptcha(c)
diff --git a/pkg/tcaptcha/client.go b/pkg/tcaptcha/client.go
new file mode 100644
index 00000000..94ddeb0b
--- /dev/null
+++ b/pkg/tcaptcha/client.go
@@ -0,0 +1,138 @@
+// Package tcaptcha verifies Tencent Cloud Captcha tickets via the
+// DescribeCaptchaResult API (TencentCloud API v3, TC3-HMAC-SHA256 signing).
+package tcaptcha
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/request"
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+)
+
+const (
+ endpoint = "captcha.tencentcloudapi.com"
+ service = "captcha"
+ version = "2019-07-22"
+ action = "DescribeCaptchaResult"
+ signAlgorithm = "TC3-HMAC-SHA256"
+ signedHeaders = "content-type;host;x-tc-action"
+ captchaTypeNew = 9 // interactive captcha (popup widget)
+)
+
+type describeResultResponse struct {
+ Response struct {
+ CaptchaCode int `json:"CaptchaCode"`
+ CaptchaMsg string `json:"CaptchaMsg"`
+ Error *struct {
+ Code string `json:"Code"`
+ Message string `json:"Message"`
+ } `json:"Error"`
+ } `json:"Response"`
+}
+
+// Verify checks a ticket produced by the Tencent captcha widget. CaptchaCode
+// 1 means the ticket is valid.
+func Verify(ctx context.Context, client request.Client, cfg *setting.TcCaptcha, ticket, randstr, userIP string) (bool, error) {
+ appID, err := strconv.ParseInt(strings.TrimSpace(cfg.AppID), 10, 64)
+ if err != nil {
+ return false, fmt.Errorf("invalid CaptchaAppId: %w", err)
+ }
+
+ payload, err := json.Marshal(map[string]any{
+ "CaptchaType": captchaTypeNew,
+ "Ticket": ticket,
+ "Randstr": randstr,
+ "UserIp": userIP,
+ "CaptchaAppId": appID,
+ "AppSecretKey": cfg.AppSecretKey,
+ })
+ if err != nil {
+ return false, err
+ }
+
+ timestamp := time.Now().Unix()
+ auth := sign(cfg.SecretID, cfg.SecretKey, string(payload), timestamp)
+
+ res, err := client.Request(
+ "POST",
+ "https://"+endpoint,
+ strings.NewReader(string(payload)),
+ request.WithContext(ctx),
+ request.WithHeader(http.Header{
+ "Content-Type": []string{"application/json; charset=utf-8"},
+ "Host": []string{endpoint},
+ "X-TC-Action": []string{action},
+ "X-TC-Version": []string{version},
+ "X-TC-Timestamp": []string{strconv.FormatInt(timestamp, 10)},
+ "Authorization": []string{auth},
+ }),
+ ).CheckHTTPResponse(http.StatusOK).GetResponse()
+ if err != nil {
+ return false, err
+ }
+
+ var parsed describeResultResponse
+ if err := json.Unmarshal([]byte(res), &parsed); err != nil {
+ return false, fmt.Errorf("failed to parse captcha result: %w", err)
+ }
+ if parsed.Response.Error != nil {
+ return false, fmt.Errorf("captcha api error %s: %s", parsed.Response.Error.Code, parsed.Response.Error.Message)
+ }
+ return parsed.Response.CaptchaCode == 1, nil
+}
+
+// sign builds the TC3-HMAC-SHA256 Authorization header value.
+func sign(secretID, secretKey, payload string, timestamp int64) string {
+ date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
+
+ canonicalRequest := strings.Join([]string{
+ "POST",
+ "/",
+ "",
+ "content-type:application/json; charset=utf-8\n" +
+ "host:" + endpoint + "\n" +
+ "x-tc-action:" + strings.ToLower(action) + "\n",
+ signedHeaders,
+ sha256Hex(payload),
+ }, "\n")
+
+ credentialScope := date + "/" + service + "/tc3_request"
+ stringToSign := strings.Join([]string{
+ signAlgorithm,
+ strconv.FormatInt(timestamp, 10),
+ credentialScope,
+ sha256Hex(canonicalRequest),
+ }, "\n")
+
+ signingKey := hmacSHA256(
+ hmacSHA256(
+ hmacSHA256([]byte("TC3"+secretKey), date),
+ service,
+ ),
+ "tc3_request",
+ )
+ signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
+
+ return fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s",
+ signAlgorithm, secretID, credentialScope, signedHeaders, signature)
+}
+
+func sha256Hex(s string) string {
+ sum := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(sum[:])
+}
+
+func hmacSHA256(key []byte, data string) []byte {
+ h := hmac.New(sha256.New, key)
+ h.Write([]byte(data))
+ return h.Sum(nil)
+}
diff --git a/pkg/tcaptcha/client_test.go b/pkg/tcaptcha/client_test.go
new file mode 100644
index 00000000..57dcd672
--- /dev/null
+++ b/pkg/tcaptcha/client_test.go
@@ -0,0 +1,29 @@
+package tcaptcha
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/cloudreve/Cloudreve/v4/pkg/setting"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSignProducesStableAuthorization(t *testing.T) {
+ auth := sign("AKIDTEST", "secret", `{"CaptchaType":9}`, 1700000000)
+
+ require.True(t, strings.HasPrefix(auth, "TC3-HMAC-SHA256 Credential=AKIDTEST/2023-11-14/captcha/tc3_request"))
+ require.Contains(t, auth, "SignedHeaders=content-type;host;x-tc-action")
+ require.Contains(t, auth, "Signature=")
+
+ // Signature is the last 64 hex chars and deterministic.
+ sig := auth[strings.LastIndex(auth, "Signature=")+len("Signature="):]
+ require.Len(t, sig, 64)
+ require.Equal(t, auth, sign("AKIDTEST", "secret", `{"CaptchaType":9}`, 1700000000))
+ require.NotEqual(t, auth, sign("AKIDTEST", "secret2", `{"CaptchaType":9}`, 1700000000))
+}
+
+func TestVerifyRejectsBadAppID(t *testing.T) {
+ ok, err := Verify(t.Context(), nil, &setting.TcCaptcha{AppID: "not-a-number"}, "t", "r", "1.2.3.4")
+ require.Error(t, err)
+ require.False(t, ok)
+}
diff --git a/service/basic/site.go b/service/basic/site.go
index 1415eb86..f8d9b267 100644
--- a/service/basic/site.go
+++ b/service/basic/site.go
@@ -40,6 +40,7 @@ type SiteConfig struct {
ForgetCaptcha bool `json:"forget_captcha,omitempty"`
Authn bool `json:"authn,omitempty"`
ReCaptchaKey string `json:"captcha_ReCaptchaKey,omitempty"`
+ TCaptchaAppID string `json:"tcaptcha_app_id,omitempty"`
CaptchaType setting.CaptchaType `json:"captcha_type,omitempty"`
TurnstileSiteID string `json:"turnstile_site_id,omitempty"`
CapInstanceURL string `json:"captcha_cap_instance_url,omitempty"`
@@ -254,6 +255,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
Logo: logo.Normal,
LogoLight: logo.Light,
CaptchaType: settings.CaptchaType(c),
+ TCaptchaAppID: settings.TcCaptcha(c).AppID,
TurnstileSiteID: settings.TurnstileCaptcha(c).Key,
ReCaptchaKey: reCaptcha.Key,
CapInstanceURL: capCaptcha.InstanceURL,