From 92994b49976b6206a7000d045d924ec4fd5be1be Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Fri, 6 Feb 2026 15:32:45 +0800 Subject: [PATCH 1/3] fix: add IsAdminModerator field to request structs and implement visibility checks for timeline objects --- internal/controller/activity_controller.go | 2 + internal/schema/activity.go | 16 +-- internal/schema/simple_obj_info_schema.go | 1 + internal/service/activity/activity.go | 108 ++++++++++++++++++++ internal/service/object_info/object_info.go | 3 + 5 files changed, 123 insertions(+), 7 deletions(-) diff --git a/internal/controller/activity_controller.go b/internal/controller/activity_controller.go index 26cd400c..e109d885 100644 --- a/internal/controller/activity_controller.go +++ b/internal/controller/activity_controller.go @@ -58,6 +58,7 @@ func (ac *ActivityController) GetObjectTimeline(ctx *gin.Context) { req.ObjectID = uid.DeShortID(req.ObjectID) req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) if userInfo := middleware.GetUserInfoFromContext(ctx); userInfo != nil { req.IsAdmin = userInfo.RoleID == role.RoleAdminID } @@ -81,6 +82,7 @@ func (ac *ActivityController) GetObjectTimelineDetail(ctx *gin.Context) { } req.UserID = middleware.GetLoginUserIDFromContext(ctx) + req.IsAdminModerator = middleware.GetUserIsAdminModerator(ctx) resp, err := ac.activityService.GetObjectTimelineDetail(ctx, req) handler.HandleResponse(ctx, err, resp) diff --git a/internal/schema/activity.go b/internal/schema/activity.go index e62bdd78..0068dead 100644 --- a/internal/schema/activity.go +++ b/internal/schema/activity.go @@ -34,10 +34,11 @@ type ActivityMsg struct { // GetObjectTimelineReq get object timeline request type GetObjectTimelineReq struct { - ObjectID string `validate:"omitempty,gt=0,lte=100" form:"object_id"` - ShowVote bool `validate:"omitempty" form:"show_vote"` - UserID string `json:"-"` - IsAdmin bool `json:"-"` + ObjectID string `validate:"omitempty,gt=0,lte=100" form:"object_id"` + ShowVote bool `validate:"omitempty" form:"show_vote"` + UserID string `json:"-"` + IsAdmin bool `json:"-"` + IsAdminModerator bool `json:"-"` } // GetObjectTimelineResp get object timeline response @@ -73,9 +74,10 @@ type ActObjectInfo struct { // GetObjectTimelineDetailReq get object timeline detail request type GetObjectTimelineDetailReq struct { - NewRevisionID string `validate:"required,gt=0,lte=100" form:"new_revision_id"` - OldRevisionID string `validate:"required,gt=0,lte=100" form:"old_revision_id"` - UserID string `json:"-"` + NewRevisionID string `validate:"required,gt=0,lte=100" form:"new_revision_id"` + OldRevisionID string `validate:"required,gt=0,lte=100" form:"old_revision_id"` + UserID string `json:"-"` + IsAdminModerator bool `json:"-"` } // GetObjectTimelineDetailResp get object timeline detail response diff --git a/internal/schema/simple_obj_info_schema.go b/internal/schema/simple_obj_info_schema.go index 21a03ae2..89763e86 100644 --- a/internal/schema/simple_obj_info_schema.go +++ b/internal/schema/simple_obj_info_schema.go @@ -30,6 +30,7 @@ type SimpleObjectInfo struct { 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"` diff --git a/internal/service/activity/activity.go b/internal/service/activity/activity.go index 061d8449..deae9b9f 100644 --- a/internal/service/activity/activity.go +++ b/internal/service/activity/activity.go @@ -30,6 +30,7 @@ import ( "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/handler" + "github.com/apache/answer/internal/base/reason" "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/comment_common" @@ -41,6 +42,7 @@ import ( "github.com/apache/answer/pkg/converter" "github.com/apache/answer/pkg/obj" "github.com/apache/answer/pkg/uid" + "github.com/segmentfault/pacman/errors" "github.com/segmentfault/pacman/log" ) @@ -90,6 +92,10 @@ func NewActivityService( // GetObjectTimeline get object timeline func (as *ActivityService) GetObjectTimeline(ctx context.Context, req *schema.GetObjectTimelineReq) ( resp *schema.GetObjectTimelineResp, err error) { + if err = as.ensureTimelineObjectVisible(ctx, req.ObjectID, req.UserID, req.IsAdminModerator); err != nil { + return nil, err + } + resp = &schema.GetObjectTimelineResp{ ObjectInfo: &schema.ActObjectInfo{}, Timeline: make([]*schema.ActObjectTimeline, 0), @@ -254,12 +260,114 @@ func (as *ActivityService) formatTimelineUserInfo(ctx context.Context, timeline // GetObjectTimelineDetail get object timeline func (as *ActivityService) GetObjectTimelineDetail(ctx context.Context, req *schema.GetObjectTimelineDetailReq) ( resp *schema.GetObjectTimelineDetailResp, err error) { + if err = as.ensureTimelineRevisionVisible(ctx, req.NewRevisionID, req.UserID, req.IsAdminModerator); err != nil { + return nil, err + } + if err = as.ensureTimelineRevisionVisible(ctx, req.OldRevisionID, req.UserID, req.IsAdminModerator); err != nil { + return nil, err + } + resp = &schema.GetObjectTimelineDetailResp{} resp.OldRevision, _ = as.getOneObjectDetail(ctx, req.OldRevisionID) resp.NewRevision, _ = as.getOneObjectDetail(ctx, req.NewRevisionID) return resp, nil } +func (as *ActivityService) ensureTimelineRevisionVisible(ctx context.Context, revisionID, userID string, + isAdminModerator bool) error { + if revisionID == "0" { + return nil + } + revisionInfo, err := as.revisionService.GetRevision(ctx, revisionID) + if err != nil { + return err + } + return as.ensureTimelineObjectVisible(ctx, revisionInfo.ObjectID, userID, isAdminModerator) +} + +func (as *ActivityService) ensureTimelineObjectVisible(ctx context.Context, objectID, userID string, + isAdminModerator bool) error { + objInfo, err := as.objectInfoService.GetInfo(ctx, objectID) + if err != nil { + return err + } + + var parentQuestionInfo *schema.SimpleObjectInfo + if objInfo.ObjectType != constant.QuestionObjectType && len(objInfo.QuestionID) > 0 && objInfo.QuestionID != "0" { + parentQuestionInfo, err = as.objectInfoService.GetInfo(ctx, objInfo.QuestionID) + if err != nil { + return err + } + } + + return validateTimelineObjectVisibility(objInfo, parentQuestionInfo, userID, isAdminModerator) +} + +func validateTimelineObjectVisibility(objInfo, parentQuestionInfo *schema.SimpleObjectInfo, + userID string, isAdminModerator bool) error { + if objInfo == nil { + return errors.NotFound(reason.ObjectNotFound) + } + if isTimelineObjectRestricted(objInfo) && + !canViewRestrictedTimelineObject(objInfo.ObjectType, objInfo.ObjectCreatorUserID, userID, isAdminModerator) { + return errors.NotFound(timelineNotFoundReasonByObjectType(objInfo.ObjectType)) + } + if parentQuestionInfo != nil && isTimelineQuestionRestricted(parentQuestionInfo) && + !canViewRestrictedTimelineObject(parentQuestionInfo.ObjectType, parentQuestionInfo.ObjectCreatorUserID, + userID, isAdminModerator) { + return errors.NotFound(reason.QuestionNotFound) + } + return nil +} + +func canViewRestrictedTimelineObject(objectType, creatorUserID, userID string, isAdminModerator bool) bool { + if isAdminModerator { + return true + } + switch objectType { + case constant.QuestionObjectType, constant.AnswerObjectType, constant.CommentObjectType: + return creatorUserID == userID + default: + return false + } +} + +func isTimelineObjectRestricted(objInfo *schema.SimpleObjectInfo) bool { + switch objInfo.ObjectType { + case constant.QuestionObjectType: + return isTimelineQuestionRestricted(objInfo) + case constant.AnswerObjectType: + return objInfo.AnswerStatus == entity.AnswerStatusDeleted || objInfo.AnswerStatus == entity.AnswerStatusPending + case constant.CommentObjectType: + return objInfo.CommentStatus == entity.CommentStatusDeleted || objInfo.CommentStatus == entity.CommentStatusPending + case constant.TagObjectType: + return objInfo.TagStatus == entity.TagStatusDeleted + default: + return false + } +} + +func isTimelineQuestionRestricted(questionInfo *schema.SimpleObjectInfo) bool { + return questionInfo.QuestionStatus == entity.QuestionStatusDeleted || + questionInfo.QuestionStatus == entity.QuestionStatusPending || + questionInfo.QuestionShow == entity.QuestionHide +} + +func timelineNotFoundReasonByObjectType(objectType string) string { + switch 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 + } +} + // getOneObjectDetail get object detail func (as *ActivityService) getOneObjectDetail(ctx context.Context, revisionID string) ( resp *schema.ObjectTimelineDetail, err error) { diff --git a/internal/service/object_info/object_info.go b/internal/service/object_info/object_info.go index 6380b880..4f91fbcb 100644 --- a/internal/service/object_info/object_info.go +++ b/internal/service/object_info/object_info.go @@ -204,6 +204,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc ObjectCreatorUserID: questionInfo.UserID, QuestionID: questionInfo.ID, QuestionStatus: questionInfo.Status, + QuestionShow: questionInfo.Show, ObjectType: objectType, Title: questionInfo.Title, Content: questionInfo.ParsedText, // todo trim @@ -228,6 +229,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc ObjectCreatorUserID: answerInfo.UserID, QuestionID: answerInfo.QuestionID, QuestionStatus: questionInfo.Status, + QuestionShow: questionInfo.Show, AnswerStatus: answerInfo.Status, AnswerID: answerInfo.ID, ObjectType: objectType, @@ -258,6 +260,7 @@ func (os *ObjService) GetInfo(ctx context.Context, objectID string) (objInfo *sc if exist { objInfo.QuestionID = questionInfo.ID objInfo.QuestionStatus = questionInfo.Status + objInfo.QuestionShow = questionInfo.Show objInfo.Title = questionInfo.Title } answerInfo, exist, err := os.answerRepo.GetAnswer(ctx, commentInfo.ObjectID) From 0db88d63e304ee217353fd6c29f6987865889d1c Mon Sep 17 00:00:00 2001 From: LinkinStars Date: Fri, 6 Feb 2026 18:07:52 +0800 Subject: [PATCH 2/3] fix(chat): implement HTML rendering for display content --- ui/src/components/BubbleAi/index.tsx | 66 +++++++++++++++++-- ui/src/pages/AiAssistant/index.tsx | 2 +- .../pages/Search/components/AiCard/index.tsx | 6 +- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/ui/src/components/BubbleAi/index.tsx b/ui/src/components/BubbleAi/index.tsx index 453a61c5..1e79ca7c 100644 --- a/ui/src/components/BubbleAi/index.tsx +++ b/ui/src/components/BubbleAi/index.tsx @@ -21,10 +21,9 @@ import { FC, useEffect, useState, useRef } from 'react'; import { Button } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { marked } from 'marked'; import copy from 'copy-to-clipboard'; -import { voteConversation } from '@/services'; +import { markdownToHtml, voteConversation } from '@/services'; import { Icon, htmlRender } from '@/components'; interface IProps { @@ -40,6 +39,17 @@ interface IProps { }; } +const escapeHtml = (text: string) => + text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +const renderPlainTextAsHtml = (text: string) => + escapeHtml(text).replace(/\r?\n/g, '
'); + const BubbleAi: FC = ({ canType = false, isLast, @@ -55,6 +65,7 @@ const BubbleAi: FC = ({ const [isHelpful, setIsHelpful] = useState(false); const [isUnhelpful, setIsUnhelpful] = useState(false); const [canShowAction, setCanShowAction] = useState(false); + const [safeHtml, setSafeHtml] = useState(''); const typewriterRef = useRef<{ timer: NodeJS.Timeout | null; index: number; @@ -64,6 +75,8 @@ const BubbleAi: FC = ({ index: 0, isTyping: false, }); + const renderTimerRef = useRef(null); + const renderTaskRef = useRef(0); const fmtContainer = useRef(null); // add ref for ScrollIntoView const containerRef = useRef(null); @@ -194,13 +207,56 @@ const BubbleAi: FC = ({ }; }, [content, isCompleted]); + useEffect(() => { + if (renderTimerRef.current) { + clearTimeout(renderTimerRef.current); + renderTimerRef.current = null; + } + renderTaskRef.current += 1; + const currentRenderTask = renderTaskRef.current; + + if (!displayContent) { + setSafeHtml(''); + return undefined; + } + + // During streaming, render escaped plain text to avoid executing unsanitized HTML. + if (!isCompleted) { + setSafeHtml(renderPlainTextAsHtml(displayContent)); + return undefined; + } + + renderTimerRef.current = setTimeout(() => { + markdownToHtml(displayContent) + .then((resp) => { + if (renderTaskRef.current !== currentRenderTask) { + return; + } + setSafeHtml(resp || renderPlainTextAsHtml(displayContent)); + }) + .catch(() => { + if (renderTaskRef.current !== currentRenderTask) { + return; + } + setSafeHtml(renderPlainTextAsHtml(displayContent)); + }); + }, 0); + + return () => { + if (renderTimerRef.current) { + clearTimeout(renderTimerRef.current); + renderTimerRef.current = null; + } + }; + }, [displayContent, isCompleted]); + useEffect(() => { setIsHelpful(actionData.helpful > 0); setIsUnhelpful(actionData.unhelpful > 0); }, [actionData]); useEffect(() => { - if (fmtContainer.current && isCompleted) { + if (fmtContainer.current && isCompleted && safeHtml) { htmlRender(fmtContainer.current, { copySuccessText: t('copied', { keyPrefix: 'messages' }), copyText: t('copy', { keyPrefix: 'messages' }), @@ -211,7 +267,7 @@ const BubbleAi: FC = ({ }); setCanShowAction(true); } - }, [isCompleted, fmtContainer.current]); + }, [isCompleted, safeHtml, t]); return (
= ({ className="fmt text-break text-wrap" ref={fmtContainer} style={{ transition: 'all 0.2s ease' }} - dangerouslySetInnerHTML={{ __html: marked.parse(displayContent) }} + dangerouslySetInnerHTML={{ __html: safeHtml }} /> {canShowAction && ( diff --git a/ui/src/pages/AiAssistant/index.tsx b/ui/src/pages/AiAssistant/index.tsx index e2329f85..83ffe8f1 100644 --- a/ui/src/pages/AiAssistant/index.tsx +++ b/ui/src/pages/AiAssistant/index.tsx @@ -328,7 +328,7 @@ const Index = () => { canType={isGenerate && isLastMessage} chatId={item.chat_completion_id} isLast={isLastMessage} - isCompleted={!isGenerate} + isCompleted={!isGenerate || !isLastMessage} content={item.content} actionData={{ helpful: item.helpful, diff --git a/ui/src/pages/Search/components/AiCard/index.tsx b/ui/src/pages/Search/components/AiCard/index.tsx index 2da5cb91..99e21adc 100644 --- a/ui/src/pages/Search/components/AiCard/index.tsx +++ b/ui/src/pages/Search/components/AiCard/index.tsx @@ -149,10 +149,10 @@ const Index = () => { ) : ( Date: Fri, 6 Feb 2026 20:31:54 +0800 Subject: [PATCH 3/3] fix(auth): enhance admin user cache management and add status checks for email verification and suspension --- internal/base/middleware/auth.go | 15 ++++++++++++++ internal/service/auth/auth.go | 34 +++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/base/middleware/auth.go b/internal/base/middleware/auth.go index 57bbaae2..7bfd5e7f 100644 --- a/internal/base/middleware/auth.go +++ b/internal/base/middleware/auth.go @@ -184,7 +184,22 @@ func (am *AuthUserMiddleware) AdminAuth() gin.HandlerFunc { return } if userInfo != nil { + if userInfo.EmailStatus == entity.EmailStatusToBeVerified { + _ = am.authService.RemoveAdminUserCacheInfo(ctx, token) + handler.HandleResponse(ctx, errors.Forbidden(reason.EmailNeedToBeVerified), + &schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeInactive}) + ctx.Abort() + return + } + if userInfo.UserStatus == entity.UserStatusSuspended { + _ = am.authService.RemoveAdminUserCacheInfo(ctx, token) + handler.HandleResponse(ctx, errors.Forbidden(reason.UserSuspended), + &schema.ForbiddenResp{Type: schema.ForbiddenReasonTypeUserSuspended}) + ctx.Abort() + return + } if userInfo.UserStatus == entity.UserStatusDeleted { + _ = am.authService.RemoveAdminUserCacheInfo(ctx, token) handler.HandleResponse(ctx, errors.Unauthorized(reason.UnauthorizedError), nil) ctx.Abort() return diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go index 8f539bf1..7d275105 100644 --- a/internal/service/auth/auth.go +++ b/internal/service/auth/auth.go @@ -145,7 +145,39 @@ func (as *AuthService) RemoveTokensExceptCurrentUser(ctx context.Context, userID // Admin func (as *AuthService) GetAdminUserCacheInfo(ctx context.Context, accessToken string) (userInfo *entity.UserCacheInfo, err error) { - return as.authRepo.GetAdminUserCacheInfo(ctx, accessToken) + adminCacheInfo, err := as.authRepo.GetAdminUserCacheInfo(ctx, accessToken) + if err != nil { + return nil, err + } + if adminCacheInfo == nil { + return nil, nil + } + + // Keep admin authorization aligned with user-token lifecycle and status refresh. + refreshedUserCacheInfo, err := as.GetUserCacheInfo(ctx, accessToken) + if err != nil { + return nil, err + } + if refreshedUserCacheInfo == nil { + if err = as.authRepo.RemoveAdminUserCacheInfo(ctx, accessToken); err != nil { + return nil, err + } + return nil, nil + } + + adminCacheInfo.UserStatus = refreshedUserCacheInfo.UserStatus + adminCacheInfo.EmailStatus = refreshedUserCacheInfo.EmailStatus + if refreshedUserCacheInfo.RoleID > 0 { + adminCacheInfo.RoleID = refreshedUserCacheInfo.RoleID + } + if len(refreshedUserCacheInfo.ExternalID) > 0 { + adminCacheInfo.ExternalID = refreshedUserCacheInfo.ExternalID + } + + if err = as.authRepo.SetAdminUserCacheInfo(ctx, accessToken, adminCacheInfo); err != nil { + return nil, err + } + return adminCacheInfo, nil } func (as *AuthService) SetAdminUserCacheInfo(ctx context.Context, accessToken string, userInfo *entity.UserCacheInfo) (err error) {