Merge remote-tracking branch 'origin/fix/2.0.1/chat' into test

This commit is contained in:
LinkinStars
2026-05-11 12:16:58 +08:00
17 changed files with 386 additions and 82 deletions
+71 -3
View File
@@ -211,9 +211,27 @@ func createMainGoFile(b *buildingMaterial) (err error) {
// downloadGoModFile run go mod commands to download dependencies
func downloadGoModFile(b *buildingMaterial) (err error) {
// If user specify a module replacement, use it. Otherwise, use the latest version.
if len(b.answerModuleReplacement) > 0 {
replacement := fmt.Sprintf("%s=%s", "github.com/apache/answer", b.answerModuleReplacement)
answerReplacement := b.answerModuleReplacement
// If no replacement specified and current binary is v2+, auto-determine replacement.
// This is needed because go mod tidy would otherwise resolve github.com/apache/answer
// to the latest v1.x version, causing v2+ features (e.g. AI/MCP) to disappear.
if len(answerReplacement) == 0 && b.originalAnswerInfo.Version != "" {
ver, verErr := semver.NewVersion(strings.TrimPrefix(b.originalAnswerInfo.Version, "v"))
if verErr == nil && ver.Major() >= 2 {
answerReplacement = fmt.Sprintf("github.com/apache/answer@%s", b.originalAnswerInfo.Version)
}
}
if len(answerReplacement) > 0 {
// For v2+ versioned module paths (e.g. github.com/apache/answer@v2.0.0),
// go mod tidy rejects the version because the module path lacks a /v2 suffix.
// Work around this by cloning the repo locally and using a local path replacement.
localPath, resolveErr := resolveAnswerModuleReplacement(answerReplacement, b.tmpDir)
if resolveErr != nil {
return resolveErr
}
replacement := fmt.Sprintf("%s=%s", "github.com/apache/answer", localPath)
err = b.newExecCmd("go", "mod", "edit", "-replace", replacement).Run()
if err != nil {
return err
@@ -232,6 +250,56 @@ func downloadGoModFile(b *buildingMaterial) (err error) {
return
}
// resolveAnswerModuleReplacement resolves the ANSWER_MODULE value to a usable local path or
// remote replacement string. For v2+ versioned module paths (e.g. github.com/apache/answer@v2.0.0),
// Go module system rejects the version because the module path has no /v2 suffix. In that case
// the repository is cloned locally and the local path is returned instead.
func resolveAnswerModuleReplacement(replacement, tmpDir string) (string, error) {
// Local paths can be used as-is.
if strings.HasPrefix(replacement, "/") || strings.HasPrefix(replacement, "./") || strings.HasPrefix(replacement, "../") {
return replacement, nil
}
// Parse module@version format.
moduleName, version, hasVersion := strings.Cut(replacement, "@")
if !hasVersion {
return replacement, nil
}
// Only handle v2+ versions on module paths without the /vN suffix.
ver, err := semver.StrictNewVersion(strings.TrimPrefix(version, "v"))
if err != nil || ver.Major() < 2 {
return replacement, nil
}
if strings.HasSuffix(moduleName, fmt.Sprintf("/v%d", ver.Major())) {
return replacement, nil
}
// Clone the repo to a local directory and return its path.
gitURL := "https://" + moduleName
tag := "v" + strings.TrimPrefix(version, "v")
localPath := filepath.Join(filepath.Dir(tmpDir), fmt.Sprintf("answer_src_%s", strings.ReplaceAll(version, ".", "_")))
if _, statErr := os.Stat(localPath); statErr == nil {
fmt.Printf("[build] using cached local clone at %s\n", localPath)
return localPath, nil
}
fmt.Printf("[build] v2+ module detected, cloning %s@%s to local path %s...\n", moduleName, version, localPath)
cloneCmd := exec.Command("git", "clone", "--depth=1", "--branch="+tag, gitURL, localPath)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
if err = cloneCmd.Run(); err != nil {
return "", fmt.Errorf(
"failed to clone %s@%s: %w\nTip: set ANSWER_MODULE to a local checkout path instead, e.g. ANSWER_MODULE=/path/to/answer",
moduleName, version, err,
)
}
fmt.Printf("[build] successfully cloned to %s\n", localPath)
return localPath, nil
}
// movePluginToVendor move plugin to vendor dir
// Traverse the plugins, and if the plugin path is not github.com/apache/answer-plugins, move the contents of the current plugin to the vendor/github.com/apache/answer-plugins/ directory.
func movePluginToVendor(b *buildingMaterial) (err error) {
+5 -3
View File
@@ -164,8 +164,9 @@ func (ac *AnswerController) GetAnswerInfo(ctx *gin.Context) {
id := ctx.Query("id")
id = uid.DeShortID(id)
userID := middleware.GetLoginUserIDFromContext(ctx)
isAdminModerator := middleware.GetUserIsAdminModerator(ctx)
info, questionInfo, has, err := ac.answerService.Get(ctx, id, userID)
info, questionInfo, has, err := ac.answerService.Get(ctx, id, userID, isAdminModerator)
if err != nil {
handler.HandleResponse(ctx, err, gin.H{})
return
@@ -271,7 +272,7 @@ func (ac *AnswerController) AddAnswer(ctx *gin.Context) {
if !isAdmin || !linkUrlLimitUser {
ac.actionService.ActionRecordAdd(ctx, entity.CaptchaActionAnswer, req.UserID)
}
info, questionInfo, has, err := ac.answerService.Get(ctx, answerID, req.UserID)
info, questionInfo, has, err := ac.answerService.Get(ctx, answerID, req.UserID, isAdmin)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
@@ -348,7 +349,7 @@ func (ac *AnswerController) UpdateAnswer(ctx *gin.Context) {
if !isAdmin || !linkUrlLimitUser {
ac.actionService.ActionRecordAdd(ctx, entity.CaptchaActionEdit, req.UserID)
}
_, _, _, err = ac.answerService.Get(ctx, req.ID, req.UserID)
_, _, _, err = ac.answerService.Get(ctx, req.ID, req.UserID, isAdmin)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
@@ -376,6 +377,7 @@ func (ac *AnswerController) AnswerList(ctx *gin.Context) {
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.QuestionID = uid.DeShortID(req.QuestionID)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := ac.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.AnswerEdit,
@@ -248,6 +248,7 @@ func (cc *CommentController) GetCommentWithPage(ctx *gin.Context) {
req.ObjectID = uid.DeShortID(req.ObjectID)
req.CommentID = uid.DeShortID(req.CommentID)
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentEdit,
permission.CommentDelete,
@@ -300,6 +301,7 @@ func (cc *CommentController) GetComment(ctx *gin.Context) {
}
req.UserID = middleware.GetLoginUserIDFromContext(ctx)
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := cc.rankService.CheckOperationPermissions(ctx, req.UserID, []string{
permission.CommentEdit,
permission.CommentDelete,
+2 -1
View File
@@ -234,6 +234,7 @@ func (qc *QuestionController) GetQuestion(ctx *gin.Context) {
id = uid.DeShortID(id)
userID := middleware.GetLoginUserIDFromContext(ctx)
req := schema.QuestionPermission{}
req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx)
canList, err := qc.rankService.CheckOperationPermissions(ctx, userID, []string{
permission.QuestionEdit,
permission.QuestionDelete,
@@ -590,7 +591,7 @@ func (qc *QuestionController) AddQuestionByAnswer(ctx *gin.Context) {
handler.HandleResponse(ctx, err, nil)
return
}
info, questionInfo, has, err := qc.answerService.Get(ctx, answerID, req.UserID)
info, questionInfo, has, err := qc.answerService.Get(ctx, answerID, req.UserID, isAdmin)
if err != nil {
handler.HandleResponse(ctx, err, nil)
return
+10 -9
View File
@@ -106,15 +106,16 @@ type AnswerUpdateResp struct {
}
type AnswerListReq struct {
QuestionID string `json:"question_id" form:"question_id"`
Order string `json:"order" form:"order"`
Page int `json:"page" form:"page"`
PageSize int `json:"page_size" form:"page_size"`
UserID string `json:"-"`
IsAdmin bool `json:"-"`
CanEdit bool `json:"-"`
CanDelete bool `json:"-"`
CanRecover bool `json:"-"`
QuestionID string `json:"question_id" form:"question_id"`
Order string `json:"order" form:"order"`
Page int `json:"page" form:"page"`
PageSize int `json:"page_size" form:"page_size"`
UserID string `json:"-"`
IsAdmin bool `json:"-"`
IsAdminModerator bool `json:"-"`
CanEdit bool `json:"-"`
CanDelete bool `json:"-"`
CanRecover bool `json:"-"`
}
type AnswerInfo struct {
+4 -2
View File
@@ -150,7 +150,8 @@ type GetCommentWithPageReq struct {
// query condition
QueryCond string `validate:"omitempty,oneof=vote created_at" form:"query_cond"`
// user id
UserID string `json:"-"`
UserID string `json:"-"`
IsAdminModerator bool `json:"-"`
// whether user can edit it
CanEdit bool `json:"-"`
// whether user can delete it
@@ -162,7 +163,8 @@ type GetCommentReq struct {
// object id
ID string `validate:"required" form:"id"`
// user id
UserID string `json:"-"`
UserID string `json:"-"`
IsAdminModerator bool `json:"-"`
// whether user can edit it
CanEdit bool `json:"-"`
// whether user can delete it
+1
View File
@@ -143,6 +143,7 @@ func (req *QuestionAddByAnswer) Check() (errFields []*validator.FormErrorField,
}
type QuestionPermission struct {
IsAdminModerator bool `json:"-"`
// whether user can add it
CanAdd bool `json:"-"`
// whether user can edit it
+99 -14
View File
@@ -21,25 +21,28 @@ package schema
import (
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/base/reason"
"github.com/apache/answer/internal/entity"
"github.com/segmentfault/pacman/errors"
)
// SimpleObjectInfo simple object info
type SimpleObjectInfo struct {
ObjectID string `json:"object_id"`
ObjectCreatorUserID string `json:"object_creator_user_id"`
QuestionID string `json:"question_id"`
QuestionStatus int `json:"question_status"`
QuestionShow int `json:"question_show"`
AnswerID string `json:"answer_id"`
AnswerStatus int `json:"answer_status"`
CommentID string `json:"comment_id"`
CommentStatus int `json:"comment_status"`
TagID string `json:"tag_id"`
TagStatus int `json:"tag_status"`
ObjectType string `json:"object_type"`
Title string `json:"title"`
Content string `json:"content"`
ObjectID string `json:"object_id"`
ObjectCreatorUserID string `json:"object_creator_user_id"`
QuestionID string `json:"question_id"`
QuestionCreatorUserID string `json:"question_creator_user_id"`
QuestionStatus int `json:"question_status"`
QuestionShow int `json:"question_show"`
AnswerID string `json:"answer_id"`
AnswerStatus int `json:"answer_status"`
CommentID string `json:"comment_id"`
CommentStatus int `json:"comment_status"`
TagID string `json:"tag_id"`
TagStatus int `json:"tag_status"`
ObjectType string `json:"object_type"`
Title string `json:"title"`
Content string `json:"content"`
}
// IsDeleted is deleted
@@ -57,6 +60,88 @@ func (s *SimpleObjectInfo) IsDeleted() bool {
return false
}
func (s *SimpleObjectInfo) CheckVisibility(userID string, isAdminModerator bool) error {
if s == nil {
return errors.NotFound(reason.ObjectNotFound)
}
if s.isObjectRestricted() && !s.canViewObject(userID, isAdminModerator) {
return errors.NotFound(s.objectNotFoundReason())
}
if s.hasParentQuestion() && s.isParentQuestionRestricted() &&
!s.canViewParentQuestion(userID, isAdminModerator) {
return errors.NotFound(reason.QuestionNotFound)
}
return nil
}
func (s *SimpleObjectInfo) canViewObject(userID string, isAdminModerator bool) bool {
if isAdminModerator {
return true
}
switch s.ObjectType {
case constant.QuestionObjectType:
return s.QuestionCreatorUserID == userID
case constant.AnswerObjectType, constant.CommentObjectType, constant.TagObjectType:
return s.ObjectCreatorUserID == userID
default:
return false
}
}
func (s *SimpleObjectInfo) canViewParentQuestion(userID string, isAdminModerator bool) bool {
if isAdminModerator {
return true
}
return s.QuestionCreatorUserID == userID
}
func (s *SimpleObjectInfo) hasParentQuestion() bool {
switch s.ObjectType {
case constant.AnswerObjectType, constant.CommentObjectType:
return len(s.QuestionID) > 0 && s.QuestionID != "0"
default:
return false
}
}
func (s *SimpleObjectInfo) isObjectRestricted() bool {
switch s.ObjectType {
case constant.QuestionObjectType:
return s.QuestionStatus == entity.QuestionStatusDeleted ||
s.QuestionStatus == entity.QuestionStatusPending ||
s.QuestionShow == entity.QuestionHide
case constant.AnswerObjectType:
return s.AnswerStatus == entity.AnswerStatusDeleted || s.AnswerStatus == entity.AnswerStatusPending
case constant.CommentObjectType:
return s.CommentStatus == entity.CommentStatusDeleted || s.CommentStatus == entity.CommentStatusPending
case constant.TagObjectType:
return s.TagStatus == entity.TagStatusDeleted
default:
return false
}
}
func (s *SimpleObjectInfo) isParentQuestionRestricted() bool {
return s.QuestionStatus == entity.QuestionStatusDeleted ||
s.QuestionStatus == entity.QuestionStatusPending ||
s.QuestionShow == entity.QuestionHide
}
func (s *SimpleObjectInfo) objectNotFoundReason() string {
switch s.ObjectType {
case constant.QuestionObjectType:
return reason.QuestionNotFound
case constant.AnswerObjectType:
return reason.AnswerNotFound
case constant.CommentObjectType:
return reason.CommentNotFound
case constant.TagObjectType:
return reason.TagNotFound
default:
return reason.ObjectNotFound
}
}
type UnreviewedRevisionInfoInfo struct {
CreatedAt int64 `json:"created_at"`
ObjectID string `json:"object_id"`
@@ -348,6 +348,13 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment
if !exist {
return nil, errors.BadRequest(reason.CommentNotFound)
}
objInfo, err := cs.objectInfoService.GetInfo(ctx, comment.ObjectID)
if err != nil {
return nil, err
}
if err := objInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
return nil, err
}
resp = &schema.GetCommentResp{
CommentID: comment.ID,
@@ -399,6 +406,13 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment
// GetCommentWithPage get comment list page
func (cs *CommentService) GetCommentWithPage(ctx context.Context, req *schema.GetCommentWithPageReq) (
pageModel *pager.PageModel, err error) {
objInfo, err := cs.objectInfoService.GetInfo(ctx, req.ObjectID)
if err != nil {
return nil, err
}
if err := objInfo.CheckVisibility(req.UserID, req.IsAdminModerator); err != nil {
return nil, err
}
dto := &CommentQuery{
PageCond: pager.PageCond{Page: req.Page, PageSize: req.PageSize},
ObjectID: req.ObjectID,
+30 -1
View File
@@ -533,11 +533,27 @@ func (as *AnswerService) updateAnswerRank(ctx context.Context, userID string,
}
}
func (as *AnswerService) Get(ctx context.Context, answerID, loginUserID string) (*schema.AnswerInfo, *schema.QuestionInfoResp, bool, error) {
func (as *AnswerService) Get(ctx context.Context, answerID, loginUserID string, isAdminModerator bool) (*schema.AnswerInfo, *schema.QuestionInfoResp, bool, error) {
answerInfo, has, err := as.answerRepo.GetByID(ctx, answerID)
if err != nil {
return nil, nil, has, err
}
if !has {
return nil, nil, false, nil
}
question, exist, err := as.questionRepo.GetQuestion(ctx, answerInfo.QuestionID)
if err != nil {
return nil, nil, has, err
}
if !exist {
return nil, nil, false, errors.NotFound(reason.AnswerNotFound)
}
if (question.Status == entity.QuestionStatusDeleted ||
question.Status == entity.QuestionStatusPending ||
question.Show == entity.QuestionHide) &&
!isAdminModerator && question.UserID != loginUserID {
return nil, nil, false, errors.NotFound(reason.AnswerNotFound)
}
info := as.ShowFormat(ctx, answerInfo)
// todo questionFunc
questionInfo, err := as.questionCommon.Info(ctx, answerInfo.QuestionID, loginUserID)
@@ -650,6 +666,19 @@ func (as *AnswerService) AdminSetAnswerStatus(ctx context.Context, req *schema.A
func (as *AnswerService) SearchList(ctx context.Context, req *schema.AnswerListReq) ([]*schema.AnswerInfo, int64, error) {
list := make([]*schema.AnswerInfo, 0)
questionInfo, exist, err := as.questionRepo.GetQuestion(ctx, req.QuestionID)
if err != nil {
return list, 0, err
}
if !exist {
return list, 0, errors.NotFound(reason.QuestionNotFound)
}
if (questionInfo.Status == entity.QuestionStatusDeleted ||
questionInfo.Status == entity.QuestionStatusPending ||
questionInfo.Show == entity.QuestionHide) &&
!req.IsAdminModerator && questionInfo.UserID != req.UserID {
return list, 0, errors.NotFound(reason.QuestionNotFound)
}
dbSearch := entity.AnswerSearch{}
dbSearch.QuestionID = req.QuestionID
dbSearch.Page = req.Page
@@ -1096,6 +1096,9 @@ func (qs *QuestionService) GetQuestion(ctx context.Context, questionID, userID s
question.Status == entity.QuestionStatusPending) && !per.CanReopen && question.UserID != userID {
return nil, errors.NotFound(reason.QuestionNotFound)
}
if question.Show == entity.QuestionHide && !per.IsAdminModerator && question.UserID != userID {
return nil, errors.NotFound(reason.QuestionNotFound)
}
if question.Status != entity.QuestionStatusClosed {
per.CanReopen = false
}
+2 -11
View File
@@ -392,17 +392,8 @@ func (rs *RevisionService) GetRevisionList(ctx context.Context, req *schema.GetR
if infoErr != nil {
return nil, infoErr
}
if !req.IsAdmin && objInfo.IsDeleted() && objInfo.ObjectCreatorUserID != req.UserID {
switch objInfo.ObjectType {
case constant.QuestionObjectType:
return nil, errors.NotFound(reason.QuestionNotFound)
case constant.AnswerObjectType:
return nil, errors.NotFound(reason.AnswerNotFound)
case constant.TagObjectType:
return nil, errors.NotFound(reason.TagNotFound)
default:
return nil, errors.NotFound(reason.ObjectNotFound)
}
if err := objInfo.CheckVisibility(req.UserID, req.IsAdmin); err != nil {
return nil, err
}
_ = copier.Copy(&rev, req)
+39
View File
@@ -353,6 +353,10 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
if !exist {
return nil, errors.BadRequest(reason.UserNotFound)
}
errFields, err = us.validateAvatarInfo(ctx, req.UserID, oldUserInfo.Avatar, req.Avatar)
if err != nil {
return errFields, err
}
cond := us.formatUserInfoForUpdateInfo(oldUserInfo, req)
@@ -366,6 +370,41 @@ func (us *UserService) UpdateInfo(ctx context.Context, req *schema.UpdateInfoReq
return nil, err
}
func (us *UserService) validateAvatarInfo(
ctx context.Context,
userID string,
oldAvatarJSON string,
newAvatar schema.AvatarInfo,
) (errFields []*validator.FormErrorField, err error) {
if newAvatar.Type != constant.AvatarTypeCustom {
return nil, nil
}
if len(newAvatar.Custom) == 0 {
return append(errFields, &validator.FormErrorField{
ErrorField: "avatar",
ErrorMsg: reason.UserSetAvatar,
}), errors.BadRequest(reason.UserSetAvatar)
}
var oldAvatar schema.AvatarInfo
_ = json.Unmarshal([]byte(oldAvatarJSON), &oldAvatar)
if oldAvatar.Type == constant.AvatarTypeCustom && oldAvatar.Custom == newAvatar.Custom {
return nil, nil
}
fileRecord, err := us.fileRecordService.GetFileRecordByURL(ctx, newAvatar.Custom)
if err != nil {
return nil, err
}
if fileRecord == nil || fileRecord.UserID != userID || fileRecord.Source != string(plugin.UserAvatar) {
return append(errFields, &validator.FormErrorField{
ErrorField: "avatar",
ErrorMsg: reason.UserSetAvatar,
}), errors.BadRequest(reason.UserSetAvatar)
}
return nil, nil
}
func (us *UserService) cleanUpRemovedAvatar(
ctx context.Context,
oldAvatarJSON string,
+35 -4
View File
@@ -23,6 +23,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"html"
"mime"
"os"
"strings"
@@ -224,6 +225,10 @@ func (es *EmailService) TestTemplate(ctx context.Context) (title, body string, e
return title, body, nil
}
func escapeEmailHTMLText(text string) string {
return html.EscapeString(text)
}
// NewAnswerTemplate new answer template
func (es *EmailService) NewAnswerTemplate(ctx context.Context, raw *schema.NewAnswerTemplateRawData) (
title, body string, err error) {
@@ -246,7 +251,14 @@ func (es *EmailService) NewAnswerTemplate(ctx context.Context, raw *schema.NewAn
lang := handler.GetLangByCtx(ctx)
title = translator.TrWithData(lang, constant.EmailTplKeyNewAnswerTitle, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyNewAnswerBody, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyNewAnswerBody, &schema.NewAnswerTemplateData{
SiteName: escapeEmailHTMLText(templateData.SiteName),
DisplayName: escapeEmailHTMLText(templateData.DisplayName),
QuestionTitle: escapeEmailHTMLText(templateData.QuestionTitle),
AnswerUrl: templateData.AnswerUrl,
AnswerSummary: escapeEmailHTMLText(templateData.AnswerSummary),
UnsubscribeUrl: templateData.UnsubscribeUrl,
})
return title, body, nil
}
@@ -271,7 +283,13 @@ func (es *EmailService) NewInviteAnswerTemplate(ctx context.Context, raw *schema
lang := handler.GetLangByCtx(ctx)
title = translator.TrWithData(lang, constant.EmailTplKeyInvitedAnswerTitle, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyInvitedAnswerBody, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyInvitedAnswerBody, &schema.NewInviteAnswerTemplateData{
SiteName: escapeEmailHTMLText(templateData.SiteName),
DisplayName: escapeEmailHTMLText(templateData.DisplayName),
QuestionTitle: escapeEmailHTMLText(templateData.QuestionTitle),
InviteUrl: templateData.InviteUrl,
UnsubscribeUrl: templateData.UnsubscribeUrl,
})
return title, body, nil
}
@@ -298,7 +316,14 @@ func (es *EmailService) NewCommentTemplate(ctx context.Context, raw *schema.NewC
lang := handler.GetLangByCtx(ctx)
title = translator.TrWithData(lang, constant.EmailTplKeyNewCommentTitle, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyNewCommentBody, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyNewCommentBody, &schema.NewCommentTemplateData{
SiteName: escapeEmailHTMLText(templateData.SiteName),
DisplayName: escapeEmailHTMLText(templateData.DisplayName),
QuestionTitle: escapeEmailHTMLText(templateData.QuestionTitle),
CommentUrl: templateData.CommentUrl,
CommentSummary: escapeEmailHTMLText(templateData.CommentSummary),
UnsubscribeUrl: templateData.UnsubscribeUrl,
})
return title, body, nil
}
@@ -324,7 +349,13 @@ func (es *EmailService) NewQuestionTemplate(ctx context.Context, raw *schema.New
lang := handler.GetLangByCtx(ctx)
title = translator.TrWithData(lang, constant.EmailTplKeyNewQuestionTitle, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyNewQuestionBody, templateData)
body = translator.TrWithData(lang, constant.EmailTplKeyNewQuestionBody, &schema.NewQuestionTemplateData{
SiteName: escapeEmailHTMLText(templateData.SiteName),
QuestionTitle: escapeEmailHTMLText(templateData.QuestionTitle),
QuestionUrl: templateData.QuestionUrl,
Tags: escapeEmailHTMLText(templateData.Tags),
UnsubscribeUrl: templateData.UnsubscribeUrl,
})
return title, body, nil
}
+21 -18
View File
@@ -200,14 +200,15 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc
break
}
objInfo = &schema.SimpleObjectInfo{
ObjectID: questionInfo.ID,
ObjectCreatorUserID: questionInfo.UserID,
QuestionID: questionInfo.ID,
QuestionStatus: questionInfo.Status,
QuestionShow: questionInfo.Show,
ObjectType: objectType,
Title: questionInfo.Title,
Content: questionInfo.ParsedText, // todo trim
ObjectID: questionInfo.ID,
ObjectCreatorUserID: questionInfo.UserID,
QuestionID: questionInfo.ID,
QuestionCreatorUserID: questionInfo.UserID,
QuestionStatus: questionInfo.Status,
QuestionShow: questionInfo.Show,
ObjectType: objectType,
Title: questionInfo.Title,
Content: questionInfo.ParsedText, // todo trim
}
case constant.AnswerObjectType:
answerInfo, exist, err := os.answerRepo.GetAnswer(ctx, objectID)
@@ -225,16 +226,17 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc
break
}
objInfo = &schema.SimpleObjectInfo{
ObjectID: answerInfo.ID,
ObjectCreatorUserID: answerInfo.UserID,
QuestionID: answerInfo.QuestionID,
QuestionStatus: questionInfo.Status,
QuestionShow: questionInfo.Show,
AnswerStatus: answerInfo.Status,
AnswerID: answerInfo.ID,
ObjectType: objectType,
Title: questionInfo.Title, // this should be question title
Content: answerInfo.ParsedText, // todo trim
ObjectID: answerInfo.ID,
ObjectCreatorUserID: answerInfo.UserID,
QuestionID: answerInfo.QuestionID,
QuestionCreatorUserID: questionInfo.UserID,
QuestionStatus: questionInfo.Status,
QuestionShow: questionInfo.Show,
AnswerStatus: answerInfo.Status,
AnswerID: answerInfo.ID,
ObjectType: objectType,
Title: questionInfo.Title, // this should be question title
Content: answerInfo.ParsedText, // todo trim
}
case constant.CommentObjectType:
commentInfo, exist, err := os.commentRepo.GetComment(ctx, objectID)
@@ -259,6 +261,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc
}
if exist {
objInfo.QuestionID = questionInfo.ID
objInfo.QuestionCreatorUserID = questionInfo.UserID
objInfo.QuestionStatus = questionInfo.Status
objInfo.QuestionShow = questionInfo.Show
objInfo.Title = questionInfo.Title
@@ -27,6 +27,7 @@ import (
"github.com/apache/answer/internal/base/constant"
"github.com/apache/answer/internal/entity"
"github.com/apache/answer/internal/schema"
"github.com/apache/answer/pkg/checker"
"github.com/apache/answer/pkg/gravatar"
"github.com/segmentfault/pacman/log"
)
@@ -158,6 +159,10 @@ func (s *siteInfoCommonService) selectedAvatar(
email string, userStatus int) *schema.AvatarInfo {
avatarInfo := &schema.AvatarInfo{}
_ = json.Unmarshal([]byte(originalAvatarData), avatarInfo)
if len(avatarInfo.Type) == 0 && checker.IsURL(originalAvatarData) {
avatarInfo.Type = constant.AvatarTypeCustom
avatarInfo.Custom = originalAvatarData
}
if userStatus == entity.UserStatusDeleted {
return &schema.AvatarInfo{
+43 -16
View File
@@ -22,9 +22,9 @@ package checker
import (
"fmt"
"image"
_ "image/gif" // use init to support decode jpeg,jpg,png,gif
_ "image/jpeg"
_ "image/png"
"image/gif"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
@@ -47,25 +47,26 @@ func IsUnAuthorizedExtension(fileName string, allowedExtensions []string) bool {
func DecodeAndCheckImageFile(localFilePath string, maxImageMegapixel int) bool {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(localFilePath), "."))
switch ext {
case "jpg", "jpeg", "png", "gif": // only allow for `image/jpeg, image/jpg, image/png, image/gif`
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, standardImageConfigCheck) {
case "jpg", "jpeg", "png", "gif":
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, formatSpecificConfigCheck) {
return false
}
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, standardImageCheck) {
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, formatSpecificImageCheck) {
return false
}
case "webp":
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, webpImageConfigCheck) {
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, webpImageConfigCheck) {
return false
}
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, webpImageCheck) {
if !decodeAndCheckImageFile(localFilePath, maxImageMegapixel, ext, webpImageCheck) {
return false
}
}
return true
}
func decodeAndCheckImageFile(localFilePath string, maxImageMegapixel int, checker func(file io.Reader, maxImageMegapixel int) error) bool {
func decodeAndCheckImageFile(localFilePath string, maxImageMegapixel int, ext string,
checker func(file io.Reader, ext string, maxImageMegapixel int) error) bool {
file, err := os.Open(localFilePath)
if err != nil {
log.Errorf("open file error: %v", err)
@@ -75,15 +76,30 @@ func decodeAndCheckImageFile(localFilePath string, maxImageMegapixel int, checke
_ = file.Close()
}()
if err = checker(file, maxImageMegapixel); err != nil {
if err = checker(file, ext, maxImageMegapixel); err != nil {
log.Errorf("check image format error: %v", err)
return false
}
return true
}
func standardImageConfigCheck(file io.Reader, maxImageMegapixel int) error {
config, _, err := image.DecodeConfig(file)
// formatSpecificConfigCheck decodes image config using a format-specific decoder
// based on the file extension. This avoids calling image.DecodeConfig() which
// dispatches by magic bytes and can invoke unintended decoders (e.g., TIFF)
// registered by transitive dependencies.
func formatSpecificConfigCheck(file io.Reader, ext string, maxImageMegapixel int) error {
var config image.Config
var err error
switch ext {
case "jpg", "jpeg":
config, err = jpeg.DecodeConfig(file)
case "png":
config, err = png.DecodeConfig(file)
case "gif":
config, err = gif.DecodeConfig(file)
default:
return fmt.Errorf("unsupported image format: %s", ext)
}
if err != nil {
return fmt.Errorf("decode image config error: %v", err)
}
@@ -93,15 +109,26 @@ func standardImageConfigCheck(file io.Reader, maxImageMegapixel int) error {
return nil
}
func standardImageCheck(file io.Reader, maxImageMegapixel int) error {
_, _, err := image.Decode(file)
// formatSpecificImageCheck fully decodes the image using a format-specific decoder.
func formatSpecificImageCheck(file io.Reader, ext string, _ int) error {
var err error
switch ext {
case "jpg", "jpeg":
_, err = jpeg.Decode(file)
case "png":
_, err = png.Decode(file)
case "gif":
_, err = gif.Decode(file)
default:
return fmt.Errorf("unsupported image format: %s", ext)
}
if err != nil {
return fmt.Errorf("decode image error: %v", err)
}
return nil
}
func webpImageConfigCheck(file io.Reader, maxImageMegapixel int) error {
func webpImageConfigCheck(file io.Reader, _ string, maxImageMegapixel int) error {
config, err := webp.DecodeConfig(file)
if err != nil {
return fmt.Errorf("decode webp image config error: %v", err)
@@ -112,7 +139,7 @@ func webpImageConfigCheck(file io.Reader, maxImageMegapixel int) error {
return nil
}
func webpImageCheck(file io.Reader, maxImageMegapixel int) error {
func webpImageCheck(file io.Reader, _ string, _ int) error {
_, err := webp.Decode(file)
if err != nil {
return fmt.Errorf("decode webp image error: %v", err)