Merge remote-tracking branch 'origin/fix/2.0.1/chat' into test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const renderPlainTextAsHtml = (text: string) =>
|
||||
escapeHtml(text).replace(/\r?\n/g, '<br />');
|
||||
|
||||
const BubbleAi: FC<IProps> = ({
|
||||
canType = false,
|
||||
isLast,
|
||||
@@ -55,6 +65,7 @@ const BubbleAi: FC<IProps> = ({
|
||||
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<IProps> = ({
|
||||
index: 0,
|
||||
isTyping: false,
|
||||
});
|
||||
const renderTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const renderTaskRef = useRef(0);
|
||||
const fmtContainer = useRef<HTMLDivElement>(null);
|
||||
// add ref for ScrollIntoView
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -194,13 +207,56 @@ const BubbleAi: FC<IProps> = ({
|
||||
};
|
||||
}, [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<IProps> = ({
|
||||
});
|
||||
setCanShowAction(true);
|
||||
}
|
||||
}, [isCompleted, fmtContainer.current]);
|
||||
}, [isCompleted, safeHtml, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -223,7 +279,7 @@ const BubbleAi: FC<IProps> = ({
|
||||
className="fmt text-break text-wrap"
|
||||
ref={fmtContainer}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(displayContent) }}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
|
||||
{canShowAction && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -149,10 +149,10 @@ const Index = () => {
|
||||
<BubbleUser content={item.content} />
|
||||
) : (
|
||||
<BubbleAi
|
||||
canType
|
||||
canType={isGenerate && isLastMessage}
|
||||
chatId={item.chat_completion_id}
|
||||
isLast
|
||||
isCompleted={!isGenerate}
|
||||
isLast={isLastMessage}
|
||||
isCompleted={!isGenerate || !isLastMessage}
|
||||
content={item.content}
|
||||
actionData={{
|
||||
helpful: item.helpful,
|
||||
|
||||
Reference in New Issue
Block a user