4cddfcf2f3
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
286 lines
9.7 KiB
Go
286 lines
9.7 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/pkg/errors"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
|
storepb "github.com/usememos/memos/proto/gen/store"
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
// CreateMemoComment creates a comment on an existing memo.
|
|
func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.CreateMemoCommentRequest) (*v1pb.Memo, error) {
|
|
memoUID, err := ExtractMemoUIDFromName(request.Name)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
|
}
|
|
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
|
}
|
|
if relatedMemo == nil {
|
|
return nil, status.Errorf(codes.NotFound, "memo not found")
|
|
}
|
|
|
|
// Check memo visibility before allowing comment.
|
|
user, err := s.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user")
|
|
}
|
|
if user == nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
if relatedMemo.Visibility == store.Private && relatedMemo.CreatorID != user.ID && !isSuperUser(user) {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
if request.Comment == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "comment is required")
|
|
}
|
|
|
|
comment, ok := proto.Clone(request.Comment).(*v1pb.Memo)
|
|
if !ok {
|
|
return nil, status.Errorf(codes.Internal, "failed to clone memo comment")
|
|
}
|
|
comment.Visibility = convertVisibilityFromStore(relatedMemo.Visibility)
|
|
|
|
// Create the memo comment first; suppress the generic memo.created SSE event
|
|
// since CreateMemoComment broadcasts memo.comment.created for the parent instead.
|
|
memoComment, err := s.CreateMemo(withSuppressMentionNotifications(withSuppressSSE(ctx)), &v1pb.CreateMemoRequest{
|
|
Memo: comment,
|
|
MemoId: request.CommentId,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create memo")
|
|
}
|
|
memoUID, err = ExtractMemoUIDFromName(memoComment.Name)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
|
}
|
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
|
}
|
|
|
|
// Build the relation between the comment memo and the original memo.
|
|
_, err = s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
|
MemoID: memo.ID,
|
|
RelatedMemoID: relatedMemo.ID,
|
|
Type: store.MemoRelationComment,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
|
|
}
|
|
|
|
// The comment memo was converted before the relation above existed, so its
|
|
// Relations slice is empty. Reload the relations now so that both the API
|
|
// response and the memo.comment.created webhook payload carry the relation
|
|
// to the parent memo.
|
|
relations, err := s.loadMemoRelations(ctx, memo)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to load memo relations")
|
|
}
|
|
memoComment.Relations = relations
|
|
|
|
creator, err := ResolveUserByName(ctx, s.Store, memoComment.Creator)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator")
|
|
}
|
|
if creator == nil {
|
|
return nil, status.Errorf(codes.NotFound, "memo creator not found")
|
|
}
|
|
creatorID := creator.ID
|
|
if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID {
|
|
if _, err := s.createInboxWithEmailNotification(ctx, &store.Inbox{
|
|
SenderID: creatorID,
|
|
ReceiverID: relatedMemo.CreatorID,
|
|
Status: store.UNREAD,
|
|
Message: &storepb.InboxMessage{
|
|
Type: storepb.InboxMessage_MEMO_COMMENT,
|
|
Payload: &storepb.InboxMessage_MemoComment{
|
|
MemoComment: &storepb.InboxMessage_MemoCommentPayload{
|
|
MemoId: memo.ID,
|
|
RelatedMemoId: relatedMemo.ID,
|
|
},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create inbox")
|
|
}
|
|
}
|
|
|
|
if err := s.DispatchMemoCommentCreatedWebhook(ctx, memoComment, relatedMemo.CreatorID); err != nil {
|
|
slog.Warn("Failed to dispatch memo comment created webhook", slog.Any("err", err))
|
|
}
|
|
|
|
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, relatedMemo, "")
|
|
|
|
// Broadcast live refresh event for the parent memo so subscribers see the new comment.
|
|
s.SSEHub.Broadcast(&SSEEvent{
|
|
Type: SSEEventMemoCommentCreated,
|
|
Name: request.Name,
|
|
Visibility: relatedMemo.Visibility,
|
|
CreatorID: relatedMemo.CreatorID,
|
|
})
|
|
|
|
return memoComment, nil
|
|
}
|
|
|
|
func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListMemoCommentsRequest) (*v1pb.ListMemoCommentsResponse, error) {
|
|
memoUID, err := ExtractMemoUIDFromName(request.Name)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
|
}
|
|
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
|
}
|
|
if memo == nil {
|
|
return nil, status.Errorf(codes.NotFound, "memo not found")
|
|
}
|
|
if err := s.checkMemoReadAccess(ctx, memo); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
currentUser, err := s.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user")
|
|
}
|
|
var memoFilter string
|
|
if currentUser == nil {
|
|
memoFilter = `visibility == "PUBLIC"`
|
|
} else {
|
|
memoFilter = fmt.Sprintf(`creator_id == %d || visibility in ["PUBLIC", "PROTECTED"]`, currentUser.ID)
|
|
}
|
|
memoRelationComment := store.MemoRelationComment
|
|
var limit, offset int
|
|
if request.PageToken != "" {
|
|
var pageToken v1pb.PageToken
|
|
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
|
|
}
|
|
limit = normalizePageSize(pageToken.Limit)
|
|
offset = max(int(pageToken.Offset), 0)
|
|
} else {
|
|
limit = normalizePageSize(request.PageSize)
|
|
}
|
|
limitPlusOne := limit + 1
|
|
memoRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
|
RelatedMemoID: &memo.ID,
|
|
Type: &memoRelationComment,
|
|
MemoFilter: &memoFilter,
|
|
Limit: &limitPlusOne,
|
|
Offset: &offset,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list memo relations")
|
|
}
|
|
|
|
nextPageToken := ""
|
|
if len(memoRelations) == limitPlusOne {
|
|
memoRelations = memoRelations[:limit]
|
|
nextPageToken, err = getPageToken(limit, offset+limit)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err)
|
|
}
|
|
}
|
|
|
|
if len(memoRelations) == 0 {
|
|
response := &v1pb.ListMemoCommentsResponse{
|
|
Memos: []*v1pb.Memo{},
|
|
NextPageToken: nextPageToken,
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
memoRelationIDs := make([]int32, 0, len(memoRelations))
|
|
for _, m := range memoRelations {
|
|
memoRelationIDs = append(memoRelationIDs, m.MemoID)
|
|
}
|
|
memos, err := s.Store.ListMemos(ctx, &store.FindMemo{IDList: memoRelationIDs})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list memos")
|
|
}
|
|
|
|
memoIDToNameMap := make(map[int32]string)
|
|
contentIDs := make([]string, 0, len(memos))
|
|
memoIDsForAttachments := make([]int32, 0, len(memos))
|
|
|
|
for _, memo := range memos {
|
|
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
|
|
memoIDToNameMap[memo.ID] = memoName
|
|
contentIDs = append(contentIDs, memoName)
|
|
memoIDsForAttachments = append(memoIDsForAttachments, memo.ID)
|
|
}
|
|
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
|
}
|
|
|
|
memoReactionsMap := make(map[string][]*store.Reaction)
|
|
for _, reaction := range reactions {
|
|
memoReactionsMap[reaction.ContentID] = append(memoReactionsMap[reaction.ContentID], reaction)
|
|
}
|
|
|
|
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDsForAttachments})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
|
}
|
|
attachmentMap := make(map[int32][]*store.Attachment)
|
|
for _, attachment := range attachments {
|
|
attachmentMap[*attachment.MemoID] = append(attachmentMap[*attachment.MemoID], attachment)
|
|
}
|
|
|
|
// RELATIONS (batch load to avoid N+1)
|
|
relationMap, err := s.batchConvertMemoRelations(ctx, memos, false)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to batch load memo relations")
|
|
}
|
|
creatorIDs := make([]int32, 0, len(memos)+len(reactions))
|
|
for _, memo := range memos {
|
|
creatorIDs = append(creatorIDs, memo.CreatorID)
|
|
}
|
|
for _, reaction := range reactions {
|
|
creatorIDs = append(creatorIDs, reaction.CreatorID)
|
|
}
|
|
creatorMap, err := s.listUsersByID(ctx, creatorIDs)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list memo creators: %v", err)
|
|
}
|
|
var memosResponse []*v1pb.Memo
|
|
for _, m := range memos {
|
|
memoName := memoIDToNameMap[m.ID]
|
|
reactions := memoReactionsMap[memoName]
|
|
attachments := attachmentMap[m.ID]
|
|
relations := relationMap[m.ID]
|
|
|
|
memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, m, reactions, attachments, relations, creatorMap)
|
|
if err != nil {
|
|
if stderrors.Is(err, errMemoCreatorNotFound) {
|
|
slog.Warn("Skipping memo comment with missing creator",
|
|
slog.Int64("memo_id", int64(m.ID)),
|
|
slog.String("memo_uid", m.UID),
|
|
slog.Int64("creator_id", int64(m.CreatorID)),
|
|
slog.String("parent_name", request.Name),
|
|
)
|
|
continue
|
|
}
|
|
return nil, errors.Wrap(err, "failed to convert memo")
|
|
}
|
|
memosResponse = append(memosResponse, memoMessage)
|
|
}
|
|
|
|
response := &v1pb.ListMemoCommentsResponse{
|
|
Memos: memosResponse,
|
|
NextPageToken: nextPageToken,
|
|
}
|
|
return response, nil
|
|
}
|