feat: add captcha support for user login and enhance user profile page

- Refactored userLogin function to include captcha handling.
- Introduced getCaptchaConfig API to fetch captcha configuration.
- Added Captcha component to handle different captcha providers (hCaptcha, reCaptcha, Turnstile).
- Updated LoginForm component to integrate captcha verification.
- Created UserProfile component to display user information with avatar.
- Implemented getUserByUsername API to fetch user details by username.
- Removed deprecated LoginRequest interface from user model.
- Enhanced navbar and layout with animation effects.
- Removed unused user page component and added dynamic user profile routing.
- Updated localization files to include captcha-related messages.
- Improved Gravatar component for better avatar handling.
This commit is contained in:
2025-09-10 21:15:36 +08:00
parent a7da023b1e
commit 4781d81869
28 changed files with 1048 additions and 701 deletions

View File

@ -10,8 +10,10 @@ import (
"github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/service" "github.com/snowykami/neo-blog/internal/service"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps" "github.com/snowykami/neo-blog/pkg/resps"
utils2 "github.com/snowykami/neo-blog/pkg/utils"
) )
type UserController struct { type UserController struct {
@ -125,6 +127,22 @@ func (u *UserController) GetUser(ctx context.Context, c *app.RequestContext) {
return return
} }
resps.Ok(c, resps.Success, resp.User) resps.Ok(c, resps.Success, resp.User)
}
func (u *UserController) GetUserByUsername(ctx context.Context, c *app.RequestContext) {
username := c.Param("username")
if username == "" {
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
resp, err := u.service.GetUserByUsername(&dto.GetUserByUsernameReq{Username: username})
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
resps.Ok(c, resps.Success, resp.User)
} }
func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) { func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext) {
@ -184,3 +202,11 @@ func (u *UserController) ChangePassword(ctx context.Context, c *app.RequestConte
func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) { func (u *UserController) ChangeEmail(ctx context.Context, c *app.RequestContext) {
// TODO: 实现修改邮箱功能 // TODO: 实现修改邮箱功能
} }
func (u *UserController) GetCaptchaConfig(ctx context.Context, c *app.RequestContext) {
resps.Ok(c, "ok", utils.H{
"provider": utils2.Env.Get(constant.EnvKeyCaptchaProvider),
"site_key": utils2.Env.Get(constant.EnvKeyCaptchaSiteKey),
"url": utils2.Env.Get(constant.EnvKeyCaptchaUrl),
})
}

View File

@ -70,6 +70,10 @@ type GetUserReq struct {
UserID uint `json:"user_id"` UserID uint `json:"user_id"`
} }
type GetUserByUsernameReq struct {
Username string `json:"username"`
}
type GetUserResp struct { type GetUserResp struct {
User UserDto `json:"user"` // 用户信息 User UserDto `json:"user"` // 用户信息
} }

View File

@ -2,6 +2,7 @@ package middleware
import ( import (
"context" "context"
"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/resps" "github.com/snowykami/neo-blog/pkg/resps"

View File

@ -1,27 +1,29 @@
package apiv1 package apiv1
import ( import (
"github.com/cloudwego/hertz/pkg/route" "github.com/cloudwego/hertz/pkg/route"
"github.com/snowykami/neo-blog/internal/controller/v1" "github.com/snowykami/neo-blog/internal/controller/v1"
"github.com/snowykami/neo-blog/internal/middleware" "github.com/snowykami/neo-blog/internal/middleware"
) )
func registerUserRoutes(group *route.RouterGroup) { func registerUserRoutes(group *route.RouterGroup) {
userController := v1.NewUserController() userController := v1.NewUserController()
userGroup := group.Group("/user").Use(middleware.UseAuth(true)) userGroup := group.Group("/user").Use(middleware.UseAuth(true))
userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false)) userGroupWithoutAuth := group.Group("/user").Use(middleware.UseAuth(false))
userGroupWithoutAuthNeedsCaptcha := userGroupWithoutAuth.Use(middleware.UseCaptcha()) userGroupWithoutAuthNeedsCaptcha := group.Group("/user").Use(middleware.UseCaptcha())
{ {
userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login) userGroupWithoutAuthNeedsCaptcha.POST("/login", userController.Login)
userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register) userGroupWithoutAuthNeedsCaptcha.POST("/register", userController.Register)
userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code userGroupWithoutAuthNeedsCaptcha.POST("/email/verify", userController.VerifyEmail) // Send email verification code
userGroupWithoutAuth.GET("/oidc/list", userController.OidcList) userGroupWithoutAuth.GET("/captcha", userController.GetCaptchaConfig)
userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin) userGroupWithoutAuth.GET("/oidc/list", userController.OidcList)
userGroupWithoutAuth.GET("/u/:id", userController.GetUser) userGroupWithoutAuth.GET("/oidc/login/:name", userController.OidcLogin)
userGroup.GET("/me", userController.GetUser) userGroupWithoutAuth.GET("/u/:id", userController.GetUser)
userGroupWithoutAuth.POST("/logout", userController.Logout) userGroupWithoutAuth.GET("/username/:username", userController.GetUserByUsername)
userGroup.PUT("/u/:id", userController.UpdateUser) userGroup.GET("/me", userController.GetUser)
userGroup.PUT("/password/edit", userController.ChangePassword) userGroupWithoutAuth.POST("/logout", userController.Logout)
userGroup.PUT("/email/edit", userController.ChangeEmail) userGroup.PUT("/u/:id", userController.UpdateUser)
} userGroup.PUT("/password/edit", userController.ChangePassword)
userGroup.PUT("/email/edit", userController.ChangeEmail)
}
} }

View File

