feat: add new color themes and styles for rose, violet, and yellow

- Introduced new CSS files for rose, violet, and yellow themes with custom color variables.
- Implemented dark mode styles for each theme.
- Created a color data structure to manage theme colors in the console settings.

feat: implement image cropper component

- Added an image cropper component for user profile picture editing.
- Integrated the image cropper into the user profile page.

feat: enhance console sidebar with user permissions

- Defined sidebar items with permission checks for admin and editor roles.
- Updated user center navigation to reflect user permissions.

feat: add user profile and security settings

- Developed user profile page with avatar upload and editing functionality.
- Implemented user security settings for password and email verification.

feat: create reusable dialog and OTP input components

- Built a dialog component for modal interactions.
- Developed an OTP input component for email verification.

fix: improve file handling utilities

- Added utility functions for file URI generation.
- Implemented permission checks for user roles in the common utilities.
This commit is contained in:
2025-09-20 12:45:10 +08:00
parent f8e4a84d53
commit 709aa82337
62 changed files with 1844 additions and 487 deletions

View File

@ -1 +1,129 @@
package v1
import (
"context"
"io"
"path/filepath"
"strconv"
"github.com/cloudwego/hertz/pkg/app"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/filedriver"
"github.com/snowykami/neo-blog/pkg/resps"
"github.com/snowykami/neo-blog/pkg/utils"
)
type FileController struct{}
func NewFileController() *FileController {
return &FileController{}
}
func (f *FileController) UploadFileStream(ctx context.Context, c *app.RequestContext) {
// 获取文件信息
file, err := c.FormFile("file")
if err != nil {
logrus.Error("无法读取文件: ", err)
resps.BadRequest(c, err.Error())
return
}
group := string(c.FormValue("group"))
name := string(c.FormValue("name"))
// 初始化文件驱动
driver, err := filedriver.GetFileDriver(filedriver.GetWebdavDriverConfig())
if err != nil {
logrus.Error("获取文件驱动失败: ", err)
resps.InternalServerError(c, "获取文件驱动失败")
return
}
// 校验文件哈希
if hashForm := string(c.FormValue("hash")); hashForm != "" {
dir, fileName := utils.FilePath(hashForm)
storagePath := filepath.Join(dir, fileName)
if _, err := driver.Stat(c, storagePath); err == nil {
resps.Ok(c, "文件已存在", map[string]any{"hash": hashForm})
return
}
}
// 打开文件
src, err := file.Open()
if err != nil {
logrus.Error("无法打开文件: ", err)
resps.BadRequest(c, err.Error())
return
}
defer src.Close()
// 计算文件哈希值
hash, err := utils.FileHashFromStream(src)
if err != nil {
logrus.Error("计算文件哈希失败: ", err)
resps.BadRequest(c, err.Error())
return
}
// 根据哈希值生成存储路径
dir, fileName := utils.FilePath(hash)
storagePath := filepath.Join(dir, fileName)
// 保存文件
if _, err := src.Seek(0, io.SeekStart); err != nil {
logrus.Error("无法重置文件流位置: ", err)
resps.BadRequest(c, err.Error())
return
}
if err := driver.Save(c, storagePath, src); err != nil {
logrus.Error("保存文件失败: ", err)
resps.InternalServerError(c, err.Error())
return
}
// 数据库索引建立
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
resps.InternalServerError(c, "获取当前用户失败")
return
}
fileModel := &model.File{
Hash: hash,
UserID: currentUser.ID,
Group: group,
Name: name,
}
if err := repo.File.Create(fileModel); err != nil {
logrus.Error("数据库索引建立失败: ", err)
resps.InternalServerError(c, "数据库索引建立失败")
return
}
resps.Ok(c, "文件上传成功", map[string]any{"hash": hash, "id": fileModel.ID})
}
func (f *FileController) GetFile(ctx context.Context, c *app.RequestContext) {
fileIdString := c.Param("id")
fileId, err := strconv.ParseUint(fileIdString, 10, 64)
if err != nil {
logrus.Error("无效的文件ID: ", err)
resps.BadRequest(c, "无效的文件ID")
return
}
fileModel, err := repo.File.GetByID(uint(fileId))
if err != nil {
logrus.Error("获取文件信息失败: ", err)
resps.InternalServerError(c, "获取文件信息失败")
return
}
driver, err := filedriver.GetFileDriver(filedriver.GetWebdavDriverConfig())
if err != nil {
logrus.Error("获取文件驱动失败: ", err)
resps.InternalServerError(c, "获取文件驱动失败")
return
}
filePath := filepath.Join(utils.FilePath(fileModel.Hash))
driver.Get(c, filePath)
}

View File

@ -172,7 +172,7 @@ func (u *UserController) UpdateUser(ctx context.Context, c *app.RequestContext)
resp, err := u.service.UpdateUser(&updateUserReq)
if err != nil {
serviceErr := errs.AsServiceError(err)
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
resps.Custom(c, serviceErr.Code, err.Error(), nil)
return
}
resps.Ok(c, resps.Success, resp)

View File

