Merge pull request #25 from snowykami/fix/17

Fix/17
This commit is contained in:
2025-09-24 09:33:34 +08:00
committed by GitHub
19 changed files with 139 additions and 58 deletions

View File

@ -5,6 +5,7 @@ import (
"github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/resps" "github.com/snowykami/neo-blog/pkg/resps"
"github.com/snowykami/neo-blog/pkg/utils" "github.com/snowykami/neo-blog/pkg/utils"
) )
@ -14,8 +15,8 @@ func UseCaptcha() app.HandlerFunc {
captchaConfig := utils.Captcha.GetCaptchaConfigFromEnv() captchaConfig := utils.Captcha.GetCaptchaConfigFromEnv()
return func(ctx context.Context, c *app.RequestContext) { return func(ctx context.Context, c *app.RequestContext) {
CaptchaToken := string(c.GetHeader("X-Captcha-Token")) CaptchaToken := string(c.GetHeader("X-Captcha-Token"))
if utils.IsDevMode && CaptchaToken == utils.Env.Get("CAPTCHA_DEV_PASSCODE", "dev_passcode") { if utils.IsDevMode && CaptchaToken == utils.Env.Get(constant.EnvKeyPasscode, constant.DefaultCaptchaDevPasscode) {
// 开发模式直接通过密钥 // 开发模式直接通过密钥开启开发模式后Captcha可被绕过请注意安全
c.Next(ctx) c.Next(ctx)
return return
} }

View File

@ -114,7 +114,13 @@ func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterR
func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) { func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) {
verifyCode := utils.RequestEmailVerify(req.Email) verifyCode := utils.RequestEmailVerify(req.Email)
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{}) template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{
"Title": "NEO-BLOG",
"Email": req.Email,
"VerifyCode": verifyCode,
"Expire": 10,
"Details": "你正在验证电子邮件所有权",
})
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }

View File

