mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 04:17:23 +00:00
Support commenting with markdown.
This commit is contained in:
@@ -14,12 +14,31 @@ func AddCommentRoutes(router fiber.Router) {
|
||||
api.Post("/resource/:resourceID", createResourceComment)
|
||||
api.Post("/reply/:commentID", createReplyComment)
|
||||
api.Get("/resource/:resourceID", listResourceComments)
|
||||
api.Get("/reply/:commentID", listResourceComments)
|
||||
api.Get("/reply/:commentID", listReplyComments)
|
||||
api.Get("/user/:username", listCommentsByUser)
|
||||
api.Get("/:commentID", GetCommentByID)
|
||||
api.Put("/:commentID", updateComment)
|
||||
api.Delete("/:commentID", deleteComment)
|
||||
}
|
||||
|
||||
func GetCommentByID(c fiber.Ctx) error {
|
||||
commentIDStr := c.Params("commentID")
|
||||
commentID, err := strconv.Atoi(commentIDStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid comment ID")
|
||||
}
|
||||
|
||||
comment, err := service.GetCommentByID(uint(commentID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(model.Response[model.CommentWithRefView]{
|
||||
Success: true,
|
||||
Data: *comment,
|
||||
Message: "Comment retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func createResourceComment(c fiber.Ctx) error {
|
||||
userID, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
@@ -40,7 +59,7 @@ func createResourceComment(c fiber.Ctx) error {
|
||||
return model.NewRequestError("Content cannot be empty")
|
||||
}
|
||||
|
||||
comment, err := service.CreateComment(req, userID, uint(resourceID), c.IP(), model.CommentTypeResource)
|
||||
comment, err := service.CreateComment(req, userID, uint(resourceID), c.IP(), model.CommentTypeResource, c.Host())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -71,7 +90,7 @@ func createReplyComment(c fiber.Ctx) error {
|
||||
return model.NewRequestError("Content cannot be empty")
|
||||
}
|
||||
|
||||
comment, err := service.CreateComment(req, userID, uint(commentID), c.IP(), model.CommentTypeReply)
|
||||
comment, err := service.CreateComment(req, userID, uint(commentID), c.IP(), model.CommentTypeReply, c.Host())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -105,6 +124,29 @@ func listResourceComments(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func listReplyComments(c fiber.Ctx) error {
|
||||
commentIDStr := c.Params("commentID")
|
||||
commentID, err := strconv.Atoi(commentIDStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid comment ID")
|
||||
}
|
||||
pageStr := c.Query("page", "1")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid page number")
|
||||
}
|
||||
replies, totalPages, err := service.ListCommentReplies(uint(commentID), page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(model.PageResponse[model.CommentView]{
|
||||
Success: true,
|
||||
Data: replies,
|
||||
TotalPages: totalPages,
|
||||
Message: "Replies retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func listCommentsByUser(c fiber.Ctx) error {
|
||||
username := c.Params("username")
|
||||
if username == "" {
|
||||
@@ -151,7 +193,7 @@ func updateComment(c fiber.Ctx) error {
|
||||
return model.NewRequestError("Content cannot be empty")
|
||||
}
|
||||
|
||||
comment, err := service.UpdateComment(uint(commentID), userID, req)
|
||||
comment, err := service.UpdateComment(uint(commentID), userID, req, c.Host())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -6,13 +6,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func CreateComment(content string, userID uint, resourceID uint, imageIDs []uint, cType model.CommentType) (model.Comment, error) {
|
||||
func CreateComment(content string, userID uint, refID uint, imageIDs []uint, cType model.CommentType) (model.Comment, error) {
|
||||
var comment model.Comment
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
comment = model.Comment{
|
||||
Content: content,
|
||||
UserID: userID,
|
||||
RefID: resourceID,
|
||||
RefID: refID,
|
||||
Type: cType,
|
||||
}
|
||||
if err := tx.Create(&comment).Error; err != nil {
|
||||
@@ -36,9 +36,16 @@ func CreateComment(content string, userID uint, resourceID uint, imageIDs []uint
|
||||
return err
|
||||
}
|
||||
|
||||
// Update resource comments count
|
||||
if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).Update("comments", gorm.Expr("comments + 1")).Error; err != nil {
|
||||
return err
|
||||
if cType == model.CommentTypeResource {
|
||||
// Update resource comments count
|
||||
if err := tx.Model(&model.Resource{}).Where("id = ?", refID).Update("comments", gorm.Expr("comments + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if cType == model.CommentTypeReply {
|
||||
// Update reply count for the parent comment
|
||||
if err := tx.Model(&model.Comment{}).Where("id = ?", refID).Update("reply_count", gorm.Expr("reply_count + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -184,3 +191,32 @@ func DeleteCommentByID(commentID uint) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func GetCommentReplies(commentID uint, page, pageSize int) ([]model.Comment, int, error) {
|
||||
var replies []model.Comment
|
||||
var total int64
|
||||
|
||||
if err := db.
|
||||
Model(&model.Comment{}).
|
||||
Where("type = ?", model.CommentTypeReply).
|
||||
Where("ref_id = ?", commentID).
|
||||
Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := db.
|
||||
Model(&model.Comment{}).
|
||||
Where("type = ?", model.CommentTypeReply).
|
||||
Where("ref_id = ?", commentID).
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Preload("User").
|
||||
Order("created_at DESC").
|
||||
Find(&replies).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (int(total) + pageSize - 1) / pageSize
|
||||
|
||||
return replies, totalPages, nil
|
||||
}
|
||||
|
@@ -8,12 +8,13 @@ import (
|
||||
|
||||
type Comment struct {
|
||||
gorm.Model
|
||||
Content string `gorm:"not null"`
|
||||
RefID uint `gorm:"not null;index:idx_refid_type,priority:1"`
|
||||
Type CommentType `gorm:"not null;index:idx_refid_type,priority:2"`
|
||||
UserID uint `gorm:"not null"`
|
||||
User User `gorm:"foreignKey:UserID"`
|
||||
Images []Image `gorm:"many2many:comment_images;"`
|
||||
Content string `gorm:"not null"`
|
||||
RefID uint `gorm:"not null;index:idx_refid_type,priority:1"`
|
||||
Type CommentType `gorm:"not null;index:idx_refid_type,priority:2"`
|
||||
UserID uint `gorm:"not null"`
|
||||
User User `gorm:"foreignKey:UserID"`
|
||||
Images []Image `gorm:"many2many:comment_images;"`
|
||||
ReplyCount uint `gorm:"default:0;not null"`
|
||||
}
|
||||
|
||||
type CommentType uint
|
||||
@@ -24,11 +25,13 @@ const (
|
||||
)
|
||||
|
||||
type CommentView struct {
|
||||
ID uint `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User UserView `json:"user"`
|
||||
Images []ImageView `json:"images"`
|
||||
ID uint `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User UserView `json:"user"`
|
||||
Images []ImageView `json:"images"`
|
||||
ReplyCount uint `json:"reply_count"`
|
||||
ContentTruncated bool `json:"content_truncated"`
|
||||
}
|
||||
|
||||
func (c *Comment) ToView() *CommentView {
|
||||
@@ -38,21 +41,24 @@ func (c *Comment) ToView() *CommentView {
|
||||
}
|
||||
|
||||
return &CommentView{
|
||||
ID: c.ID,
|
||||
Content: c.Content,
|
||||
CreatedAt: c.CreatedAt,
|
||||
User: c.User.ToView(),
|
||||
Images: imageViews,
|
||||
ID: c.ID,
|
||||
Content: c.Content,
|
||||
CreatedAt: c.CreatedAt,
|
||||
User: c.User.ToView(),
|
||||
Images: imageViews,
|
||||
ReplyCount: c.ReplyCount,
|
||||
}
|
||||
}
|
||||
|
||||
type CommentWithResourceView struct {
|
||||
ID uint `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Resource ResourceView `json:"resource"`
|
||||
User UserView `json:"user"`
|
||||
Images []ImageView `json:"images"`
|
||||
ID uint `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Resource ResourceView `json:"resource"`
|
||||
User UserView `json:"user"`
|
||||
Images []ImageView `json:"images"`
|
||||
ReplyCount uint `json:"reply_count"`
|
||||
ContentTruncated bool `json:"content_truncated"`
|
||||
}
|
||||
|
||||
func (c *Comment) ToViewWithResource(r *Resource) *CommentWithResourceView {
|
||||
@@ -62,11 +68,52 @@ func (c *Comment) ToViewWithResource(r *Resource) *CommentWithResourceView {
|
||||
}
|
||||
|
||||
return &CommentWithResourceView{
|
||||
ID: c.ID,
|
||||
Content: c.Content,
|
||||
CreatedAt: c.CreatedAt,
|
||||
Resource: r.ToView(),
|
||||
User: c.User.ToView(),
|
||||
Images: imageViews,
|
||||
ID: c.ID,
|
||||
Content: c.Content,
|
||||
CreatedAt: c.CreatedAt,
|
||||
Resource: r.ToView(),
|
||||
User: c.User.ToView(),
|
||||
Images: imageViews,
|
||||
ReplyCount: c.ReplyCount,
|
||||
}
|
||||
}
|
||||
|
||||
type CommentWithRefView struct {
|
||||
ID uint `json:"id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User UserView `json:"user"`
|
||||
Images []ImageView `json:"images"`
|
||||
ReplyCount uint `json:"reply_count"`
|
||||
Resource *ResourceView `json:"resource,omitempty"`
|
||||
ReplyTo *CommentView `json:"reply_to,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Comment) ToViewWithRef(r *Resource, replyTo *Comment) *CommentWithRefView {
|
||||
imageViews := make([]ImageView, 0, len(c.Images))
|
||||
for _, img := range c.Images {
|
||||
imageViews = append(imageViews, img.ToView())
|
||||
}
|
||||
|
||||
var replyToView *CommentView
|
||||
if replyTo != nil {
|
||||
replyToView = replyTo.ToView()
|
||||
}
|
||||
|
||||
var rView *ResourceView
|
||||
if r != nil {
|
||||
v := r.ToView()
|
||||
rView = &v
|
||||
}
|
||||
|
||||
return &CommentWithRefView{
|
||||
ID: c.ID,
|
||||
Content: c.Content,
|
||||
CreatedAt: c.CreatedAt,
|
||||
User: c.User.ToView(),
|
||||
Images: imageViews,
|
||||
ReplyCount: c.ReplyCount,
|
||||
Resource: rView,
|
||||
ReplyTo: replyToView,
|
||||
}
|
||||
}
|
||||
|
@@ -4,15 +4,19 @@ import (
|
||||
"nysoure/server/dao"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/utils"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3/log"
|
||||
)
|
||||
|
||||
const (
|
||||
maxImagePerComment = 9
|
||||
maxCommentsPerIP = 512 // Maximum number of comments allowed per IP address per day
|
||||
maxCommentLength = 1024 // Maximum length of a comment
|
||||
maxCommentsPerIP = 512 // Maximum number of comments allowed per IP address per day
|
||||
maxCommentLength = 2048 // Maximum length of a comment
|
||||
maxCommentBriefLines = 16 // Maximum number of lines in a comment brief
|
||||
maxCommentBriefLength = 256 // Maximum length of a comment brief
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -20,11 +24,44 @@ var (
|
||||
)
|
||||
|
||||
type CommentRequest struct {
|
||||
Content string `json:"content"`
|
||||
Images []uint `json:"images"`
|
||||
Content string `json:"content"` // markdown
|
||||
// Images []uint `json:"images"` // Unrequired after new design
|
||||
}
|
||||
|
||||
func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType model.CommentType) (*model.CommentView, error) {
|
||||
func findImagesInContent(content string, host string) []uint {
|
||||
// Handle both absolute and relative URLs
|
||||
absolutePattern := `!\[.*?\]\((?:https?://` + host + `)?/api/image/(\d+)(?:\s+["'].*?["'])?\)`
|
||||
relativePattern := `!\[.*?\]\(/api/image/(\d+)(?:\s+["'].*?["'])?\)`
|
||||
|
||||
// Combine patterns and compile regex
|
||||
patterns := []string{absolutePattern, relativePattern}
|
||||
|
||||
// Store unique image IDs to avoid duplicates
|
||||
imageIDs := make(map[uint]struct{})
|
||||
|
||||
for _, pattern := range patterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(match) >= 2 {
|
||||
if id, err := strconv.ParseUint(match[1], 10, 32); err == nil {
|
||||
imageIDs[uint(id)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map keys to slice
|
||||
result := make([]uint, 0, len(imageIDs))
|
||||
for id := range imageIDs {
|
||||
result = append(result, id)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType model.CommentType, host string) (*model.CommentView, error) {
|
||||
if !commentsLimiter.AllowRequest(ip) {
|
||||
log.Warnf("IP %s has exceeded the comment limit of %d comments per day", ip, maxCommentsPerIP)
|
||||
return nil, model.NewRequestError("Too many comments from this IP address, please try again later")
|
||||
@@ -37,9 +74,6 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
|
||||
return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters")
|
||||
}
|
||||
|
||||
if len(req.Images) > maxImagePerComment {
|
||||
return nil, model.NewRequestError("Too many images, maximum is 9")
|
||||
}
|
||||
resourceExists, err := dao.ExistsResource(refID)
|
||||
if err != nil {
|
||||
log.Error("Error checking resource existence:", err)
|
||||
@@ -56,7 +90,10 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
|
||||
if !userExists {
|
||||
return nil, model.NewNotFoundError("User not found")
|
||||
}
|
||||
c, err := dao.CreateComment(req.Content, userID, refID, req.Images, cType)
|
||||
|
||||
images := findImagesInContent(req.Content, host)
|
||||
|
||||
c, err := dao.CreateComment(req.Content, userID, refID, images, cType)
|
||||
if err != nil {
|
||||
log.Error("Error creating comment:", err)
|
||||
return nil, model.NewInternalServerError("Error creating comment")
|
||||
@@ -68,6 +105,41 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
|
||||
return c.ToView(), nil
|
||||
}
|
||||
|
||||
func restrictCommentLength(content string) (c string, truncated bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
lineCount := 0
|
||||
for i, line := range lines {
|
||||
reg := regexp.MustCompile(`!\[.*?\]\(.*?\)`)
|
||||
if reg.MatchString(line) {
|
||||
// Count the line with image as 5 lines
|
||||
lineCount += 5
|
||||
} else {
|
||||
lineCount++
|
||||
}
|
||||
|
||||
if lineCount > maxCommentBriefLines {
|
||||
lines = lines[:i+1] // Keep the current line
|
||||
content = strings.Join(lines, "\n")
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len([]rune(content)) > maxCommentBriefLength {
|
||||
i := len(lines) - 1
|
||||
for count := len([]rune(content)); count > maxCommentBriefLength && i > 0; i-- {
|
||||
count -= len([]rune(lines[i]))
|
||||
}
|
||||
if i == 0 && len([]rune(lines[0])) > maxCommentBriefLength {
|
||||
content = string([]rune(lines[0])[:maxCommentBriefLength])
|
||||
} else {
|
||||
content = strings.Join(lines[:i+1], "\n")
|
||||
}
|
||||
truncated = true
|
||||
}
|
||||
return content, truncated
|
||||
}
|
||||
|
||||
func ListResourceComments(resourceID uint, page int) ([]model.CommentView, int, error) {
|
||||
resourceExists, err := dao.ExistsResource(resourceID)
|
||||
if err != nil {
|
||||
@@ -84,7 +156,37 @@ func ListResourceComments(resourceID uint, page int) ([]model.CommentView, int,
|
||||
}
|
||||
res := make([]model.CommentView, 0, len(comments))
|
||||
for _, c := range comments {
|
||||
res = append(res, *c.ToView())
|
||||
v := *c.ToView()
|
||||
var truncated bool
|
||||
v.Content, truncated = restrictCommentLength(v.Content)
|
||||
v.ContentTruncated = truncated
|
||||
res = append(res, v)
|
||||
}
|
||||
return res, totalPages, nil
|
||||
}
|
||||
|
||||
func ListCommentReplies(commentID uint, page int) ([]model.CommentView, int, error) {
|
||||
comment, err := dao.GetCommentByID(commentID)
|
||||
if err != nil {
|
||||
log.Error("Error getting comment:", err)
|
||||
return nil, 0, model.NewNotFoundError("Comment not found")
|
||||
}
|
||||
if comment.Type != model.CommentTypeReply {
|
||||
return nil, 0, model.NewRequestError("This comment is not a reply")
|
||||
}
|
||||
|
||||
replies, totalPages, err := dao.GetCommentReplies(commentID, page, pageSize)
|
||||
if err != nil {
|
||||
log.Error("Error getting replies:", err)
|
||||
return nil, 0, model.NewInternalServerError("Error getting replies")
|
||||
}
|
||||
res := make([]model.CommentView, 0, len(replies))
|
||||
for _, r := range replies {
|
||||
v := *r.ToView()
|
||||
var truncated bool
|
||||
v.Content, truncated = restrictCommentLength(v.Content)
|
||||
v.ContentTruncated = truncated
|
||||
res = append(res, v)
|
||||
}
|
||||
return res, totalPages, nil
|
||||
}
|
||||
@@ -102,12 +204,16 @@ func ListCommentsWithUser(username string, page int) ([]model.CommentWithResourc
|
||||
log.Error("Error getting resource for comment:", err)
|
||||
return nil, 0, model.NewInternalServerError("Error getting resource for comment")
|
||||
}
|
||||
res = append(res, *c.ToViewWithResource(&r))
|
||||
v := *c.ToViewWithResource(&r)
|
||||
var truncated bool
|
||||
v.Content, truncated = restrictCommentLength(v.Content)
|
||||
v.ContentTruncated = truncated
|
||||
res = append(res, v)
|
||||
}
|
||||
return res, totalPages, nil
|
||||
}
|
||||
|
||||
func UpdateComment(commentID, userID uint, req CommentRequest) (*model.CommentView, error) {
|
||||
func UpdateComment(commentID, userID uint, req CommentRequest, host string) (*model.CommentView, error) {
|
||||
if len(req.Content) == 0 {
|
||||
return nil, model.NewRequestError("Content cannot be empty")
|
||||
}
|
||||
@@ -115,17 +221,22 @@ func UpdateComment(commentID, userID uint, req CommentRequest) (*model.CommentVi
|
||||
return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters")
|
||||
}
|
||||
|
||||
if len(req.Images) > maxImagePerComment {
|
||||
return nil, model.NewRequestError("Too many images, maximum is 9")
|
||||
}
|
||||
comment, err := dao.GetCommentByID(commentID)
|
||||
if err != nil {
|
||||
return nil, model.NewNotFoundError("Comment not found")
|
||||
}
|
||||
if comment.UserID != userID {
|
||||
return nil, model.NewRequestError("You can only update your own comments")
|
||||
isAdmin, err := CheckUserIsAdmin(userID)
|
||||
if err != nil {
|
||||
log.Error("Error checking if user is admin:", err)
|
||||
return nil, model.NewInternalServerError("Error checking user permissions")
|
||||
}
|
||||
if !isAdmin {
|
||||
return nil, model.NewUnAuthorizedError("You can only update your own comments")
|
||||
}
|
||||
}
|
||||
updated, err := dao.UpdateCommentContent(commentID, req.Content, req.Images)
|
||||
images := findImagesInContent(req.Content, host)
|
||||
updated, err := dao.UpdateCommentContent(commentID, req.Content, images)
|
||||
if err != nil {
|
||||
return nil, model.NewInternalServerError("Error updating comment")
|
||||
}
|
||||
@@ -145,3 +256,31 @@ func DeleteComment(commentID, userID uint) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetCommentByID(commentID uint) (*model.CommentWithRefView, error) {
|
||||
comment, err := dao.GetCommentByID(commentID)
|
||||
if err != nil {
|
||||
return nil, model.NewNotFoundError("Comment not found")
|
||||
}
|
||||
|
||||
var resource *model.Resource
|
||||
var replyTo *model.Comment
|
||||
if comment.Type == model.CommentTypeResource {
|
||||
r, err := dao.GetResourceByID(comment.RefID)
|
||||
if err != nil {
|
||||
log.Error("Error getting resource for comment:", err)
|
||||
return nil, model.NewInternalServerError("Error getting resource for comment")
|
||||
}
|
||||
resource = &r
|
||||
} else {
|
||||
reply, err := dao.GetCommentByID(comment.RefID)
|
||||
if err != nil {
|
||||
log.Error("Error getting reply for comment:", err)
|
||||
return nil, model.NewInternalServerError("Error getting reply for comment")
|
||||
}
|
||||
replyTo = reply
|
||||
}
|
||||
|
||||
return comment.ToViewWithRef(resource, replyTo), nil
|
||||
|
||||
}
|
||||
|
Reference in New Issue
Block a user