mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 02:56:22 +00:00
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/cloudwego/hertz/pkg/app"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/snowykami/neo-blog/pkg/constant"
|
||||
"github.com/snowykami/neo-blog/pkg/resps"
|
||||
"github.com/snowykami/neo-blog/pkg/utils"
|
||||
)
|
||||
@ -14,8 +15,8 @@ func UseCaptcha() app.HandlerFunc {
|
||||
captchaConfig := utils.Captcha.GetCaptchaConfigFromEnv()
|
||||
return func(ctx context.Context, c *app.RequestContext) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
@ -114,7 +114,13 @@ func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterR
|
||||
|
||||
func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) {
|
||||
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 {
|
||||
return nil, errs.ErrInternalServer
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ const (
|
||||
RoleAdmin = "admin"
|
||||
DefaultFileBasePath = "./data/uploads"
|
||||
EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL
|
||||
EnvKeyPasscode = "\"CAPTCHA_DEV_PASSCODE\""
|
||||
EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
|
||||
EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
|
||||
EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
|
||||
@ -64,6 +65,7 @@ const (
|
||||
OidcUri = "/user/oidc/login" // OIDC登录URI
|
||||
OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey
|
||||
OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub
|
||||
DefaultCaptchaDevPasscode = "dev_passcode"
|
||||
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
|
||||
DefaultPasswordSalt = "default_salt_114514"
|
||||
TargetTypePost = "post"
|
||||
|
@ -25,21 +25,17 @@ type EmailConfig struct {
|
||||
SSL bool // 是否使用SSL
|
||||
}
|
||||
|
||||
// SendTemplate 发送HTML模板,从配置文件中读取邮箱配置,支持上下文控制
|
||||
func (e *emailUtils) SendTemplate(emailConfig *EmailConfig, target, subject, htmlTemplate string, data map[string]interface{}) error {
|
||||
func (e *emailUtils) RenderTemplate(htmlTemplate string, data map[string]interface{}) (string, error) {
|
||||
// 使用Go的模板系统处理HTML模板
|
||||
tmpl, err := template.New("email").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析模板失败: %w", err)
|
||||
return "", fmt.Errorf("解析模板失败: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return fmt.Errorf("执行模板失败: %w", err)
|
||||
return "", fmt.Errorf("执行模板失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送处理后的HTML内容
|
||||
return e.SendEmail(emailConfig, target, subject, buf.String(), true)
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// SendEmail 使用gomail库发送邮件
|
||||
|
16
pkg/utils/email_test.go
Normal file
16
pkg/utils/email_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import type { OidcConfig } from '@/models/oidc-config'
|
||||
import type { BaseResponse } from '@/models/resp'
|
||||
import type { User } from '@/models/user'
|
||||
import { CaptchaProvider } from '@/models/captcha'
|
||||
import { CaptchaProvider } from '@/types/captcha'
|
||||
import axiosClient from './client'
|
||||
|
||||
export async function userLogin(
|
||||
@ -97,8 +97,8 @@ export async function updateUser(data: Partial<User>): Promise<BaseResponse<User
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function requestEmailVerifyCode(email: string): Promise<BaseResponse<{ coolDown: number }>> {
|
||||
const res = await axiosClient.post<BaseResponse<{ coolDown: number }>>('/user/email/verify', { email })
|
||||
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 }, { headers: { 'X-Captcha-Token': captchaToken } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,7 @@ import { RegisterForm } from '@/components/auth/register/register-form'
|
||||
function PageContent() {
|
||||
return (
|
||||
<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 />
|
||||
<RegisterForm />
|
||||
</div>
|
||||
|
@ -3,7 +3,7 @@ import { ResetPasswordForm } from "@/components/auth/reset-password/reset-passwo
|
||||
export default function Page() {
|
||||
return (
|
||||
<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 />
|
||||
<ResetPasswordForm />
|
||||
</div>
|
||||
|
@ -18,7 +18,7 @@ import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Captcha from "@/components/common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
import { CaptchaProvider } from "@/types/captcha"
|
||||
import { toast } from "sonner"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { registerPath, resetPasswordPath } from "@/hooks/use-route"
|
||||
|
@ -15,7 +15,7 @@ import { getCaptchaConfig, requestEmailVerifyCode, userRegister } from "@/api/us
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Captcha from "@/components/common/captcha"
|
||||
import { CaptchaProvider } from "@/models/captcha"
|
||||
import { CaptchaProvider } from "@/types/captcha"
|
||||
import { toast } from "sonner"
|
||||
import { CurrentLogged } from "@/components/auth/common/current-logged"
|
||||
import { SectionDivider } from "@/components/common/section-divider"
|
||||
@ -41,13 +41,22 @@ export function RegisterForm({
|
||||
url?: string
|
||||
} | null>(null)
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
const [isLogging, setIsLogging] = useState(false)
|
||||
const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = 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(() => {
|
||||
getCaptchaConfig()
|
||||
@ -68,13 +77,19 @@ export function RegisterForm({
|
||||
}
|
||||
|
||||
const handleSendVerifyCode = () => {
|
||||
requestEmailVerifyCode(email)
|
||||
if (!email || coolDown > 0 || sendingVerifyCode) return;
|
||||
setSendingVerifyCode(true);
|
||||
requestEmailVerifyCode({ email, captchaToken: captchaToken || '' })
|
||||
.then(() => {
|
||||
toast.success(t("send_verify_code_success"))
|
||||
})
|
||||
.catch((error: BaseErrorResponse) => {
|
||||
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setSendingVerifyCode(false);
|
||||
setCoolDown(60);
|
||||
})
|
||||
}
|
||||
|
||||
const handleRegister = () => {
|
||||
@ -83,9 +98,9 @@ export function RegisterForm({
|
||||
return;
|
||||
}
|
||||
if (!captchaToken) {
|
||||
toast.error(t("please_complete_captcha_verification"));
|
||||
return;
|
||||
}
|
||||
setRegistering(true)
|
||||
userRegister({ username, password, email, verifyCode, captchaToken })
|
||||
.then(res => {
|
||||
toast.success(t("register_success") + ` ${res.data.user.nickname || res.data.user.username}`);
|
||||
@ -98,12 +113,12 @@ export function RegisterForm({
|
||||
setCaptchaToken(null)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLogging(false)
|
||||
setRegistering(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<div className={cn("", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t("title")}</CardTitle>
|
||||
@ -164,11 +179,10 @@ export function RegisterForm({
|
||||
<InputOTPControlled
|
||||
onChange={value => setVerifyCode(value)}
|
||||
/>
|
||||
<Button onClick={handleSendVerifyCode} disabled={!email} variant="outline" className="border-2" type="button">
|
||||
{commonT("send_verify_code")}
|
||||
<Button onClick={handleSendVerifyCode} disabled={!email || coolDown > 0 || sendingVerifyCode} variant="outline" className="border-2" type="button">
|
||||
{commonT("send_verify_code")}{coolDown > 0 ? `(${coolDown})` : ""}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{captchaProps &&
|
||||
<div className="flex justify-center items-center w-full">
|
||||
@ -179,9 +193,9 @@ export function RegisterForm({
|
||||
type="button"
|
||||
className="w-full"
|
||||
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>
|
||||
{/* 注册链接 */}
|
||||
<div className="text-center text-sm">
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { requestEmailVerifyCode, resetPassword } from "@/api/user"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
@ -28,15 +28,31 @@ export function ResetPasswordForm({
|
||||
const [email, setEmail] = useState("")
|
||||
const [verifyCode, setVerifyCode] = 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 = () => {
|
||||
requestEmailVerifyCode(email)
|
||||
if (coolDown > 0 || !email || sendingVerifyCode) return
|
||||
setSendingVerifyCode(true)
|
||||
requestEmailVerifyCode({ email })
|
||||
.then(() => {
|
||||
toast.success(t("send_verify_code_success"))
|
||||
})
|
||||
.catch((error: BaseErrorResponse) => {
|
||||
toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`)
|
||||
})
|
||||
.finally(() => {
|
||||
setSendingVerifyCode(false)
|
||||
setCoolDown(60)
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetPassword = () => {
|
||||
@ -66,18 +82,23 @@ export function ResetPasswordForm({
|
||||
<Label htmlFor="email">{commonT("email")}</Label>
|
||||
<div className="flex gap-3">
|
||||
<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 className="grid gap-3">
|
||||
<Label htmlFor="verify_code">{t("verify_code")}</Label>
|
||||
<div className="flex gap-3 justify-between">
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
|
21
web/src/components/common/captcha/dialog-captcha.tsx
Normal file
21
web/src/components/common/captcha/dialog-captcha.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
@ -3,19 +3,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import { CaptchaProvider } from "@/models/captcha";
|
||||
import "./captcha.css";
|
||||
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) {
|
||||
return (
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CaptchaProps } from ".";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CaptchaProps } from "@/types/captcha";
|
||||
|
||||
const TURNSTILE_TIMEOUT = 15
|
||||
// 简单的转圈圈动画
|
||||
|
@ -34,7 +34,7 @@ export function UserSecurityPage() {
|
||||
}
|
||||
|
||||
const handleSendVerifyCode = () => {
|
||||
requestEmailVerifyCode(email)
|
||||
requestEmailVerifyCode({email})
|
||||
.then(() => {
|
||||
toast.success(t("send_verify_code_success"))
|
||||
})
|
||||
|
@ -16,6 +16,7 @@ const config = {
|
||||
bodyWidthMobile: "100vw",
|
||||
postsPerPage: 9,
|
||||
commentsPerPage: 8,
|
||||
verifyCodeCoolDown: 60,
|
||||
animationDurationSecond: 0.618,
|
||||
footer: {
|
||||
text: "Liteyuki ICP备 1145141919810",
|
||||
|
@ -156,6 +156,7 @@
|
||||
"Register": {
|
||||
"title": "注册",
|
||||
"already_have_account": "已经有账号?",
|
||||
"please_fill_in_all_required_fields": "请填写所有必填项",
|
||||
"register": "注册",
|
||||
"registering": "注册中...",
|
||||
"register_a_new_account": "注册一个新账号",
|
||||
|
@ -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
16
web/src/types/captcha.ts
Normal 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;
|
||||
};
|
Reference in New Issue
Block a user