@ -15,6 +15,7 @@ const (
RoleAdmin = "admin" RoleAdmin = "admin"
DefaultFileBasePath = "./data/uploads" DefaultFileBasePath = "./data/uploads"
EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL
EnvKeyPasscode = "\"CAPTCHA_DEV_PASSCODE\""
EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
@ -64,6 +65,7 @@ const (
OidcUri = "/user/oidc/login" // OIDC登录URI OidcUri = "/user/oidc/login" // OIDC登录URI
OidcProviderTypeMisskey = "misskey" // OIDC提供者类型Misskey OidcProviderTypeMisskey = "misskey" // OIDC提供者类型Misskey
OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型GitHub OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型GitHub
DefaultCaptchaDevPasscode = "dev_passcode"
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
DefaultPasswordSalt = "default_salt_114514" DefaultPasswordSalt = "default_salt_114514"
TargetTypePost = "post" TargetTypePost = "post"

View File

@ -25,21 +25,17 @@ type EmailConfig struct {
SSL bool // 是否使用SSL SSL bool // 是否使用SSL
} }
// SendTemplate 发送HTML模板从配置文件中读取邮箱配置支持上下文控制 func (e *emailUtils) RenderTemplate(htmlTemplate string, data map[string]interface{}) (string, error) {
func (e *emailUtils) SendTemplate(emailConfig *EmailConfig, target, subject, htmlTemplate string, data map[string]interface{}) error {
// 使用Go的模板系统处理HTML模板 // 使用Go的模板系统处理HTML模板
tmpl, err := template.New("email").Parse(htmlTemplate) tmpl, err := template.New("email").Parse(htmlTemplate)
if err != nil { if err != nil {
return fmt.Errorf("解析模板失败: %w", err) return "", fmt.Errorf("解析模板失败: %w", err)
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil { if err := tmpl.Execute(&buf, data); err != nil {
return fmt.Errorf("执行模板失败: %w", err) return "", fmt.Errorf("执行模板失败: %w", err)
} }
return buf.String(), nil
// 发送处理后的HTML内容
return e.SendEmail(emailConfig, target, subject, buf.String(), true)
} }
// SendEmail 使用gomail库发送邮件 // SendEmail 使用gomail库发送邮件

16
pkg/utils/email_test.go Normal file
View File

@ -0,0 +1,16 @@
package utils
import "testing"
func TestEmailUtils_SendEmail(t *testing.T) {
templateString := "{{.A}} {{.B}} {{.C}}"
data := map[string]interface{}{"A": 1, "B": 2, "C": 3}
rendered, err := Email.RenderTemplate(templateString, data)
if err != nil {
t.Fatalf("RenderTemplate failed: %v", err)
}
expected := "1 2 3"
if rendered != expected {
t.Errorf("RenderTemplate = %q; want %q", rendered, expected)
}
}

View File

@ -1,7 +1,7 @@
import type { OidcConfig } from '@/models/oidc-config' import type { OidcConfig } from '@/models/oidc-config'
import type { BaseResponse } from '@/models/resp' import type { BaseResponse } from '@/models/resp'
import type { User } from '@/models/user' import type { User } from '@/models/user'
import { CaptchaProvider } from '@/models/captcha' import { CaptchaProvider } from '@/types/captcha'
import axiosClient from './client' import axiosClient from './client'
export async function userLogin( export async function userLogin(
@ -97,8 +97,8 @@ export async function updateUser(data: Partial<User>): Promise<BaseResponse<User
return res.data return res.data
} }
export async function requestEmailVerifyCode(email: string): Promise<BaseResponse<{ coolDown: number }>> { export async function requestEmailVerifyCode({email, captchaToken}: {email: string, captchaToken?: string}): Promise<BaseResponse<{ coolDown: number }>> {
const res = await axiosClient.post<BaseResponse<{ coolDown: number }>>('/user/email/verify', { email }) const res = await axiosClient.post<BaseResponse<{ coolDown: number }>>('/user/email/verify', { email }, { headers: { 'X-Captcha-Token': captchaToken } })
return res.data return res.data
} }

View File

@ -5,7 +5,7 @@ import { RegisterForm } from '@/components/auth/register/register-form'
function PageContent() { function PageContent() {
return ( return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6"> <div className="flex w-full max-w-md flex-col gap-6">
<AuthHeader /> <AuthHeader />
<RegisterForm /> <RegisterForm />
</div> </div>

View File

@ -3,7 +3,7 @@ import { ResetPasswordForm } from "@/components/auth/reset-password/reset-passwo
export default function Page() { export default function Page() {
return ( return (
<div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="flex w-full max-w-sm flex-col gap-6"> <div className="flex w-full max-w-md flex-col gap-6">
<AuthHeader /> <AuthHeader />
<ResetPasswordForm /> <ResetPasswordForm />
</div> </div>

View File

@ -18,7 +18,7 @@ import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import Captcha from "@/components/common/captcha" import Captcha from "@/components/common/captcha"
import { CaptchaProvider } from "@/models/captcha" import { CaptchaProvider } from "@/types/captcha"
import { toast } from "sonner" import { toast } from "sonner"
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context"
import { registerPath, resetPasswordPath } from "@/hooks/use-route" import { registerPath, resetPasswordPath } from "@/hooks/use-route"

View File

@ -15,7 +15,7 @@ import { getCaptchaConfig, requestEmailVerifyCode, userRegister } from "@/api/us
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import Captcha from "@/components/common/captcha" import Captcha from "@/components/common/captcha"
import { CaptchaProvider } from "@/models/captcha" import { CaptchaProvider } from "@/types/captcha"
import { toast } from "sonner" import { toast } from "sonner"
import { CurrentLogged } from "@/components/auth/common/current-logged" import { CurrentLogged } from "@/components/auth/common/current-logged"
import { SectionDivider } from "@/components/common/section-divider" import { SectionDivider } from "@/components/common/section-divider"
@ -41,13 +41,22 @@ export function RegisterForm({
url?: string url?: string
} | null>(null) } | null>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null) const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [isLogging, setIsLogging] = useState(false)
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0) const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [verifyCode, setVerifyCode] = useState('') const [verifyCode, setVerifyCode] = useState('')
const [sendingVerifyCode, setSendingVerifyCode] = useState(false)
const [registering, setRegistering] = useState(false)
const [coolDown, setCoolDown] = useState(0)
useEffect(() => {
if (coolDown <= 0) return
const id = setInterval(() => {
setCoolDown(c => (c > 1 ? c - 1 : 0))
}, 1000)
return () => clearInterval(id)
}, [coolDown])
useEffect(() => { useEffect(() => {
getCaptchaConfig() getCaptchaConfig()
@ -68,13 +77,19 @@ export function RegisterForm({
} }
const handleSendVerifyCode = () => { const handleSendVerifyCode = () => {
requestEmailVerifyCode(email) if (!email || coolDown > 0 || sendingVerifyCode) return;
setSendingVerifyCode(true);
requestEmailVerifyCode({ email, captchaToken: captchaToken || '' })
.then(() => { .then(() => {
toast.success(t("send_verify_code_success")) toast.success(t("send_verify_code_success"))
}) })
.catch((error: BaseErrorResponse) => { .catch((error: BaseErrorResponse) => {
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`) toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
}) })
.finally(() => {
setSendingVerifyCode(false);
setCoolDown(60);
})
} }
const handleRegister = () => { const handleRegister = () => {
@ -83,9 +98,9 @@ export function RegisterForm({
return; return;
} }
if (!captchaToken) { if (!captchaToken) {
toast.error(t("please_complete_captcha_verification"));
return; return;
} }
setRegistering(true)
userRegister({ username, password, email, verifyCode, captchaToken }) userRegister({ username, password, email, verifyCode, captchaToken })
.then(res => { .then(res => {
toast.success(t("register_success") + ` ${res.data.user.nickname || res.data.user.username}`); toast.success(t("register_success") + ` ${res.data.user.nickname || res.data.user.username}`);
@ -98,12 +113,12 @@ export function RegisterForm({
setCaptchaToken(null) setCaptchaToken(null)
}) })
.finally(() => { .finally(() => {
setIsLogging(false) setRegistering(false)
}) })
} }
return ( return (
<div className={cn("flex flex-col gap-6", className)} {...props}> <div className={cn("", className)} {...props}>
<Card> <Card>
<CardHeader className="text-center"> <CardHeader className="text-center">
<CardTitle className="text-xl">{t("title")}</CardTitle> <CardTitle className="text-xl">{t("title")}</CardTitle>
@ -164,11 +179,10 @@ export function RegisterForm({
<InputOTPControlled <InputOTPControlled
onChange={value => setVerifyCode(value)} onChange={value => setVerifyCode(value)}
/> />
<Button onClick={handleSendVerifyCode} disabled={!email} variant="outline" className="border-2" type="button"> <Button onClick={handleSendVerifyCode} disabled={!email || coolDown > 0 || sendingVerifyCode} variant="outline" className="border-2" type="button">
{commonT("send_verify_code")} {commonT("send_verify_code")}{coolDown > 0 ? `(${coolDown})` : ""}
</Button> </Button>
</div> </div>
</div> </div>
{captchaProps && {captchaProps &&
<div className="flex justify-center items-center w-full"> <div className="flex justify-center items-center w-full">
@ -179,9 +193,9 @@ export function RegisterForm({
type="button" type="button"
className="w-full" className="w-full"
onClick={handleRegister} onClick={handleRegister}
disabled={!captchaToken || isLogging || !username || !password || !email} disabled={!captchaToken || registering || !username || !password || !email || !(verifyCode.length == 6)}
> >
{isLogging ? t("registering") : t("register")} {registering ? t("registering") : t("register")}
</Button> </Button>
{/* 注册链接 */} {/* 注册链接 */}
<div className="text-center text-sm"> <div className="text-center text-sm">

View File

@ -10,7 +10,7 @@ import {
} from "@/components/ui/card" } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useState } from "react" import { useEffect, useState } from "react"
import { requestEmailVerifyCode, resetPassword } from "@/api/user" import { requestEmailVerifyCode, resetPassword } from "@/api/user"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
@ -28,15 +28,31 @@ export function ResetPasswordForm({
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [verifyCode, setVerifyCode] = useState("") const [verifyCode, setVerifyCode] = useState("")
const [newPassword, setNewPassword] = useState("") const [newPassword, setNewPassword] = useState("")
const [coolDown, setCoolDown] = useState(0)
const [sendingVerifyCode, setSendingVerifyCode] = useState(false)
useEffect(() => {
if (coolDown <= 0) return
const id = setInterval(() => {
setCoolDown(c => (c > 1 ? c - 1 : 0))
}, 1000)
return () => clearInterval(id)
}, [coolDown])
const handleSendVerifyCode = () => { const handleSendVerifyCode = () => {
requestEmailVerifyCode(email) if (coolDown > 0 || !email || sendingVerifyCode) return
setSendingVerifyCode(true)
requestEmailVerifyCode({ email })
.then(() => { .then(() => {
toast.success(t("send_verify_code_success")) toast.success(t("send_verify_code_success"))
}) })
.catch((error: BaseErrorResponse) => { .catch((error: BaseErrorResponse) => {
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`) toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
}) })
.finally(() => {
setSendingVerifyCode(false)
setCoolDown(60)
})
} }
const handleResetPassword = () => { const handleResetPassword = () => {
@ -66,18 +82,23 @@ export function ResetPasswordForm({
<Label htmlFor="email">{commonT("email")}</Label> <Label htmlFor="email">{commonT("email")}</Label>
<div className="flex gap-3"> <div className="flex gap-3">
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Button
disabled={!email}
variant="outline"
className="border-2"
type="button"
onClick={handleSendVerifyCode}>{t("send_verify_code")}
</Button>
</div> </div>
</div> </div>
<div className="grid gap-3"> <div className="grid gap-3">
<Label htmlFor="verify_code">{t("verify_code")}</Label> <Label htmlFor="verify_code">{t("verify_code")}</Label>
<div className="flex gap-3 justify-between">
<InputOTPControlled onChange={value => setVerifyCode(value)} /> <InputOTPControlled onChange={value => setVerifyCode(value)} />
<Button
disabled={!email || coolDown > 0}
variant="outline"
className="border-2"
type="button"
onClick={handleSendVerifyCode}>
{t("send_verify_code")}{coolDown > 0 ? `(${coolDown})` : ""}
</Button>
</div>
</div> </div>
<Button <Button
type="button" type="button"

View File

@ -0,0 +1,21 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { CaptchaProps } from "@/types/captcha";
import AIOCaptchaWidget from ".";
import { cn } from "@/lib/utils";
export function DialogCaptcha({ className, ...props }: React.ComponentProps<"div"> & CaptchaProps) {
return (
<div className={cn(className)}>
<Dialog>
<DialogTrigger>{props.children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Captcha</DialogTitle>
</DialogHeader>
<AIOCaptchaWidget {...props} />
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -3,19 +3,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3"; import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import HCaptcha from "@hcaptcha/react-hcaptcha"; import HCaptcha from "@hcaptcha/react-hcaptcha";
import { CaptchaProvider } from "@/models/captcha";
import "./captcha.css"; import "./captcha.css";
import { TurnstileWidget } from "./turnstile"; import { TurnstileWidget } from "./turnstile";
import { CaptchaProps, CaptchaProvider } from "@/types/captcha";
export type CaptchaProps = {
provider: CaptchaProvider;
siteKey: string;
url?: string;
onSuccess: (token: string) => void;
onError: (error: string) => void;
onAbort?: () => void;
};
export function ReCaptchaWidget(props: CaptchaProps) { export function ReCaptchaWidget(props: CaptchaProps) {
return ( return (

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CaptchaProps } from ".";
import { Turnstile } from "@marsidev/react-turnstile"; import { Turnstile } from "@marsidev/react-turnstile";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { CaptchaProps } from "@/types/captcha";
const TURNSTILE_TIMEOUT = 15 const TURNSTILE_TIMEOUT = 15
// 简单的转圈圈动画 // 简单的转圈圈动画

View File

@ -34,7 +34,7 @@ export function UserSecurityPage() {
} }
const handleSendVerifyCode = () => { const handleSendVerifyCode = () => {
requestEmailVerifyCode(email) requestEmailVerifyCode({email})
.then(() => { .then(() => {
toast.success(t("send_verify_code_success")) toast.success(t("send_verify_code_success"))
}) })

View File

@ -16,6 +16,7 @@ const config = {
bodyWidthMobile: "100vw", bodyWidthMobile: "100vw",
postsPerPage: 9, postsPerPage: 9,
commentsPerPage: 8, commentsPerPage: 8,
verifyCodeCoolDown: 60,
animationDurationSecond: 0.618, animationDurationSecond: 0.618,
footer: { footer: {
text: "Liteyuki ICP备 1145141919810", text: "Liteyuki ICP备 1145141919810",

View File

@ -156,6 +156,7 @@
"Register": { "Register": {
"title": "注册", "title": "注册",
"already_have_account": "已经有账号?", "already_have_account": "已经有账号?",
"please_fill_in_all_required_fields": "请填写所有必填项",
"register": "注册", "register": "注册",
"registering": "注册中...", "registering": "注册中...",
"register_a_new_account": "注册一个新账号", "register_a_new_account": "注册一个新账号",

View File

@ -1,7 +0,0 @@
export enum CaptchaProvider {
HCAPTCHA = "hcaptcha",
MCAPTCHA = "mcaptcha",
RECAPTCHA = "recaptcha",
TURNSTILE = "turnstile",
DISABLE = "disable",
}

16
web/src/types/captcha.ts Normal file
View File

@ -0,0 +1,16 @@
export enum CaptchaProvider {
HCAPTCHA = "hcaptcha",
MCAPTCHA = "mcaptcha",
RECAPTCHA = "recaptcha",
TURNSTILE = "turnstile",
DISABLE = "disable",
}
export type CaptchaProps = {
provider: CaptchaProvider;
siteKey: string;
url?: string;
onSuccess: (token: string) => void;
onError: (error: string) => void;
onAbort?: () => void;
};