@ -1,379 +1,400 @@
package service package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/sirupsen/logrus" "net/http"
"github.com/snowykami/neo-blog/internal/dto" "strings"
"github.com/snowykami/neo-blog/internal/model" "time"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/internal/static" "github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/utils" "github.com/snowykami/neo-blog/internal/repo"
"gorm.io/gorm" "github.com/snowykami/neo-blog/internal/static"
"net/http" "github.com/snowykami/neo-blog/pkg/constant"
"strings" "github.com/snowykami/neo-blog/pkg/errs"
"time" "github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm"
) )
type UserService struct{} type UserService struct{}
func NewUserService() *UserService { func NewUserService() *UserService {
return &UserService{} return &UserService{}
} }
func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) { func (s *UserService) UserLogin(req *dto.UserLoginReq) (*dto.UserLoginResp, error) {
user, err := repo.User.GetUserByUsernameOrEmail(req.Username) user, err := repo.User.GetUserByUsernameOrEmail(req.Username)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Warnf("User not found: %s", req.Username) logrus.Warnf("User not found: %s", req.Username)
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if user == nil { if user == nil {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) { if utils.Password.VerifyPassword(req.Password, user.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) {
token, refreshToken, err := s.generate2Token(user.ID) token, refreshToken, err := s.generate2Token(user.ID)
if err != nil { if err != nil {
logrus.Errorln("Failed to generate tokens:", err) logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
resp := &dto.UserLoginResp{ resp := &dto.UserLoginResp{
Token: token, Token: token,
RefreshToken: refreshToken, RefreshToken: refreshToken,
User: user.ToDto(), User: user.ToDto(),
} }
return resp, nil return resp, nil
} else { } else {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
} }
func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) { func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterResp, error) {
// 验证邮箱验证码 // 验证邮箱验证码
if !utils.Env.GetAsBool("ENABLE_REGISTER", true) { if !utils.Env.GetAsBool("ENABLE_REGISTER", true) {
return nil, errs.ErrForbidden return nil, errs.ErrForbidden
} }
if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) { if utils.Env.GetAsBool("ENABLE_EMAIL_VERIFICATION", true) {
ok, err := s.verifyEmail(req.Email, req.VerificationCode) ok, err := s.verifyEmail(req.Email, req.VerificationCode)
if err != nil { if err != nil {
logrus.Errorln("Failed to verify email:", err) logrus.Errorln("Failed to verify email:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if !ok { if !ok {
return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil) return nil, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
} }
} }
// 检查用户名或邮箱是否已存在 // 检查用户名或邮箱是否已存在
usernameExist, err := repo.User.CheckUsernameExists(req.Username) usernameExist, err := repo.User.CheckUsernameExists(req.Username)
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
emailExist, err := repo.User.CheckEmailExists(req.Email) emailExist, err := repo.User.CheckEmailExists(req.Email)
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if usernameExist || emailExist { if usernameExist || emailExist {
return nil, errs.New(http.StatusConflict, "Username or email already exists", nil) return nil, errs.New(http.StatusConflict, "Username or email already exists", nil)
} }
// 创建新用户 // 创建新用户
hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt")) hashedPassword, err := utils.Password.HashPassword(req.Password, utils.Env.Get(constant.EnvKeyPasswordSalt, "default_salt"))
if err != nil { if err != nil {
logrus.Errorln("Failed to hash password:", err) logrus.Errorln("Failed to hash password:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
newUser := &model.User{ newUser := &model.User{
Username: req.Username, Username: req.Username,
Nickname: req.Nickname, Nickname: req.Nickname,
Email: req.Email, Email: req.Email,
Gender: "", Gender: "",
Role: "user", Role: "user",
Password: hashedPassword, Password: hashedPassword,
} }
err = repo.User.CreateUser(newUser) err = repo.User.CreateUser(newUser)
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
// 创建默认管理员账户 // 创建默认管理员账户
if newUser.ID == 1 { if newUser.ID == 1 {
newUser.Role = constant.RoleAdmin newUser.Role = constant.RoleAdmin
err = repo.User.UpdateUser(newUser) err = repo.User.UpdateUser(newUser)
if err != nil { if err != nil {
logrus.Errorln("Failed to update user role to admin:", err) logrus.Errorln("Failed to update user role to admin:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
} }
// 生成访问令牌和刷新令牌 // 生成访问令牌和刷新令牌
token, refreshToken, err := s.generate2Token(newUser.ID) token, refreshToken, err := s.generate2Token(newUser.ID)
if err != nil { if err != nil {
logrus.Errorln("Failed to generate tokens:", err) logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
resp := &dto.UserRegisterResp{ resp := &dto.UserRegisterResp{
Token: token, Token: token,
RefreshToken: refreshToken, RefreshToken: refreshToken,
User: newUser.ToDto(), User: newUser.ToDto(),
} }
return resp, nil return resp, nil
} }
func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) { func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) {
generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef") generatedVerificationCode := utils.Strings.GenerateRandomStringWithCharset(6, "0123456789abcdef")
kv := utils.KV.GetInstance() kv := utils.KV.GetInstance()
kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10) kv.Set(constant.KVKeyEmailVerificationCode+req.Email, generatedVerificationCode, time.Minute*10)
template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{}) template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{})
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if utils.IsDevMode { if utils.IsDevMode {
logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode) logrus.Infof("%s's verification code is %s", req.Email, generatedVerificationCode)
} }
err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true) err = utils.Email.SendEmail(utils.Email.GetEmailConfigFromEnv(), req.Email, "验证你的电子邮件 / Verify your email", template, true)
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
return &dto.VerifyEmailResp{Success: true}, nil return &dto.VerifyEmailResp{Success: true}, nil
} }
func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) { func (s *UserService) ListOidcConfigs() ([]dto.UserOidcConfigDto, error) {
enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true) enabledOidcConfigs, err := repo.Oidc.ListOidcConfigs(true)
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
var oidcConfigsDtos []dto.UserOidcConfigDto var oidcConfigsDtos []dto.UserOidcConfigDto
for _, oidcConfig := range enabledOidcConfigs { for _, oidcConfig := range enabledOidcConfigs {
state := utils.Strings.GenerateRandomString(32) state := utils.Strings.GenerateRandomString(32)
kvStore := utils.KV.GetInstance() kvStore := utils.KV.GetInstance()
kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute) kvStore.Set(constant.KVKeyOidcState+state, oidcConfig.Name, 5*time.Minute)
loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ loginUrl := utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID, "client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的 "redirect_uri": fmt.Sprintf("%s%s%s/%sREDIRECT_BACK", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix, constant.ApiSuffix,
constant.OidcUri, constant.OidcUri,
oidcConfig.Name, oidcConfig.Name,
), ),
"response_type": "code", "response_type": "code",
"scope": "openid email profile", "scope": "openid email profile",
"state": state, "state": state,
}) })
if oidcConfig.Type == constant.OidcProviderTypeMisskey { if oidcConfig.Type == constant.OidcProviderTypeMisskey {
// Misskey OIDC 特殊处理 // Misskey OIDC 特殊处理
loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{ loginUrl = utils.Url.BuildUrl(oidcConfig.AuthorizationEndpoint, map[string]string{
"client_id": oidcConfig.ClientID, "client_id": oidcConfig.ClientID,
"redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的 "redirect_uri": fmt.Sprintf("%s%s%s/%s", // 这个大占位符给前端替换用的替换时也要uri编码因为是层层包的
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"), strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/"),
constant.ApiSuffix, constant.ApiSuffix,
constant.OidcUri, constant.OidcUri,
oidcConfig.Name, oidcConfig.Name,
), ),
"response_type": "code", "response_type": "code",
"scope": "read:account", "scope": "read:account",
"state": state, "state": state,
}) })
} }
oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{ oidcConfigsDtos = append(oidcConfigsDtos, dto.UserOidcConfigDto{
Name: oidcConfig.Name, Name: oidcConfig.Name,
DisplayName: oidcConfig.DisplayName, DisplayName: oidcConfig.DisplayName,
Icon: oidcConfig.Icon, Icon: oidcConfig.Icon,
LoginUrl: loginUrl, LoginUrl: loginUrl,
}) })
} }
return oidcConfigsDtos, nil return oidcConfigsDtos, nil
} }
func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) { func (s *UserService) OidcLogin(req *dto.OidcLoginReq) (*dto.OidcLoginResp, error) {
// 验证state // 验证state
kvStore := utils.KV.GetInstance() kvStore := utils.KV.GetInstance()
storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State) storedName, ok := kvStore.Get(constant.KVKeyOidcState + req.State)
if !ok || storedName != req.Name { if !ok || storedName != req.Name {
return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil) return nil, errs.New(http.StatusForbidden, "invalid oidc state", nil)
} }
// 获取OIDC配置 // 获取OIDC配置
oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name) oidcConfig, err := repo.Oidc.GetOidcConfigByName(req.Name)
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if oidcConfig == nil { if oidcConfig == nil {
return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil) return nil, errs.New(http.StatusNotFound, "OIDC configuration not found", nil)
} }
// 请求访问令牌 // 请求访问令牌
tokenResp, err := utils.Oidc.RequestToken( tokenResp, err := utils.Oidc.RequestToken(
oidcConfig.TokenEndpoint, oidcConfig.TokenEndpoint,
oidcConfig.ClientID, oidcConfig.ClientID,
oidcConfig.ClientSecret, oidcConfig.ClientSecret,
req.Code, req.Code,
strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name, strings.TrimSuffix(utils.Env.Get(constant.EnvKeyBaseUrl, constant.DefaultBaseUrl), "/")+constant.OidcUri+oidcConfig.Name,
) )
if err != nil { if err != nil {
logrus.Errorln("Failed to request OIDC token:", err) logrus.Errorln("Failed to request OIDC token:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken) userInfo, err := utils.Oidc.RequestUserInfo(oidcConfig.UserInfoEndpoint, tokenResp.AccessToken)
if err != nil { if err != nil {
logrus.Errorln("Failed to request OIDC user info:", err) logrus.Errorln("Failed to request OIDC user info:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
// 绑定过登录 // 绑定过登录
userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub) userOpenID, err := repo.User.GetUserOpenIDByIssuerAndSub(oidcConfig.Issuer, userInfo.Sub)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if userOpenID != nil { if userOpenID != nil {
user, err := repo.User.GetUserByID(userOpenID.UserID) user, err := repo.User.GetUserByID(userOpenID.UserID)
if err != nil { if err != nil {
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
token, refreshToken, err := s.generate2Token(user.ID) token, refreshToken, err := s.generate2Token(user.ID)
if err != nil { if err != nil {
logrus.Errorln("Failed to generate tokens:", err) logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
resp := &dto.OidcLoginResp{ resp := &dto.OidcLoginResp{
Token: token, Token: token,
RefreshToken: refreshToken, RefreshToken: refreshToken,
User: user.ToDto(), User: user.ToDto(),
} }
return resp, nil return resp, nil
} else { } else {
// 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户 // 若没有绑定过登录,则先通过邮箱查找用户,若没有再创建新用户
user, err := repo.User.GetUserByEmail(userInfo.Email) user, err := repo.User.GetUserByEmail(userInfo.Email)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
logrus.Errorln("Failed to get user by email:", err) logrus.Errorln("Failed to get user by email:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if user != nil { if user != nil {
userOpenID = &model.UserOpenID{ userOpenID = &model.UserOpenID{
UserID: user.ID, UserID: user.ID,
Issuer: oidcConfig.Issuer, Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub, Sub: userInfo.Sub,
} }
err = repo.User.CreateOrUpdateUserOpenID(userOpenID) err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil { if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err) logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
token, refreshToken, err := s.generate2Token(user.ID) token, refreshToken, err := s.generate2Token(user.ID)
if err != nil { if err != nil {
logrus.Errorln("Failed to generate tokens:", err) logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
resp := &dto.OidcLoginResp{ resp := &dto.OidcLoginResp{
Token: token, Token: token,
RefreshToken: refreshToken, RefreshToken: refreshToken,
User: user.ToDto(), User: user.ToDto(),
} }
return resp, nil return resp, nil
} else { } else {
user = &model.User{ user = &model.User{
Username: userInfo.Name, Username: userInfo.Name,
Nickname: userInfo.Name, Nickname: userInfo.Name,
AvatarUrl: userInfo.Picture, AvatarUrl: userInfo.Picture,
Email: userInfo.Email, Email: userInfo.Email,
} }
err = repo.User.CreateUser(user) err = repo.User.CreateUser(user)
if err != nil { if err != nil {
logrus.Errorln("Failed to create user:", err) logrus.Errorln("Failed to create user:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
userOpenID = &model.UserOpenID{ userOpenID = &model.UserOpenID{
UserID: user.ID, UserID: user.ID,
Issuer: oidcConfig.Issuer, Issuer: oidcConfig.Issuer,
Sub: userInfo.Sub, Sub: userInfo.Sub,
} }
err = repo.User.CreateOrUpdateUserOpenID(userOpenID) err = repo.User.CreateOrUpdateUserOpenID(userOpenID)
if err != nil { if err != nil {
logrus.Errorln("Failed to create or update user OpenID:", err) logrus.Errorln("Failed to create or update user OpenID:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
token, refreshToken, err := s.generate2Token(user.ID) token, refreshToken, err := s.generate2Token(user.ID)
if err != nil { if err != nil {
logrus.Errorln("Failed to generate tokens:", err) logrus.Errorln("Failed to generate tokens:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
resp := &dto.OidcLoginResp{ resp := &dto.OidcLoginResp{
Token: token, Token: token,
RefreshToken: refreshToken, RefreshToken: refreshToken,
User: user.ToDto(), User: user.ToDto(),
} }
return resp, nil return resp, nil
} }
} }
} }
func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) { func (s *UserService) GetUser(req *dto.GetUserReq) (*dto.GetUserResp, error) {
if req.UserID == 0 { if req.UserID == 0 {
return nil, errs.New(http.StatusBadRequest, "user_id is required", nil) return nil, errs.New(http.StatusBadRequest, "user_id is required", nil)
} }
user, err := repo.User.GetUserByID(req.UserID) user, err := repo.User.GetUserByID(req.UserID)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
logrus.Errorln("Failed to get user by ID:", err) logrus.Errorln("Failed to get user by ID:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
if user == nil { if user == nil {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
return &dto.GetUserResp{ return &dto.GetUserResp{
User: user.ToDto(), User: user.ToDto(),
}, nil }, nil
}
func (s *UserService) GetUserByUsername(req *dto.GetUserByUsernameReq) (*dto.GetUserResp, error) {
if req.Username == "" {
return nil, errs.New(http.StatusBadRequest, "username is required", nil)
}
user, err := repo.User.GetUserByUsername(req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to get user by username:", err)
return nil, errs.ErrInternalServer
}
if user == nil {
return nil, errs.ErrNotFound
}
return &dto.GetUserResp{
User: user.ToDto(),
}, nil
} }
func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) { func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, error) {
user := &model.User{ user := &model.User{
Model: gorm.Model{ Model: gorm.Model{
ID: req.ID, ID: req.ID,
}, },
Username: req.Username, Username: req.Username,
Nickname: req.Nickname, Nickname: req.Nickname,
Gender: req.Gender, Gender: req.Gender,
AvatarUrl: req.AvatarUrl, AvatarUrl: req.AvatarUrl,
} }
err := repo.User.UpdateUser(user) err := repo.User.UpdateUser(user)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
logrus.Errorln("Failed to update user:", err) logrus.Errorln("Failed to update user:", err)
return nil, errs.ErrInternalServer return nil, errs.ErrInternalServer
} }
return &dto.UpdateUserResp{}, nil return &dto.UpdateUserResp{}, nil
} }
func (s *UserService) generate2Token(userID uint) (string, string, error) { func (s *UserService) generate2Token(userID uint) (string, string, error) {
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second) token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
tokenString, err := token.ToString() tokenString, err := token.ToString()
if err != nil { if err != nil {
return "", "", errs.ErrInternalServer return "", "", errs.ErrInternalServer
} }
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second) refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
refreshTokenString, err := refreshToken.ToString() refreshTokenString, err := refreshToken.ToString()
if err != nil { if err != nil {
return "", "", errs.ErrInternalServer return "", "", errs.ErrInternalServer
} }
err = repo.Session.SaveSession(refreshToken.SessionKey) err = repo.Session.SaveSession(refreshToken.SessionKey)
if err != nil { if err != nil {
return "", "", errs.ErrInternalServer return "", "", errs.ErrInternalServer
} }
return tokenString, refreshTokenString, nil return tokenString, refreshTokenString, nil
} }
func (s *UserService) verifyEmail(email, code string) (bool, error) { func (s *UserService) verifyEmail(email, code string) (bool, error) {
kv := utils.KV.GetInstance() kv := utils.KV.GetInstance()
verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email) verificationCode, ok := kv.Get(constant.KVKeyEmailVerificationCode + email)
if !ok || verificationCode != code { if !ok || verificationCode != code {
return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil) return false, errs.New(http.StatusForbidden, "Invalid email verification code", nil)
} }
return true, nil return true, nil
} }

View File

@ -10,15 +10,19 @@ const (
ModeProd = "prod" ModeProd = "prod"
RoleUser = "user" RoleUser = "user"
RoleAdmin = "admin" RoleAdmin = "admin"
EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
EnvKeyMode = "MODE" // 环境变量:运行模式 EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量JWT密钥 EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 EnvKeyMode = "MODE" // 环境变量:运行模式
EnvKeyTokenDurationDefault = 300 EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量JWT密钥
EnvKeyRefreshTokenDurationDefault = 604800 EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
EnvKeyTokenDurationDefault = 300 // Token有效时长
EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储邮箱验证码 KVKeyEmailVerificationCode = "email_verification_code:" // KV存储邮箱验证码

View File

@ -12,16 +12,16 @@ type captchaUtils struct{}
var Captcha = captchaUtils{} var Captcha = captchaUtils{}
type CaptchaConfig struct { type CaptchaConfig struct {
Type string Type string
SiteSecret string // Site secret key for the captcha service SiteKey string // Site secret key for the captcha service
SecretKey string // Secret key for the captcha service SecretKey string // Secret key for the captcha service
} }
func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig { func (c *captchaUtils) GetCaptchaConfigFromEnv() *CaptchaConfig {
return &CaptchaConfig{ return &CaptchaConfig{
Type: Env.Get("CAPTCHA_TYPE", "disable"), Type: Env.Get(constant.EnvKeyCaptchaProvider, constant.CaptchaTypeDisable),
SiteSecret: Env.Get("CAPTCHA_SITE_SECRET", ""), SiteKey: Env.Get(constant.EnvKeyCaptchaSiteKey, ""),
SecretKey: Env.Get("CAPTCHA_SECRET_KEY", ""), SecretKey: Env.Get(constant.EnvKeyCaptchaSecreteKey, ""),
} }
} }

View File

@ -9,6 +9,8 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@hcaptcha/react-hcaptcha": "^1.12.1",
"@marsidev/react-turnstile": "^1.3.0",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
@ -30,6 +32,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-google-recaptcha-v3": "^1.11.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"sonner": "^2.0.6", "sonner": "^2.0.6",

63
web/pnpm-lock.yaml generated
View File

@ -8,6 +8,12 @@ importers:
.: .:
dependencies: dependencies:
'@hcaptcha/react-hcaptcha':
specifier: ^1.12.1
version: 1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@marsidev/react-turnstile':
specifier: ^1.3.0
version: 1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-checkbox': '@radix-ui/react-checkbox':
specifier: ^1.3.3 specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 1.3.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -71,6 +77,9 @@ importers:
react-dom: react-dom:
specifier: 19.1.0 specifier: 19.1.0
version: 19.1.0(react@19.1.0) version: 19.1.0(react@19.1.0)
react-google-recaptcha-v3:
specifier: ^1.11.0
version: 1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-icons: react-icons:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.5.0(react@19.1.0) version: 5.5.0(react@19.1.0)
@ -130,6 +139,10 @@ packages:
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@emnapi/core@1.4.4': '@emnapi/core@1.4.4':
resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==}
@ -195,6 +208,15 @@ packages:
'@formatjs/intl-localematcher@0.6.1': '@formatjs/intl-localematcher@0.6.1':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==} resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@hcaptcha/loader@2.0.1':
resolution: {integrity: sha512-L36qqdOmv8fL6VBZcH34JUI0/SvC5KPOZ5N/m+5pQAPPhtXXRdU4o9iosZr12hWAM2qf5hC92kmi+XdqxKOEZQ==}
'@hcaptcha/react-hcaptcha@1.12.1':
resolution: {integrity: sha512-/A08MOAHa5L9B8UfNRkTR/+x2dOyfk3pI1/qgXI4NpDl/z4CjnSxaYCDtkbD21vEocN1KKCggQD3wJ7OcY494w==}
peerDependencies:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -354,6 +376,12 @@ packages:
'@jridgewell/trace-mapping@0.3.29': '@jridgewell/trace-mapping@0.3.29':
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
'@marsidev/react-turnstile@1.3.0':
resolution: {integrity: sha512-VO99Nynt+j4ETfMImQCj5LgbUKZ9mWPpy3RjP/3e/3vZu+FIphjEdU6g+cq4FeDoNshSxLlRzBTKcH5JMeM1GQ==}
peerDependencies:
react: ^17.0.2 || ^18.0.0 || ^19.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
'@mdx-js/mdx@3.1.0': '@mdx-js/mdx@3.1.0':
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
@ -1689,6 +1717,9 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -2320,6 +2351,12 @@ packages:
peerDependencies: peerDependencies:
react: ^19.1.0 react: ^19.1.0
react-google-recaptcha-v3@1.11.0:
resolution: {integrity: sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==}
peerDependencies:
react: ^16.3 || ^17.0 || ^18.0 || ^19.0
react-dom: ^17.0 || ^18.0 || ^19.0
react-icons@5.5.0: react-icons@5.5.0:
resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
peerDependencies: peerDependencies:
@ -2788,6 +2825,8 @@ snapshots:
'@babel/helper-validator-identifier@7.27.1': {} '@babel/helper-validator-identifier@7.27.1': {}
'@babel/runtime@7.28.4': {}
'@emnapi/core@1.4.4': '@emnapi/core@1.4.4':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.0.3 '@emnapi/wasi-threads': 1.0.3
@ -2878,6 +2917,15 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@hcaptcha/loader@2.0.1': {}
'@hcaptcha/react-hcaptcha@1.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.28.4
'@hcaptcha/loader': 2.0.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6': '@humanfs/node@0.16.6':
@ -2995,6 +3043,11 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.4 '@jridgewell/sourcemap-codec': 1.5.4
'@marsidev/react-turnstile@1.3.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
'@mdx-js/mdx@3.1.0(acorn@8.15.0)': '@mdx-js/mdx@3.1.0(acorn@8.15.0)':
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@ -4499,6 +4552,10 @@ snapshots:
highlight.js@11.11.1: {} highlight.js@11.11.1: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@ -5313,6 +5370,12 @@ snapshots:
react: 19.1.0 react: 19.1.0
scheduler: 0.26.0 scheduler: 0.26.0
react-google-recaptcha-v3@1.11.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
hoist-non-react-statics: 3.3.2
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-icons@5.5.0(react@19.1.0): react-icons@5.5.0(react@19.1.0):
dependencies: dependencies:
react: 19.1.0 react: 19.1.0

View File

@ -1,14 +1,26 @@
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 { LoginRequest, RegisterRequest, User } from '@/models/user' import type { RegisterRequest, User } from '@/models/user'
import axiosClient from './client' import axiosClient from './client'
import { CaptchaProvider } from '@/models/captcha'
export async function userLogin( export async function userLogin(
data: LoginRequest, {
): Promise<BaseResponse<{ token: string, user: User }>> { username,
password,
rememberMe,
captcha
}: {
username: string,
password: string,
rememberMe?: boolean,
captcha?: string,
}): Promise<BaseResponse<{ token: string, user: User }>> {
console.log("Logging in with captcha:", captcha)
const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>( const res = await axiosClient.post<BaseResponse<{ token: string, user: User }>>(
'/user/login', '/user/login',
data, { username, password, rememberMe },
{ headers: { 'X-Captcha-Token': captcha || '' } },
) )
return res.data return res.data
} }
@ -43,3 +55,21 @@ export async function getUserById(id: number): Promise<BaseResponse<User>> {
const res = await axiosClient.get<BaseResponse<User>>(`/user/u/${id}`) const res = await axiosClient.get<BaseResponse<User>>(`/user/u/${id}`)
return res.data return res.data
} }
export async function getUserByUsername(username: string): Promise<BaseResponse<User>> {
const res = await axiosClient.get<BaseResponse<User>>(`/user/username/${username}`)
return res.data
}
export async function getCaptchaConfig(): Promise<BaseResponse<{
provider: CaptchaProvider
siteKey: string
url?: string
}>> {
const res = await axiosClient.get<BaseResponse<{
provider: CaptchaProvider
siteKey: string
url?: string
}>>('/user/captcha')
return res.data
}

View File

@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation'
import { Navbar } from '@/components/layout/navbar' import { Navbar } from '@/components/layout/navbar'
import { BackgroundProvider } from '@/contexts/background-context' import { BackgroundProvider } from '@/contexts/background-context'
import Footer from '@/components/layout/footer' import Footer from '@/components/layout/footer'
import config from '@/config'
export default function RootLayout({ export default function RootLayout({
children, children,
@ -14,25 +15,18 @@ export default function RootLayout({
const pathname = usePathname() const pathname = usePathname()
return ( return (
<> <>
<header className="fixed top-0 left-0 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800"> <motion.nav
initial={{ y: -64, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
<header className="fixed top-0 left-0 h-16 w-full z-50 bg-white/80 dark:bg-slate-900/80 backdrop-blur flex justify-center border-b border-slate-200 dark:border-slate-800">
<Navbar /> <Navbar />
</header> </header>
<motion.main </motion.nav>
key={pathname}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
type: 'tween',
ease: 'easeOut',
duration: 0.30,
}}
className="pt-16"
>
<BackgroundProvider> <BackgroundProvider>
<div className='container mx-auto px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div> <div className='container mx-auto pt-16 px-4 sm:px-6 lg:px-10 max-w-7xl'>{children}</div>
</BackgroundProvider> </BackgroundProvider>
</motion.main>
<Footer /> <Footer />
</> </>
) )

View File

@ -1,8 +0,0 @@
export default function Page() {
return (
<div>
<h1>Page Title</h1>
<p>This is the User content.</p>
</div>
)
}

View File

@ -0,0 +1,27 @@
"use client"
import { getUserByUsername } from "@/api/user";
import { UserPage } from "@/components/user";
import { User } from "@/models/user";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function Page() {
const { username } = useParams() as { username: string };
const [user,setUser] = useState<User | null>(null);
useEffect(() => {
getUserByUsername(username).then(res => {
setUser(res.data);
});
},[username]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<UserPage user={user} />
</div>
)
}

View File

@ -13,150 +13,163 @@ import { useEffect, useState } from "react";
import { useStoredState } from '@/hooks/use-storage-state'; import { useStoredState } from '@/hooks/use-storage-state';
import { listLabels } from "@/api/label"; import { listLabels } from "@/api/label";
import { POST_SORT_TYPE } from "@/localstore"; import { POST_SORT_TYPE } from "@/localstore";
import { motion } from "framer-motion";
// 定义排序类型 // 定义排序类型
type SortType = 'latest' | 'popular'; type SortType = 'latest' | 'popular';
export default function BlogHome() { export default function BlogHome() {
const [labels, setLabels] = useState<Label[]>([]); const [labels, setLabels] = useState<Label[]>([]);
const [posts, setPosts] = useState<Post[]>([]); const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest'); const [sortType, setSortType, sortTypeLoaded] = useStoredState<SortType>(POST_SORT_TYPE, 'latest');
useEffect(() => { useEffect(() => {
if (!sortTypeLoaded) return; if (!sortTypeLoaded) return;
const fetchPosts = async () => { const fetchPosts = async () => {
try { try {
setLoading(true); setLoading(true);
let orderBy: string; let orderBy: string;
let desc: boolean; let desc: boolean;
switch (sortType) { switch (sortType) {
case 'latest': case 'latest':
orderBy = 'updated_at'; orderBy = 'updated_at';
desc = true; desc = true;
break; break;
case 'popular': case 'popular':
orderBy = 'heat'; orderBy = 'heat';
desc = true; desc = true;
break; break;
default: default:
orderBy = 'updated_at'; orderBy = 'updated_at';
desc = true; desc = true;
}
// 处理关键词,空格分割转逗号
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderBy: orderBy,
desc: desc,
keywords
});
setPosts(data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
}
};
fetchPosts();
}, [sortType, sortTypeLoaded]);
// 获取标签
useEffect(() => {
listLabels().then(data => {
setLabels(data.data || []);
}).catch(error => {
console.error("Failed to fetch labels:", error);
});
}, []);
// 处理排序切换
const handleSortChange = (type: SortType) => {
if (sortType !== type) {
setSortType(type);
} }
// 处理关键词,空格分割转逗号
const keywords = ""?.trim() ? ""?.trim().split(/\s+/).join(",") : undefined;
const data = await listPosts({
page: 1,
size: 10,
orderBy: orderBy,
desc: desc,
keywords
});
setPosts(data.data);
} catch (error) {
console.error("Failed to fetch posts:", error);
} finally {
setLoading(false);
}
}; };
fetchPosts();
}, [sortType, sortTypeLoaded]);
return ( // 获取标签
<> useEffect(() => {
{/* 主内容区域 */} listLabels().then(data => {
<section className="py-16"> setLabels(data.data || []);
{/* 容器 - 关键布局 */} }).catch(error => {
<div className=""> console.error("Failed to fetch labels:", error);
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> });
{/* 主要内容区域 */} }, []);
<div className="lg:col-span-3 self-start">
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
{sortType === 'latest' ? '最新文章' : '热门文章'}
{posts.length > 0 && (
<span className="text-sm font-normal text-slate-500 ml-2">
({posts.length} )
</span>
)}
</h2>
{/* 排序按钮组 */} // 处理排序切换
<div className="flex items-center gap-2"> const handleSortChange = (type: SortType) => {
<Button if (sortType !== type) {
variant={sortType === 'latest' ? 'default' : 'outline'} setSortType(type);
size="sm" }
onClick={() => handleSortChange('latest')} };
disabled={loading}
className="transition-all duration-200"
>
<Clock className="w-4 h-4 mr-2" />
</Button>
<Button
variant={sortType === 'popular' ? 'default' : 'outline'}
size="sm"
onClick={() => handleSortChange('popular')}
disabled={loading}
className="transition-all duration-200"
>
<TrendingUp className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 博客卡片网格 */} return (
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} /> <>
{/* 主内容区域 */}
<section className="py-16">
{/* 容器 - 关键布局 */}
<div className="">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 主要内容区域 */}
<motion.div
className="lg:col-span-3 self-start"
initial={{ y: 150, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}>
{/* 文章列表标题 */}
<div className="flex items-center justify-between mb-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-slate-100">
{sortType === 'latest' ? '最新文章' : '热门文章'}
{posts.length > 0 && (
<span className="text-sm font-normal text-slate-500 ml-2">
({posts.length} )
</span>
)}
</h2>
{/* 加载更多按钮 */} {/* 排序按钮 */}
{!loading && posts.length > 0 && ( <div className="flex items-center gap-2">
<div className="text-center mt-12"> <Button
<Button size="lg" className="px-8"> variant={sortType === 'latest' ? 'default' : 'outline'}
size="sm"
</Button> onClick={() => handleSortChange('latest')}
</div> disabled={loading}
)} className="transition-all duration-200"
>
{/* 加载状态指示器 */} <Clock className="w-4 h-4 mr-2" />
{loading && (
<div className="text-center py-8"> </Button>
<div className="inline-flex items-center gap-2 text-slate-600"> <Button
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div> variant={sortType === 'popular' ? 'default' : 'outline'}
{sortType === 'latest' ? '最新' : '热门'}... size="sm"
</div> onClick={() => handleSortChange('popular')}
</div> disabled={loading}
)} className="transition-all duration-200"
</div> >
<TrendingUp className="w-4 h-4 mr-2" />
{/* 侧边栏 */}
<Sidebar </Button>
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>
</div>
</div> </div>
</section> </div>
</>
); {/* 博客卡片网格 */}
<BlogCardGrid posts={posts} isLoading={loading} showPrivate={true} />
{/* 加载更多按钮 */}
{!loading && posts.length > 0 && (
<div className="text-center mt-12">
<Button size="lg" className="px-8">
</Button>
</div>
)}
{/* 加载状态指示器 */}
{loading && (
<div className="text-center py-8">
<div className="inline-flex items-center gap-2 text-slate-600">
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
{sortType === 'latest' ? '最新' : '热门'}...
</div>
</div>
)}
</motion.div>
{/* 侧边栏 */}
<motion.div
initial={{ x: 200, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: config.animationDurationSecond, ease: "easeOut" }}
>
<Sidebar
cards={[
<SidebarAbout key="about" config={config} />,
posts.length > 0 ? <SidebarHotPosts key="hot" posts={posts} sortType={sortType} /> : null,
<SidebarTags key="tags" labels={labels} />,
<SidebarMisskeyIframe key="misskey" />,
].filter(Boolean)}
/>
</motion.div>
</div>
</div>
</section>
</>
);
} }

View File

@ -3,7 +3,7 @@ import { User } from "@/models/user";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getGravatarByUser } from "@/components/common/gravatar"; import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { CircleUser } from "lucide-react"; import { CircleUser } from "lucide-react";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
@ -60,7 +60,7 @@ export function CommentInput(
<div className="fade-in-up"> <div className="fade-in-up">
<div className="flex py-4 fade-in"> <div className="flex py-4 fade-in">
<div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in"> <div onClick={user ? () => clickToUserProfile(user.username) : clickToLogin} className="cursor-pointer flex-shrink-0 w-10 h-10 fade-in">
{user ? getGravatarByUser(user) : null} {user && <GravatarAvatar url={user.avatarUrl} email={user.email} size={100}/>}
{!user && <CircleUser className="w-full h-full fade-in" />} {!user && <CircleUser className="w-full h-full fade-in" />}
</div> </div>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">

View File

@ -3,7 +3,7 @@ import { User } from "@/models/user";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getGravatarByUser } from "@/components/common/gravatar"; import GravatarAvatar, { getGravatarByUser } from "@/components/common/gravatar";
import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react"; import { Reply, Trash, Heart, Pencil, Lock } from "lucide-react";
import { Comment } from "@/models/comment"; import { Comment } from "@/models/comment";
import { TargetType } from "@/models/types"; import { TargetType } from "@/models/types";
@ -158,8 +158,8 @@ export function CommentItem(
return ( return (
<div> <div>
<div className="flex"> <div className="flex">
<div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in"> <div onClick={() => clickToUserProfile(comment.user.username)} className="cursor-pointer fade-in w-12 h-12">
{getGravatarByUser(comment.user)} <GravatarAvatar email={comment.user.email} size={120}/>
</div> </div>
<div className="flex-1 pl-2 fade-in-up"> <div className="flex-1 pl-2 fade-in-up">
<div className="flex gap-2 md:gap-4 items-center"> <div className="flex gap-2 md:gap-4 items-center">

View File

@ -0,0 +1 @@
/* 选择 Turnstile 组件内的 iframe */

View File

@ -0,0 +1,61 @@
"use client"
import { useEffect } from "react";
import { GoogleReCaptcha, GoogleReCaptchaProvider } from "react-google-recaptcha-v3";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import { Turnstile } from "@marsidev/react-turnstile";
import { CaptchaProvider } from "@/models/captcha";
import "./captcha.css";
import { TurnstileWidget } from "./turnstile";
export type CaptchaProps = {
provider: CaptchaProvider;
siteKey: string;
url?: string;
onSuccess: (token: string) => void;
onError: (error: string) => void;
};
export function ReCaptchaWidget(props: CaptchaProps) {
return (
<GoogleReCaptchaProvider reCaptchaKey={props.siteKey} useEnterprise={false}>
<GoogleReCaptcha action="submit" onVerify={props.onSuccess} />
</GoogleReCaptchaProvider>
);
}
export function NoCaptchaWidget(props: CaptchaProps) {
useEffect(() => {
props.onSuccess("no-captcha");
}, [props, props.onSuccess]);
return null;
}
export function HCaptchaWidget(props: CaptchaProps) {
return (
<HCaptcha
sitekey={props.siteKey}
onVerify={props.onSuccess}
onError={props.onError}
onExpire={() => props.onError?.("Captcha expired")}
/>
);
}
export default function AIOCaptchaWidget(props: CaptchaProps) {
switch (props.provider) {
case CaptchaProvider.HCAPTCHA:
return <HCaptchaWidget {...props} />;
case CaptchaProvider.RECAPTCHA:
return <ReCaptchaWidget {...props} />;
case CaptchaProvider.TURNSTILE:
return <TurnstileWidget {...props} />;
case CaptchaProvider.DISABLE:
return <NoCaptchaWidget {...props} />;
default:
throw new Error(`Unsupported captcha provider: ${props.provider}`);
}
}

View File

@ -0,0 +1,54 @@
import { useState } from "react";
import { CaptchaProps } from ".";
import { Turnstile } from "@marsidev/react-turnstile";
import { useTranslations } from "next-intl";
// 简单的转圈圈动画
function Spinner() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
<svg className="animate-spin" width="32" height="32" viewBox="0 0 50 50">
<circle className="opacity-25" cx="25" cy="25" r="20" fill="none" stroke="#e5e7eb" strokeWidth="5" />
<circle className="opacity-75" cx="25" cy="25" r="20" fill="none" stroke="#6366f1" strokeWidth="5" strokeDasharray="90 150" strokeDashoffset="0" />
</svg>
</div>
);
}
// 勾勾动画
function CheckMark() {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 40 }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 10 18 4 12" />
</svg>
</div>
);
}
export function OfficialTurnstileWidget(props: CaptchaProps) {
return <div>
<Turnstile className="w-full" options={{ size: "invisible" }} siteKey={props.siteKey} onSuccess={props.onSuccess} />
</div>;
}
// 自定义包装组件
export function TurnstileWidget(props: CaptchaProps) {
const t = useTranslations("Captcha");
const [status, setStatus] = useState<'loading' | 'success'>('loading');
// 只在验证通过时才显示勾
const handleSuccess = (token: string) => {
setStatus('success');
props.onSuccess(token);
};
return (
<div className="flex items-center justify-evenly w-full border border-gray-300 rounded-md px-4 py-2 relative">
{status === 'loading' && <Spinner />}
{status === 'success' && <CheckMark />}
<div className="flex-1 text-center">{status === 'success' ? t("success") :t("doing")}</div>
<div className="absolute inset-0 opacity-0 pointer-events-none">
<OfficialTurnstileWidget {...props} onSuccess={handleSuccess} />
</div>
</div>
);
}

View File

@ -28,27 +28,25 @@ const GravatarAvatar: React.FC<GravatarAvatarProps> = ({
defaultType = "identicon" defaultType = "identicon"
}) => { }) => {
// 如果有自定义URL使用自定义URL // 如果有自定义URL使用自定义URL
if (url) { if (url && url.trim() !== "") {
return ( return (
<Image <Image
src={url} src={url}
width={size} width={size}
height={size} height={size}
className={`rounded-full object-cover ${className}`} className={`rounded-full object-cover w-full h-full ${className}`}
alt={alt} alt={alt}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
); );
} }
const gravatarUrl = getGravatarUrl(email, size * 10, defaultType); const gravatarUrl = getGravatarUrl(email, size * 10, defaultType);
return ( return (
<Image <Image
src={gravatarUrl} src={gravatarUrl}
width={size} width={size}
height={size} height={size}
className={`rounded-full object-cover ${className}`} className={`rounded-full object-cover w-full h-full ${className}`}
alt={alt} alt={alt}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
@ -63,14 +61,14 @@ interface User {
avatarUrl?: string; avatarUrl?: string;
} }
export function getGravatarByUser(user?: User, className: string = ""): React.ReactElement { export function getGravatarByUser({user, className="", size=640}:{user?: User, className?: string, size?: number}): React.ReactElement {
if (!user) { if (!user) {
return <GravatarAvatar email="" className={className} />; return <GravatarAvatar email="" className={className} />;
} }
return ( return (
<GravatarAvatar <GravatarAvatar
email={user.email || ""} email={user.email || ""}
size={40} size={size}
className={className} className={className}
alt={user.displayName || user.name || "User Avatar"} alt={user.displayName || user.name || "User Avatar"}
url={user.avatarUrl} url={user.avatarUrl}

View File

@ -4,13 +4,13 @@ import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { import {
NavigationMenu, NavigationMenu,
NavigationMenuContent, NavigationMenuContent,
NavigationMenuItem, NavigationMenuItem,
NavigationMenuLink, NavigationMenuLink,
NavigationMenuList, NavigationMenuList,
NavigationMenuTrigger, NavigationMenuTrigger,
navigationMenuTriggerStyle, navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu" } from "@/components/ui/navigation-menu"
import GravatarAvatar from "@/components/common/gravatar" import GravatarAvatar from "@/components/common/gravatar"
import { useDevice } from "@/contexts/device-context" import { useDevice } from "@/contexts/device-context"
@ -21,157 +21,156 @@ import { Menu } from "lucide-react"
import { Switch } from "../ui/switch" import { Switch } from "../ui/switch"
const navbarMenuComponents = [ const navbarMenuComponents = [
{ {
title: "首页", title: "首页",
href: "/" href: "/"
}, },
{ {
title: "文章", title: "文章",
children: [ children: [
{ title: "归档", href: "/archives" }, { title: "归档", href: "/archives" },
{ title: "标签", href: "/labels" }, { title: "标签", href: "/labels" },
{ title: "随机", href: "/random" } { title: "随机", href: "/random" }
] ]
}, },
{ {
title: "页面", title: "页面",
children: [ children: [
{ title: "关于我", href: "/about" }, { title: "关于我", href: "/about" },
{ title: "联系我", href: "/contact" }, { title: "联系我", href: "/contact" },
{ title: "友链", href: "/links" }, { title: "友链", href: "/links" },
{ title: "隐私政策", href: "/privacy-policy" }, { title: "隐私政策", href: "/privacy-policy" },
] ]
} }
] ]
export function Navbar() { export function Navbar() {
const { navbarAdditionalClassName, setMode, mode } = useDevice() const { navbarAdditionalClassName, setMode, mode } = useDevice()
return ( return (
<nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-16 px-4 w-full ${navbarAdditionalClassName}`}> <nav className={`grid grid-cols-[1fr_auto_1fr] items-center gap-4 h-full px-4 w-full ${navbarAdditionalClassName}`}>
<div className="flex items-center justify-start"> <div className="flex items-center justify-start">
<span className="font-bold truncate">{config.metadata.name}</span> <span className="font-bold truncate">{config.metadata.name}</span>
</div> </div>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<NavMenuCenter /> <NavMenuCenter />
</div> </div>
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} /> <Switch checked={mode === "dark"} onCheckedChange={(checked) => setMode(checked ? "dark" : "light")} />
<GravatarAvatar email="snowykami@outlook.com" /> <SidebarMenuClientOnly />
<SidebarMenuClientOnly /> </div>
</div> </nav>
</nav> )
)
} }
function NavMenuCenter() { function NavMenuCenter() {
return ( return (
<NavigationMenu viewport={false} className="hidden md:block"> <NavigationMenu viewport={false} className="hidden md:block">
<NavigationMenuList className="flex space-x-1"> <NavigationMenuList className="flex space-x-1">
{navbarMenuComponents.map((item) => ( {navbarMenuComponents.map((item) => (
<NavigationMenuItem key={item.title}> <NavigationMenuItem key={item.title}>
{item.href ? ( {item.href ? (
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}> <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href={item.href} className="flex items-center gap-1 font-extrabold"> <Link href={item.href} className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span> <span>{item.title}</span>
</Link> </Link>
</NavigationMenuLink> </NavigationMenuLink>
) : item.children ? ( ) : item.children ? (
<> <>
<NavigationMenuTrigger className="flex items-center gap-1 font-extrabold"> <NavigationMenuTrigger className="flex items-center gap-1 font-extrabold">
<span>{item.title}</span> <span>{item.title}</span>
</NavigationMenuTrigger> </NavigationMenuTrigger>
<NavigationMenuContent> <NavigationMenuContent>
<ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]"> <ul className="grid gap-2 p-0 min-w-[200px] max-w-[600px] grid-cols-[repeat(auto-fit,minmax(120px,1fr))]">
{item.children.map((child) => ( {item.children.map((child) => (
<ListItem <ListItem
key={child.title} key={child.title}
title={child.title} title={child.title}
href={child.href} href={child.href}
/> />
))} ))}
</ul> </ul>
</NavigationMenuContent> </NavigationMenuContent>
</> </>
) : null} ) : null}
</NavigationMenuItem> </NavigationMenuItem>
))} ))}
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
) )
} }
function ListItem({ function ListItem({
title, title,
children, children,
href, href,
...props ...props
}: React.ComponentPropsWithoutRef<"li"> & { href: string }) { }: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
return ( return (
<li {...props} className="flex justify-center"> <li {...props} className="flex justify-center">
<NavigationMenuLink asChild> <NavigationMenuLink asChild>
<Link href={href} className="flex flex-col items-center text-center w-full"> <Link href={href} className="flex flex-col items-center text-center w-full">
<div className="text-sm leading-none font-medium">{title}</div> <div className="text-sm leading-none font-medium">{title}</div>
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug"> <p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
{children} {children}
</p> </p>
</Link> </Link>
</NavigationMenuLink> </NavigationMenuLink>
</li> </li>
) )
} }
function SidebarMenuClientOnly() { function SidebarMenuClientOnly() {
return <SidebarMenu />; return <SidebarMenu />;
} }
function SidebarMenu() { function SidebarMenu() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
<div className="md:hidden"> <div className="md:hidden">
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<button <button
aria-label="打开菜单" aria-label="打开菜单"
className="p-2 rounded-md hover:bg-accent transition-colors" className="p-2 rounded-md hover:bg-accent transition-colors"
> >
<Menu className="w-6 h-6" /> <Menu className="w-6 h-6" />
</button> </button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" className="p-0 w-64"> <SheetContent side="right" className="p-0 w-64">
{/* 可访问性要求的标题,视觉上隐藏 */} {/* 可访问性要求的标题,视觉上隐藏 */}
<SheetTitle className="sr-only"></SheetTitle> <SheetTitle className="sr-only"></SheetTitle>
<nav className="flex flex-col gap-2 p-4"> <nav className="flex flex-col gap-2 p-4">
{navbarMenuComponents.map((item) => {navbarMenuComponents.map((item) =>
item.href ? ( item.href ? (
<Link <Link
key={item.title} key={item.title}
href={item.href} href={item.href}
className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors" className="py-2 px-3 rounded hover:bg-accent font-bold transition-colors"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
{item.title} {item.title}
</Link> </Link>
) : item.children ? ( ) : item.children ? (
<div key={item.title} className="mb-2"> <div key={item.title} className="mb-2">
<div className="font-bold px-3 py-2">{item.title}</div> <div className="font-bold px-3 py-2">{item.title}</div>
<div className="flex flex-col pl-4"> <div className="flex flex-col pl-4">
{item.children.map((child) => ( {item.children.map((child) => (
<Link <Link
key={child.title} key={child.title}
href={child.href} href={child.href}
className="py-2 px-3 rounded hover:bg-accent transition-colors" className="py-2 px-3 rounded hover:bg-accent transition-colors"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
{child.title} {child.title}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
) : null ) : null
)} )}
</nav> </nav>
</SheetContent> </SheetContent>
</Sheet></div> </Sheet></div>
) )
} }

View File

@ -14,10 +14,12 @@ import { Label } from "@/components/ui/label"
import Image from "next/image" import Image from "next/image"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import type { OidcConfig } from "@/models/oidc-config" import type { OidcConfig } from "@/models/oidc-config"
import { ListOidcConfigs, userLogin } from "@/api/user" import { getCaptchaConfig, ListOidcConfigs, userLogin } from "@/api/user"
import Link from "next/link" 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 "../common/captcha"
import { CaptchaProvider } from "@/models/captcha"
export function LoginForm({ export function LoginForm({
className, className,
@ -25,12 +27,18 @@ export function LoginForm({
}: React.ComponentProps<"div">) { }: React.ComponentProps<"div">) {
const t = useTranslations('Login') const t = useTranslations('Login')
const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([]) const [oidcConfigs, setOidcConfigs] = useState<OidcConfig[]>([])
const [captchaProps, setCaptchaProps] = useState<{
provider: CaptchaProvider
siteKey: string
url?: string
} | null>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaError, setCaptchaError] = useState<string | null>(null)
const [{ username, password }, setCredentials] = useState({ username: '', password: '' }) const [{ username, password }, setCredentials] = useState({ username: '', password: '' })
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const redirectBack = searchParams.get("redirect_back") || "/" const redirectBack = searchParams.get("redirect_back") || "/"
useEffect(() => { useEffect(() => {
ListOidcConfigs() ListOidcConfigs()
.then((res) => { .then((res) => {
@ -42,10 +50,20 @@ export function LoginForm({
}) })
}, []) }, [])
useEffect(() => {
getCaptchaConfig()
.then((res) => {
setCaptchaProps(res.data)
})
.catch((error) => {
console.error("Error fetching captcha config:", error)
})
}, [])
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
try { try {
const res = await userLogin({username, password}) const res = await userLogin({ username, password, captcha: captchaToken || "" })
console.log("Login successful:", res) console.log("Login successful:", res)
router.push(redirectBack) router.push(redirectBack)
} catch (error) { } catch (error) {
@ -128,7 +146,19 @@ export function LoginForm({
onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))} onChange={e => setCredentials(c => ({ ...c, password: e.target.value }))}
/> />
</div> </div>
<Button type="submit" className="w-full" onClick={handleLogin}> {captchaProps &&
<div className="flex justify-center items-center w-full">
<Captcha {...captchaProps} onSuccess={setCaptchaToken} onError={setCaptchaError} />
</div>
}
{captchaError && <div>
{t("captcha_error")}</div>}
<Button
type="submit"
className="w-full"
onClick={handleLogin}
disabled={!captchaToken}
>
{t("login")} {t("login")}
</Button> </Button>
</div> </div>

View File

@ -0,0 +1,5 @@
import { User } from "@/models/user";
export function UserPage({user}: {user: User}) {
return <div>User: {user.username}</div>;
}

View File

@ -0,0 +1,13 @@
"use client"
import { getUserByUsername } from "@/api/user";
import { User } from "@/models/user";
import { useEffect, useState } from "react";
import { getGravatarByUser } from "../common/gravatar";
export function UserProfile({ user }: { user: User }) {
return (
<div className="flex">
{getGravatarByUser({user,className: "rounded-full mr-4"})}
</div>
);
}

View File

@ -16,6 +16,7 @@ const config = {
bodyWidthMobile: "100vw", bodyWidthMobile: "100vw",
postsPerPage: 12, postsPerPage: 12,
commentsPerPage: 8, commentsPerPage: 8,
animationDurationSecond: 0.5,
footer: { footer: {
text: "Liteyuki ICP备 1145141919810", text: "Liteyuki ICP备 1145141919810",
links: [] links: []

View File

@ -2,6 +2,10 @@
"HomePage": { "HomePage": {
"title": "Hello world!" "title": "Hello world!"
}, },
"Captcha": {
"doing": "正在检测你是不是机器人...",
"success": "恭喜,你是人类!"
},
"Comment": { "Comment": {
"collapse_replies": "收起", "collapse_replies": "收起",
"comment": "评论", "comment": "评论",
@ -41,6 +45,7 @@
"secondsAgo": "秒前" "secondsAgo": "秒前"
}, },
"Login": { "Login": {
"captcha_error": "验证错误,请重试。",
"welcome": "欢迎回来", "welcome": "欢迎回来",
"with_oidc": "使用第三方身份提供者", "with_oidc": "使用第三方身份提供者",
"or_continue_with_local_account": "或使用用户名和密码", "or_continue_with_local_account": "或使用用户名和密码",

View File

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

View File

@ -10,13 +10,6 @@ export interface User {
} }
export interface LoginRequest {
username: string
password: string
rememberMe?: boolean
captcha?: string
}
export interface RegisterRequest { export interface RegisterRequest {
username: string username: string
password: string password: string