@ -1,32 +1,33 @@
package ctxutils
import (
"context"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/constant"
"context"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/constant"
)
// GetCurrentUser 从上下文中获取当前用户
func GetCurrentUser(ctx context.Context) (*model.User, bool) {
val := ctx.Value(constant.ContextKeyUserID)
if val == nil {
return nil, false
}
user, err := repo.User.GetUserByID(val.(uint))
if err != nil {
return nil, false
}
val := ctx.Value(constant.ContextKeyUserID)
if val == nil {
return nil, false
}
user, err := repo.User.GetUserByID(val.(uint))
if err != nil {
return nil, false
}
return user, true
return user, true
}
// GetCurrentUserID 从上下文中获取当前用户ID
func GetCurrentUserID(ctx context.Context) (uint, bool) {
user, ok := GetCurrentUser(ctx)
if !ok || user == nil {
return 0, false
}
user, ok := GetCurrentUser(ctx)
if !ok || user == nil {
return 0, false
}
return user.ID, true
return user.ID, true
}

View File

@ -1 +1,12 @@
package model
import "gorm.io/gorm"
type File struct {
gorm.Model
ID uint `gorm:"primaryKey"` // 文件ID File ID
Hash string `gorm:"not null"` // 文件哈希值 File hash
UserID uint `gorm:"not null"` // 上传者ID Uploader ID
Group string // 分组名称
Name string // 文件名为空显示未hash
}

View File

