BREAKING CHANGE: The original Go module name was `github.com/ArtalkJS/Artalk`, which has now been modified to `github.com/artalkjs/artalk/v2` (all lowercase). This change may affect the CI pipeline or other areas if you are compiling the Artalk server and have specified the fixed Go module name. Regarding the original `-ldflags -X github.com/ArtalkJS/Artalk/internal/config.Version` and `CommitHash` definitions, please remove them, as they are now obsolete. The `Version` has been hardcoded into the Go code, and the `CommitHash` is now read from the Go build info via runtime/debug’s vcs.revision value. This change only affects the server developers and does not impact the frontend UI client.
273 lines
8.9 KiB
Go
273 lines
8.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"cmp"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/artalkjs/artalk/v2/internal/core"
|
|
"github.com/artalkjs/artalk/v2/internal/entity"
|
|
"github.com/artalkjs/artalk/v2/internal/i18n"
|
|
"github.com/artalkjs/artalk/v2/internal/log"
|
|
"github.com/artalkjs/artalk/v2/internal/utils"
|
|
"github.com/artalkjs/artalk/v2/server/common"
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type ParamsCommentCreate struct {
|
|
Name string `json:"name" validate:"required"` // The comment name
|
|
Email string `json:"email" validate:"required"` // The comment email
|
|
Link string `json:"link" validate:"optional"` // The comment link
|
|
Content string `json:"content" validate:"required"` // The comment content
|
|
Rid uint `json:"rid" validate:"optional"` // The comment rid
|
|
UA string `json:"ua" validate:"optional"` // The comment ua
|
|
|
|
PageKey string `json:"page_key" validate:"required"` // The comment page_key
|
|
PageTitle string `json:"page_title" validate:"optional"` // The comment page_title
|
|
|
|
SiteName string `json:"site_name" validate:"required"` // The site name of your content scope
|
|
}
|
|
|
|
type ResponseCommentCreate struct {
|
|
entity.CookedComment
|
|
}
|
|
|
|
// @Id CreateComment
|
|
// @Summary Create Comment
|
|
// @Description Create a new comment
|
|
// @Tags Comment
|
|
// @Param comment body ParamsCommentCreate true "The comment data"
|
|
// @Security ApiKeyAuth
|
|
// @Success 200 {object} ResponseCommentCreate
|
|
// @Failure 400 {object} Map{msg=string}
|
|
// @Failure 500 {object} Map{msg=string}
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Router /comments [post]
|
|
func CommentCreate(app *core.App, router fiber.Router) {
|
|
router.Post("/comments", common.LimiterGuard(app, func(c *fiber.Ctx) error {
|
|
var p ParamsCommentCreate
|
|
if isOK, resp := common.ParamsDecode(c, &p); !isOK {
|
|
return resp
|
|
}
|
|
|
|
if !utils.ValidateEmail(p.Email) {
|
|
return common.RespError(c, 400, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Email")}))
|
|
}
|
|
if p.Link != "" && !utils.ValidateURL(p.Link) {
|
|
return common.RespError(c, 400, i18n.T("Invalid {{name}}", Map{"name": i18n.T("Link")}))
|
|
}
|
|
|
|
if _, ok, resp := common.CheckSiteExist(app, c, p.SiteName); !ok {
|
|
return resp
|
|
}
|
|
|
|
// Prepare the arguments for creating comment
|
|
var (
|
|
ip = c.IP()
|
|
ua = cmp.Or(p.UA, string(c.Request().Header.UserAgent())) // allows the patched UA from the post data
|
|
referer = cmp.Or(c.Get("Referer"), c.Get("Origin"))
|
|
isAdmin = common.CheckIsAdminReq(app, c)
|
|
isVerified = true // for display the verified badge
|
|
)
|
|
|
|
// Find or create page
|
|
page := app.Dao().FindCreatePage(p.PageKey, p.PageTitle, p.SiteName)
|
|
if page.Key == "" {
|
|
log.Error("[CommentCreate] FindCreatePage error")
|
|
return common.RespError(c, 500, i18n.T("Comment failed"))
|
|
}
|
|
|
|
// Check the page and the user is allowed to comment (admin only check)
|
|
if isAllowed, resp := isAllowComment(app, c, p.Name, p.Email, page.AdminOnly); !isAllowed {
|
|
return resp
|
|
}
|
|
|
|
// Check parent comment (reply a comment)
|
|
var parentComment entity.Comment
|
|
if p.Rid != 0 {
|
|
parentComment = app.Dao().FindComment(p.Rid)
|
|
if parentComment.IsEmpty() {
|
|
return common.RespError(c, 404, i18n.T("{{name}} not found", Map{"name": i18n.T("Parent comment")}))
|
|
}
|
|
if parentComment.PageKey != p.PageKey {
|
|
return common.RespError(c, 400, "Inconsistent with the page_key of the parent comment")
|
|
}
|
|
if !parentComment.IsAllowReply() {
|
|
return common.RespError(c, 400, i18n.T("Cannot reply to this comment"))
|
|
}
|
|
}
|
|
|
|
// Get the user data
|
|
user, err := common.GetUserByReq(app, c) // if token is provided and a login user
|
|
if errors.Is(err, common.ErrTokenNotProvided) {
|
|
// Anonymous user
|
|
isVerified = false
|
|
if user, err = getUpdateAnonymousUser(app, p.Name, p.Email, p.Link, ip, ua); err != nil {
|
|
return common.RespError(c, 500, err.Error())
|
|
}
|
|
} else if err != nil {
|
|
// Login user error
|
|
log.Error("[CommentCreate] Get user error: ", err)
|
|
return common.RespError(c, 500, i18n.T("Comment failed"))
|
|
}
|
|
|
|
// Create new comment entity
|
|
comment := entity.Comment{
|
|
Content: p.Content,
|
|
PageKey: page.Key,
|
|
SiteName: p.SiteName,
|
|
|
|
UserID: user.ID,
|
|
IP: ip,
|
|
UA: ua,
|
|
|
|
Rid: p.Rid,
|
|
RootID: app.Dao().FindCommentRootID(p.Rid),
|
|
|
|
IsPending: false,
|
|
IsCollapsed: false,
|
|
IsPinned: false,
|
|
IsVerified: isVerified,
|
|
}
|
|
|
|
// Set the default pending status
|
|
// (if not admin and the `PendingDefault` is enabled)
|
|
if !isAdmin && app.Conf().Moderator.PendingDefault {
|
|
comment.IsPending = true
|
|
}
|
|
|
|
// Save the comment
|
|
if err := app.Dao().CreateComment(&comment); err != nil {
|
|
log.Error("Save Comment error: ", err)
|
|
return common.RespError(c, 500, i18n.T("Comment failed"))
|
|
}
|
|
|
|
// Async jobs after comment created
|
|
go commentCreatedJobs(app, comment, parentComment, commentCreatedJobsArguments{
|
|
ip, ua, referer, isAdmin, isVerified, page,
|
|
})
|
|
|
|
// Response the comment data
|
|
cookedComment := app.Dao().CookComment(&comment)
|
|
cookedComment = fetchIPRegionForComment(app, cookedComment)
|
|
|
|
return common.RespData(c, ResponseCommentCreate{
|
|
CookedComment: cookedComment,
|
|
})
|
|
}))
|
|
}
|
|
|
|
// Fetch IP Region for Comment
|
|
func fetchIPRegionForComment(app *core.App, comment entity.CookedComment) entity.CookedComment {
|
|
if app.Conf().IPRegion.Enabled {
|
|
if ipRegionService, err := core.AppService[*core.IPRegionService](app); err == nil {
|
|
comment.IPRegion = ipRegionService.Query(comment.IP)
|
|
} else {
|
|
log.Error("[IPRegionService] err: ", err)
|
|
}
|
|
}
|
|
return comment
|
|
}
|
|
|
|
func isAllowComment(app *core.App, c *fiber.Ctx, name string, email string, pageAdminOnly bool) (bool, error) {
|
|
// if the user is an admin user or page is admin only
|
|
isAdminUser := app.Dao().IsAdminUserByNameEmail(name, email)
|
|
if isAdminUser || pageAdminOnly {
|
|
// then check has admin access
|
|
if !common.CheckIsAdminReq(app, c) {
|
|
return false, common.RespError(c, 403, i18n.T("Admin access required"), Map{"need_login": true})
|
|
}
|
|
}
|
|
|
|
// if token is provided, then check token is valid
|
|
user, err := common.GetUserByReq(app, c)
|
|
if !errors.Is(err, common.ErrTokenNotProvided) && user.IsEmpty() {
|
|
// need_auth_login is a hook for frontend to show login modal (new Auth api)
|
|
return false, common.RespError(c, 401, i18n.T("Login required"), Map{"need_auth_login": true})
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// Get and update anonymous user
|
|
//
|
|
// Call this function will create or find existing user, and update the user profile from the parameters.
|
|
// If auth is enabled, but anonymous is disabled, it will return an error.
|
|
// The column `link` will be updated only when social login is disabled. (GitHub issue #921)
|
|
func getUpdateAnonymousUser(app *core.App, name string, email string, link string, ip string, ua string) (entity.User, error) {
|
|
// Check anonymous user is allowed
|
|
if app.Conf().Auth.Enabled && !app.Conf().Auth.Anonymous {
|
|
return entity.User{}, fmt.Errorf("anonymous user is not allowed")
|
|
}
|
|
|
|
// Create or find existing user
|
|
user, err := app.Dao().FindCreateUser(name, email, link)
|
|
if err != nil {
|
|
log.Error("[CommentCreate] Create user error: ", err)
|
|
return entity.User{}, fmt.Errorf("anonymous user create error")
|
|
}
|
|
|
|
// Update anonymous user profile
|
|
// (only works when social login is disabled, @see GitHub issue #921)
|
|
if !app.Conf().Auth.Enabled {
|
|
user.Link = link
|
|
user.LastIP = ip
|
|
user.LastUA = ua
|
|
|
|
// for modify the case of name but with the same name
|
|
user.Name = name
|
|
user.Email = email
|
|
|
|
app.Dao().UpdateUser(&user)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
type commentCreatedJobsArguments struct {
|
|
IP string
|
|
UA string
|
|
Referer string
|
|
IsAdmin bool
|
|
IsVerified bool
|
|
Page entity.Page
|
|
}
|
|
|
|
// The jobs after comment created (should call in async)
|
|
//
|
|
// It will do the following jobs:
|
|
// 1. Page Update
|
|
// 2. AntiSpam Check
|
|
// 3. Send Notify (email, webhook, telegram, etc.)
|
|
func commentCreatedJobs(app *core.App, comment entity.Comment, parentComment entity.Comment, args commentCreatedJobsArguments) {
|
|
// Page Update (if the original page title is empty and the URL is given)
|
|
if app.Dao().CookPage(&args.Page).URL != "" && args.Page.Title == "" {
|
|
go app.Dao().FetchPageFromURL(&args.Page)
|
|
}
|
|
|
|
// AntiSpam Check
|
|
if !args.IsAdmin { // if the user is an admin, skip the anti-spam check
|
|
if antiSpamService, err := core.AppService[*core.AntiSpamService](app); err == nil {
|
|
// The check and block is sync, if comment is blocked, the message will not be sent
|
|
antiSpamService.CheckAndBlock(&core.AntiSpamCheckPayload{
|
|
Comment: &comment,
|
|
ReqReferer: args.Referer,
|
|
ReqIP: args.IP,
|
|
ReqUserAgent: args.UA,
|
|
})
|
|
} else {
|
|
log.Error("[AntiSpamService] err: ", err)
|
|
}
|
|
}
|
|
|
|
// Send Notify (email, webhook, telegram, etc.)
|
|
if notifyService, err := core.AppService[*core.NotifyService](app); err == nil {
|
|
if err := notifyService.Push(&comment, &parentComment); err != nil {
|
|
log.Error("[NotifyService] notify push err: ", err)
|
|
}
|
|
} else {
|
|
log.Error("[NotifyService] err: ", err)
|
|
}
|
|
}
|