feat: add captcha verification for login form
- Add /api/captcha endpoint with captcha generation/verification service - Add captcha_enabled option in system settings (model/option.go) - Add captcha display and refresh logic in LoginForm - Fix captcha not showing on Chrome due to Semi UI Form.Input suffix incompatibility; refactored to standalone flex layout - Fix stale localStorage cache preventing captcha rendering on first visit by proactively fetching /api/status on LoginForm mount - Add captcha_enabled toggle in admin SystemSetting panel - Add i18n keys for captcha-related text (zh-CN, en)
This commit is contained in:
parent
aa1c9559c3
commit
b63f75ade4
|
|
@ -49,6 +49,7 @@ var LinuxDOOAuthEnabled = false
|
|||
var WeChatAuthEnabled = false
|
||||
var TelegramOAuthEnabled = false
|
||||
var TurnstileCheckEnabled = false
|
||||
var CaptchaEnabled = false
|
||||
var RegisterEnabled = true
|
||||
|
||||
var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetCaptcha 获取验证码图片
|
||||
// GET /api/captcha
|
||||
func GetCaptcha(c *gin.Context) {
|
||||
if !common.CaptchaEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "验证码功能未启用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id, question, base64PNG, err := service.GenerateMathCaptcha()
|
||||
if err != nil {
|
||||
common.SysError("failed to generate captcha: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "验证码生成失败,请重试",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"captcha_id": id,
|
||||
"question": question,
|
||||
"captcha_image": base64PNG,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -79,6 +79,7 @@ func GetStatus(c *gin.Context) {
|
|||
"server_address": system_setting.ServerAddress,
|
||||
"turnstile_check": common.TurnstileCheckEnabled,
|
||||
"turnstile_site_key": common.TurnstileSiteKey,
|
||||
"captcha_enabled": common.CaptchaEnabled,
|
||||
"top_up_link": common.TopUpLink,
|
||||
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
|
||||
"default_site_language": operation_setting.GetDefaultSiteLanguage(),
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ import (
|
|||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
CaptchaCode string `json:"captcha_code"`
|
||||
}
|
||||
|
||||
// RegisterRequest 用户注册请求体:关闭短信注册时邮箱必填;开启短信注册时邮箱与手机号二选一(至少填其一);开启邮箱验证且填写了邮箱时需验证码;开启短信且填写了手机号时需短信验证码。邮箱/手机号占用仅与未注销用户冲突。
|
||||
|
|
@ -94,6 +96,23 @@ func Login(c *gin.Context) {
|
|||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
// 验证码校验
|
||||
if common.CaptchaEnabled {
|
||||
if loginRequest.CaptchaID == "" || loginRequest.CaptchaCode == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请输入验证码",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !service.VerifyCaptcha(loginRequest.CaptchaID, loginRequest.CaptchaCode) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "验证码错误或已过期,请刷新后重试",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
user := model.User{
|
||||
Username: username,
|
||||
Password: password,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func InitOptionMap() {
|
|||
common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
|
||||
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
|
||||
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
|
||||
common.OptionMap["CaptchaEnabled"] = strconv.FormatBool(common.CaptchaEnabled)
|
||||
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
|
||||
common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled)
|
||||
common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled)
|
||||
|
|
@ -343,6 +344,8 @@ func updateOptionMap(key string, value string) (err error) {
|
|||
common.TelegramOAuthEnabled = boolValue
|
||||
case "TurnstileCheckEnabled":
|
||||
common.TurnstileCheckEnabled = boolValue
|
||||
case "CaptchaEnabled":
|
||||
common.CaptchaEnabled = boolValue
|
||||
case "SMSVerificationEnabled":
|
||||
common.SMSVerificationEnabled = boolValue
|
||||
case "RegisterEnabled":
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
|
||||
apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing)
|
||||
apiRouter.POST("/price_sync", middleware.CriticalRateLimit(), controller.PriceSync)
|
||||
apiRouter.GET("/captcha", controller.GetCaptcha)
|
||||
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
|
||||
apiRouter.GET("/sms_verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendSMSVerification)
|
||||
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"math/rand"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
// CaptchaItem 表示一个验证码条目
|
||||
type CaptchaItem struct {
|
||||
ID string
|
||||
Answer string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
captchaStore = sync.Map{} // map[string]*CaptchaItem
|
||||
captchaCleaner sync.Once
|
||||
)
|
||||
|
||||
func initCaptchaCleaner() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
now := time.Now()
|
||||
captchaStore.Range(func(key, value interface{}) bool {
|
||||
item := value.(*CaptchaItem)
|
||||
if now.After(item.ExpiresAt) {
|
||||
captchaStore.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GenerateMathCaptcha 生成一个数学验证码,返回 id、问题表达式、base64 PNG 图片
|
||||
func GenerateMathCaptcha() (id string, question string, base64PNG string, err error) {
|
||||
captchaCleaner.Do(initCaptchaCleaner)
|
||||
|
||||
// 生成随机数学题
|
||||
a := rand.Intn(20) + 1 // 1-20
|
||||
b := rand.Intn(20) + 1 // 1-20
|
||||
op := rand.Intn(2) // 0: +, 1: -
|
||||
var answer int
|
||||
var opStr string
|
||||
if op == 0 {
|
||||
answer = a + b
|
||||
opStr = "+"
|
||||
} else {
|
||||
// 确保结果非负
|
||||
if a < b {
|
||||
a, b = b, a
|
||||
}
|
||||
answer = a - b
|
||||
opStr = "-"
|
||||
}
|
||||
|
||||
question = fmt.Sprintf("%d %s %d = ?", a, opStr, b)
|
||||
answerStr := fmt.Sprintf("%d", answer)
|
||||
|
||||
img, err := drawCaptchaImage(question)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
base64PNG = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
// 生成唯一 ID 并存储
|
||||
id = fmt.Sprintf("%d%06d", time.Now().UnixNano(), rand.Intn(1000000))
|
||||
captchaStore.Store(id, &CaptchaItem{
|
||||
ID: id,
|
||||
Answer: answerStr,
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
})
|
||||
|
||||
return id, question, base64PNG, nil
|
||||
}
|
||||
|
||||
// VerifyCaptcha 验证验证码,验证后立即使其失效
|
||||
func VerifyCaptcha(id string, answer string) bool {
|
||||
if id == "" || answer == "" {
|
||||
return false
|
||||
}
|
||||
val, ok := captchaStore.LoadAndDelete(id)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
item := val.(*CaptchaItem)
|
||||
if time.Now().After(item.ExpiresAt) {
|
||||
return false
|
||||
}
|
||||
return item.Answer == answer
|
||||
}
|
||||
|
||||
// drawCaptchaImage 将验证码文字绘制为带噪点的图片
|
||||
func drawCaptchaImage(text string) (*image.RGBA, error) {
|
||||
width := 160
|
||||
height := 50
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
|
||||
// 随机浅色背景
|
||||
bgR := uint8(220 + rand.Intn(36))
|
||||
bgG := uint8(220 + rand.Intn(36))
|
||||
bgB := uint8(220 + rand.Intn(36))
|
||||
bgColor := color.RGBA{bgR, bgG, bgB, 255}
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
|
||||
|
||||
// 绘制干扰线
|
||||
for i := 0; i < 4; i++ {
|
||||
x1 := rand.Intn(width)
|
||||
y1 := rand.Intn(height)
|
||||
x2 := rand.Intn(width)
|
||||
y2 := rand.Intn(height)
|
||||
lineColor := color.RGBA{
|
||||
uint8(rand.Intn(150)),
|
||||
uint8(rand.Intn(150)),
|
||||
uint8(rand.Intn(150)),
|
||||
255,
|
||||
}
|
||||
drawLine(img, x1, y1, x2, y2, lineColor)
|
||||
}
|
||||
|
||||
// 绘制干扰点
|
||||
for i := 0; i < 60; i++ {
|
||||
x := rand.Intn(width)
|
||||
y := rand.Intn(height)
|
||||
dotColor := color.RGBA{
|
||||
uint8(rand.Intn(200)),
|
||||
uint8(rand.Intn(200)),
|
||||
uint8(rand.Intn(200)),
|
||||
255,
|
||||
}
|
||||
img.Set(x, y, dotColor)
|
||||
}
|
||||
|
||||
// 绘制文字
|
||||
face := basicfont.Face7x13
|
||||
d := &font.Drawer{
|
||||
Dst: img,
|
||||
Src: &image.Uniform{color.RGBA{50, 50, 50, 255}},
|
||||
Face: face,
|
||||
}
|
||||
|
||||
// 计算文字宽度以居中
|
||||
textWidth := font.MeasureString(face, text).Ceil()
|
||||
startX := (width - textWidth) / 2
|
||||
if startX < 5 {
|
||||
startX = 5
|
||||
}
|
||||
|
||||
d.Dot = fixed.P(startX, 30)
|
||||
d.DrawString(text)
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// drawLine 在图像上绘制一条线段(Bresenham 算法简化版)
|
||||
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.RGBA) {
|
||||
dx := abs(x2 - x1)
|
||||
dy := abs(y2 - y1)
|
||||
steps := dx
|
||||
if dy > dx {
|
||||
steps = dy
|
||||
}
|
||||
if steps == 0 {
|
||||
img.Set(x1, y1, c)
|
||||
return
|
||||
}
|
||||
for i := 0; i <= steps; i++ {
|
||||
x := x1 + (x2-x1)*i/steps
|
||||
y := y1 + (y2-y1)*i/steps
|
||||
// 线宽为 2
|
||||
img.Set(x, y, c)
|
||||
if x+1 < img.Bounds().Max.X {
|
||||
img.Set(x+1, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
getSystemName,
|
||||
getOAuthProviderIcon,
|
||||
setUserData,
|
||||
setStatusData,
|
||||
onGitHubOAuthClicked,
|
||||
onDiscordOAuthClicked,
|
||||
onOIDCClicked,
|
||||
|
|
@ -50,6 +51,7 @@ import {
|
|||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
|
|
@ -97,7 +99,7 @@ const LoginForm = () => {
|
|||
};
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
|
|
@ -122,6 +124,11 @@ const LoginForm = () => {
|
|||
const [hasPrivacyPolicy, setHasPrivacyPolicy] = useState(false);
|
||||
const [githubButtonState, setGithubButtonState] = useState('idle');
|
||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false);
|
||||
const [captchaEnabled, setCaptchaEnabled] = useState(false);
|
||||
const [captchaId, setCaptchaId] = useState('');
|
||||
const [captchaCode, setCaptchaCode] = useState('');
|
||||
const [captchaImage, setCaptchaImage] = useState('');
|
||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||
const githubTimeoutRef = useRef(null);
|
||||
const githubButtonText = t(githubButtonTextKeyByState[githubButtonState]);
|
||||
const [customOAuthLoading, setCustomOAuthLoading] = useState({});
|
||||
|
|
@ -165,8 +172,34 @@ const LoginForm = () => {
|
|||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
||||
|
||||
// 验证码状态
|
||||
if (status?.captcha_enabled) {
|
||||
setCaptchaEnabled(true);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// 登录页挂载时主动拉取最新 status,避免 localStorage 缓存的旧数据
|
||||
// 缺少 captcha_enabled 字段导致验证码不显示
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/status');
|
||||
const { success, data } = res.data;
|
||||
if (!cancelled && success && data && typeof data === 'object') {
|
||||
statusDispatch({ type: 'set', payload: data });
|
||||
setStatusData(data);
|
||||
}
|
||||
} catch {
|
||||
// 请求失败时继续使用 Context / localStorage 回退
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
isPasskeySupported()
|
||||
.then(setPasskeySupported)
|
||||
|
|
@ -185,6 +218,29 @@ const LoginForm = () => {
|
|||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (captchaEnabled) {
|
||||
fetchCaptcha();
|
||||
}
|
||||
}, [captchaEnabled]);
|
||||
|
||||
const fetchCaptcha = async () => {
|
||||
setCaptchaLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/captcha', { skipErrorHandler: true });
|
||||
const { success, data } = res.data;
|
||||
if (success && data) {
|
||||
setCaptchaId(data.captcha_id);
|
||||
setCaptchaImage(data.captcha_image);
|
||||
}
|
||||
} catch (error) {
|
||||
// silently fail; user can click refresh to retry
|
||||
setCaptchaImage('');
|
||||
} finally {
|
||||
setCaptchaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
if ((hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms) {
|
||||
showInfo(t('请先阅读并同意用户协议和隐私政策'));
|
||||
|
|
@ -246,6 +302,8 @@ const LoginForm = () => {
|
|||
{
|
||||
username,
|
||||
password,
|
||||
captcha_id: captchaEnabled ? captchaId : undefined,
|
||||
captcha_code: captchaEnabled ? captchaCode : undefined,
|
||||
},
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
|
|
@ -511,6 +569,10 @@ const LoginForm = () => {
|
|||
const handleBackToLogin = () => {
|
||||
setShowTwoFA(false);
|
||||
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
||||
setCaptchaCode('');
|
||||
if (captchaEnabled) {
|
||||
fetchCaptcha();
|
||||
}
|
||||
};
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
|
|
@ -777,6 +839,62 @@ const LoginForm = () => {
|
|||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
{captchaEnabled && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--semi-color-text-0, #1c1f23)',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{t('验证码')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Input
|
||||
placeholder={t('请输入验证码')}
|
||||
onChange={(value) => setCaptchaCode(value)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '32px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--semi-color-border, #d9d9d9)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={fetchCaptcha}
|
||||
title={t('点击刷新验证码')}
|
||||
>
|
||||
{captchaImage ? (
|
||||
<img
|
||||
src={captchaImage}
|
||||
alt='验证码'
|
||||
style={{ height: '32px', width: 'auto', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
padding: '0 12px',
|
||||
fontSize: '12px',
|
||||
color: '#999',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{captchaLoading ? '...' : t('点击获取')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(hasUserAgreement || hasPrivacyPolicy) && (
|
||||
<div className='pt-4'>
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ const SystemSetting = () => {
|
|||
WeChatServerToken: '',
|
||||
WeChatAccountQRCodeImageURL: '',
|
||||
TurnstileCheckEnabled: '',
|
||||
CaptchaEnabled: '',
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
SMSVerificationEnabled: '',
|
||||
|
|
@ -211,6 +212,7 @@ const SystemSetting = () => {
|
|||
case 'TelegramOAuthEnabled':
|
||||
case 'RegisterEnabled':
|
||||
case 'TurnstileCheckEnabled':
|
||||
case 'CaptchaEnabled':
|
||||
case 'EmailDomainRestrictionEnabled':
|
||||
case 'EmailAliasRestrictionEnabled':
|
||||
case 'SMTPSSLEnabled':
|
||||
|
|
@ -1180,6 +1182,15 @@ const SystemSetting = () => {
|
|||
>
|
||||
{t('允许 Turnstile 用户校验')}
|
||||
</Form.Checkbox>
|
||||
<Form.Checkbox
|
||||
field='CaptchaEnabled'
|
||||
noLabel
|
||||
onChange={(e) =>
|
||||
handleCheckboxChange('CaptchaEnabled', e)
|
||||
}
|
||||
>
|
||||
{t('允许图片验证码校验')}
|
||||
</Form.Checkbox>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.Checkbox
|
||||
|
|
|
|||
|
|
@ -926,6 +926,7 @@
|
|||
"允许 AccountFilter 参数": "Allow AccountFilter parameter",
|
||||
"允许 HTTP 协议图片请求(适用于自部署代理)": "Allow HTTP protocol image requests (for self-deployed proxies)",
|
||||
"允许 Turnstile 用户校验": "Allow Turnstile user verification",
|
||||
"允许图片验证码校验": "Allow image captcha verification",
|
||||
"允许 inference_geo 透传": "Allow inference_geo Pass-through",
|
||||
"允许 safety_identifier 透传": "Allow safety_identifier Pass-through",
|
||||
"允许 service_tier 透传": "Allow service_tier Pass-through",
|
||||
|
|
|
|||
|
|
@ -474,6 +474,7 @@
|
|||
"允许 safety_identifier 透传": "允许 safety_identifier 透传",
|
||||
"允许 service_tier 透传": "允许 service_tier 透传",
|
||||
"允许 Turnstile 用户校验": "允许 Turnstile 用户校验",
|
||||
"允许图片验证码校验": "允许图片验证码校验",
|
||||
"允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
|
||||
"允许回调(会泄露服务器 IP 地址)": "允许回调(会泄露服务器 IP 地址)",
|
||||
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
|
||||
|
|
|
|||
Loading…
Reference in New Issue