@ -8,10 +8,10 @@ import (
type User struct {
gorm.Model
Username string `gorm:"uniqueIndex;not null"` // 用户名,唯一
Nickname string
Nickname string `gorm:"default:''"` // 昵称
AvatarUrl string
Email string `gorm:"uniqueIndex"`
Gender string
Gender string `gorm:"default:''"`
Role string `gorm:"default:'user'"` // user editor admin
Language string `gorm:"default:'en'"`
Password string // 密码,存储加密后的值

View File

@ -1 +1,21 @@
package repo
import "github.com/snowykami/neo-blog/internal/model"
type FileRepo struct{}
var File = &FileRepo{}
func (f *FileRepo) Create(file *model.File) (err error) {
return GetDB().Create(file).Error
}
func (f *FileRepo) GetByHash(hash string) (file model.File, err error) {
err = GetDB().Where("hash = ?", hash).First(&file).Error
return
}
func (f *FileRepo) GetByID(id uint) (file model.File, err error) {
err = GetDB().Where("id = ?", id).First(&file).Error
return
}

View File

@ -127,6 +127,7 @@ func migrate() error {
&model.Comment{},
&model.Label{},
&model.Like{},
&model.File{},
&model.OidcConfig{},
&model.Post{},
&model.Session{},

View File

@ -1,7 +1,18 @@
package apiv1
import "github.com/cloudwego/hertz/pkg/route"
import (
"github.com/cloudwego/hertz/pkg/route"
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
"github.com/snowykami/neo-blog/internal/middleware"
)
func registerFileRoutes(group *route.RouterGroup) {
// TODO: Impl file routes
fileController := v1.NewFileController()
fileGroup := group.Group("/file").Use(middleware.UseAuth(true))
fileGroupWithoutAuth := group.Group("/file")
{
fileGroup.POST("/f", fileController.UploadFileStream) // 上传文件 Upload file
fileGroup.DELETE("/f/:id") // TODO: 删除文件 Delete file
fileGroupWithoutAuth.GET("/f/:id", fileController.GetFile) // 下载文件 Download file
}
}

View File

@ -1,196 +1,196 @@
package service
import (
"context"
"strconv"
"context"
"strconv"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/utils"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
)
type CommentService struct{}
func NewCommentService() *CommentService {
return &CommentService{}
return &CommentService{}
}
func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) (uint, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return 0, errs.ErrUnauthorized
}
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return 0, errs.ErrUnauthorized
}
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
if err != nil {
return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err)
}
return 0, errs.ErrBadRequest
}
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
if err != nil {
return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err)
}
return 0, errs.ErrBadRequest
}
comment := &model.Comment{
Content: req.Content,
ReplyID: req.ReplyID,
TargetID: req.TargetID,
TargetType: req.TargetType,
UserID: currentUser.ID,
IsPrivate: req.IsPrivate,
RemoteAddr: req.RemoteAddr,
UserAgent: req.UserAgent,
ShowClientInfo: req.ShowClientInfo,
}
comment := &model.Comment{
Content: req.Content,
ReplyID: req.ReplyID,
TargetID: req.TargetID,
TargetType: req.TargetType,
UserID: currentUser.ID,
IsPrivate: req.IsPrivate,
RemoteAddr: req.RemoteAddr,
UserAgent: req.UserAgent,
ShowClientInfo: req.ShowClientInfo,
}
commentID, err := repo.Comment.CreateComment(comment)
commentID, err := repo.Comment.CreateComment(comment)
if err != nil {
return 0, err
}
if err != nil {
return 0, err
}
return commentID, nil
return commentID, nil
}
func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return errs.ErrUnauthorized
}
logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID)
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return errs.ErrUnauthorized
}
logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID)
comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID)))
if err != nil {
return err
}
comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID)))
if err != nil {
return err
}
if currentUser.ID != comment.UserID {
return errs.ErrForbidden
}
if currentUser.ID != comment.UserID {
return errs.ErrForbidden
}
comment.Content = req.Content
comment.IsPrivate = req.IsPrivate
comment.ShowClientInfo = req.ShowClientInfo
err = repo.Comment.UpdateComment(comment)
if err != nil {
return err
}
return nil
comment.Content = req.Content
comment.IsPrivate = req.IsPrivate
comment.ShowClientInfo = req.ShowClientInfo
err = repo.Comment.UpdateComment(comment)
if err != nil {
return err
}
return nil
}
func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return errs.ErrUnauthorized
}
if commentID == "" {
return errs.ErrBadRequest
}
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return errs.ErrUnauthorized
}
if commentID == "" {
return errs.ErrBadRequest
}
comment, err := repo.Comment.GetComment(commentID)
if err != nil {
return errs.New(errs.ErrNotFound.Code, "comment not found", err)
}
comment, err := repo.Comment.GetComment(commentID)
if err != nil {
return errs.New(errs.ErrNotFound.Code, "comment not found", err)
}
isTargetOwner := false
if comment.TargetType == constant.TargetTypePost {
post, err := repo.Post.GetPostByID(strconv.Itoa(int(comment.TargetID)))
if err == nil && post.UserID == currentUser.ID {
isTargetOwner = true
}
}
isTargetOwner := false
if comment.TargetType == constant.TargetTypePost {
post, err := repo.Post.GetPostByID(strconv.Itoa(int(comment.TargetID)))
if err == nil && post.UserID == currentUser.ID {
isTargetOwner = true
}
}
if comment.UserID != currentUser.ID && isTargetOwner {
return errs.ErrForbidden
}
if comment.UserID != currentUser.ID && isTargetOwner {
return errs.ErrForbidden
}
if err := repo.Comment.DeleteComment(commentID); err != nil {
return err
}
return nil
if err := repo.Comment.DeleteComment(commentID); err != nil {
return err
}
return nil
}
func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) {
comment, err := repo.Comment.GetComment(commentID)
comment, err := repo.Comment.GetComment(commentID)
if err != nil {
return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err)
}
if err != nil {
return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err)
}
currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID
}
if comment.IsPrivate && currentUserID != comment.UserID {
return nil, errs.ErrForbidden
}
commentDto := cs.toGetCommentDto(comment, currentUserID)
return &commentDto, err
currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID
}
if comment.IsPrivate && currentUserID != comment.UserID {
return nil, errs.ErrForbidden
}
commentDto := cs.toGetCommentDto(comment, currentUserID)
return &commentDto, err
}
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID
}
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
if err != nil {
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
}
commentDtos := make([]dto.CommentDto, 0)
for _, comment := range comments {
//replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID)
commentDto := cs.toGetCommentDto(&comment, currentUserID)
commentDtos = append(commentDtos, commentDto)
}
return commentDtos, nil
currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID
}
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
if err != nil {
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
}
commentDtos := make([]dto.CommentDto, 0)
for _, comment := range comments {
//replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID)
commentDto := cs.toGetCommentDto(&comment, currentUserID)
commentDtos = append(commentDtos, commentDto)
}
return commentDtos, nil
}
func (cs *CommentService) toGetCommentDto(comment *model.Comment, currentUserID uint) dto.CommentDto {
isLiked := false
if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
}
ua := utils.ParseUA(comment.UserAgent)
if !comment.ShowClientInfo {
comment.Location = ""
ua.OS = ""
ua.OSVersion = ""
ua.Browser = ""
ua.BrowserVer = ""
}
isLiked := false
if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
}
ua := utils.ParseUA(comment.UserAgent)
if !comment.ShowClientInfo {
comment.Location = ""
ua.OS = ""
ua.OSVersion = ""
ua.Browser = ""
ua.BrowserVer = ""
}
return dto.CommentDto{
ID: comment.ID,
Content: comment.Content,
TargetID: comment.TargetID,
TargetType: comment.TargetType,
ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth,
User: comment.User.ToDto(),
ReplyCount: comment.CommentCount,
LikeCount: comment.LikeCount,
IsLiked: isLiked,
IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location,
ShowClientInfo: comment.ShowClientInfo,
}
return dto.CommentDto{
ID: comment.ID,
Content: comment.Content,
TargetID: comment.TargetID,
TargetType: comment.TargetType,
ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth,
User: comment.User.ToDto(),
ReplyCount: comment.CommentCount,
LikeCount: comment.LikeCount,
IsLiked: isLiked,
IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location,
ShowClientInfo: comment.ShowClientInfo,
}
}
func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) {
switch targetType {
case constant.TargetTypePost:
if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil {
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
default:
return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil)
}
return true, nil
switch targetType {
case constant.TargetTypePost:
if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil {
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
default:
return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil)
}
return true, nil
}

View File

@ -368,7 +368,7 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e
return nil, errs.ErrNotFound
}
logrus.Errorln("Failed to update user:", err)
return nil, errs.ErrInternalServer
return nil, err
}
return &dto.UpdateUserResp{}, nil
}