memos/server/router/api/v1/memo_service_converter.go

434 lines
14 KiB
Go

package v1
import (
"context"
stderrors "errors"
"fmt"
"log/slog"
"time"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/server/access"
"github.com/usememos/memos/store"
)
var (
errMemoCreatorNotFound = stderrors.New("memo creator not found")
errReactionCreatorNotFound = stderrors.New("reaction creator not found")
)
func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation) (*v1pb.Memo, error) {
creatorMap, err := s.listUsersByID(ctx, []int32{memo.CreatorID})
if err != nil {
return nil, errors.Wrap(err, "failed to list memo creators")
}
return s.convertMemoFromStoreWithCreators(ctx, memo, reactions, attachments, relations, creatorMap)
}
func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, memo *store.Memo, reactions []*store.Reaction, attachments []*store.Attachment, relations []*v1pb.MemoRelation, creatorMap map[int32]*store.User) (*v1pb.Memo, error) {
name := buildMemoName(memo.UID)
creator := creatorMap[memo.CreatorID]
if creator == nil {
return nil, errMemoCreatorNotFound
}
memoMessage := &v1pb.Memo{
Name: name,
State: convertStateFromStore(memo.RowStatus),
Creator: BuildUserName(creator.Username),
CreateTime: timestamppb.New(time.Unix(memo.CreatedTs, 0)),
UpdateTime: timestamppb.New(time.Unix(memo.UpdatedTs, 0)),
Content: memo.Content,
Visibility: convertVisibilityFromStore(memo.Visibility),
Pinned: memo.Pinned,
}
// An assigned memo needs a read context for its placement projection; an
// unassigned memo needs none.
var readContext access.MemoReadContext
if memo.SpaceID != nil {
resolved, err := s.buildMemoReadContext(ctx, memo, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to resolve memo access")
}
readContext = resolved
}
if err := s.projectMemoCollaborationContext(ctx, memo, memoMessage, readContext); err != nil {
return nil, errors.Wrap(err, "failed to project memo collaboration context")
}
if memo.Payload != nil {
memoMessage.Tags = memo.Payload.Tags
memoMessage.Property = convertMemoPropertyFromStore(memo.Payload.Property)
memoMessage.Location = convertLocationFromStore(memo.Payload.Location)
}
if memo.ParentUID != nil {
contextMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: memo.ParentUID})
if err != nil {
return nil, errors.Wrap(err, "failed to resolve comment context")
}
if contextMemo != nil && s.checkMemoReadAccess(ctx, contextMemo) == nil {
parentName := buildMemoName(*memo.ParentUID)
memoMessage.Parent = &parentName
}
}
// Reactions have no independent audience and are readable whenever this
// memo is readable. Conversion is reached only after memo authorization.
reactionMessages, err := s.convertReactionsFromStoreWithCreators(ctx, reactions, creatorMap, name)
if err != nil {
return nil, errors.Wrap(err, "failed to convert reactions")
}
memoMessage.Reactions = reactionMessages
if relations != nil {
memoMessage.Relations = relations
} else {
memoMessage.Relations = []*v1pb.MemoRelation{}
}
memoMessage.Attachments = []*v1pb.Attachment{}
for _, attachment := range attachments {
attachmentResponse := convertAttachmentFromStore(attachment)
memoMessage.Attachments = append(memoMessage.Attachments, attachmentResponse)
}
snippet, err := s.getMemoContentSnippet(memo.Content)
if err != nil {
return nil, errors.Wrap(err, "failed to get memo content snippet")
}
memoMessage.Snippet = snippet
return memoMessage, nil
}
func (s *APIV1Service) projectMemoCollaborationContext(ctx context.Context, memo *store.Memo, message *v1pb.Memo, readContext access.MemoReadContext) error {
if memo.SpaceID == nil {
return nil
}
if !readContext.SpaceValid {
// Placement is not an additional read gate for PRIVATE, PROTECTED, or
// PUBLIC. Omit a dangling placement rather than turning it into a second
// audience restriction. SPACE already failed closed in the read policy.
if memo.Visibility == store.SpaceAudience {
return errors.New("memo has invalid space placement")
}
return nil
}
// Placement is visible to the author and to active members only; a
// non-member reading an assigned PUBLIC memo learns nothing about its Space.
viewer := readContext.Viewer
if viewer == nil || (viewer.ID != memo.CreatorID && !readContext.ViewerSpaceMember) {
return nil
}
space, err := s.Store.GetSpace(ctx, &store.FindSpace{ID: memo.SpaceID})
if err != nil {
return err
}
if space == nil {
if memo.Visibility == store.SpaceAudience {
return errors.New("memo has invalid space placement")
}
return nil
}
spaceName := buildSpaceName(space.UID)
message.Space = &spaceName
return nil
}
func (s *APIV1Service) listUsersByIDWithExisting(ctx context.Context, userIDs []int32, existing map[int32]*store.User) (map[int32]*store.User, error) {
usersByID := make(map[int32]*store.User, len(existing)+len(userIDs))
for userID, user := range existing {
if user != nil {
usersByID[userID] = user
}
}
missingUserIDs := make([]int32, 0, len(userIDs))
seenMissingUserIDs := make(map[int32]struct{}, len(userIDs))
for _, userID := range userIDs {
if _, ok := usersByID[userID]; ok {
continue
}
if _, ok := seenMissingUserIDs[userID]; ok {
continue
}
seenMissingUserIDs[userID] = struct{}{}
missingUserIDs = append(missingUserIDs, userID)
}
if len(missingUserIDs) == 0 {
return usersByID, nil
}
missingUsersByID, err := s.listUsersByID(ctx, missingUserIDs)
if err != nil {
return nil, err
}
for userID, user := range missingUsersByID {
if user != nil {
usersByID[userID] = user
}
}
return usersByID, nil
}
func (s *APIV1Service) convertReactionsFromStoreWithCreators(ctx context.Context, reactions []*store.Reaction, creatorMap map[int32]*store.User, memoName string) ([]*v1pb.Reaction, error) {
if len(reactions) == 0 {
return []*v1pb.Reaction{}, nil
}
creatorIDs := make([]int32, 0, len(reactions))
for _, reaction := range reactions {
creatorIDs = append(creatorIDs, reaction.CreatorID)
}
creatorsByID, err := s.listUsersByIDWithExisting(ctx, creatorIDs, creatorMap)
if err != nil {
return nil, err
}
reactionMessages := make([]*v1pb.Reaction, 0, len(reactions))
for _, reaction := range reactions {
reactionMessage, err := convertReactionFromStoreWithCreators(reaction, creatorsByID, memoName)
if err != nil {
if stderrors.Is(err, errReactionCreatorNotFound) {
slog.Warn("Skipping reaction with missing creator",
slog.Int64("reaction_id", int64(reaction.ID)),
slog.Int64("creator_id", int64(reaction.CreatorID)),
slog.Int64("memo_id", int64(reaction.MemoID)),
)
continue
}
return nil, err
}
reactionMessages = append(reactionMessages, reactionMessage)
}
return reactionMessages, nil
}
func convertReactionFromStoreWithCreators(reaction *store.Reaction, creatorsByID map[int32]*store.User, memoName string) (*v1pb.Reaction, error) {
creator := creatorsByID[reaction.CreatorID]
if creator == nil {
return nil, errReactionCreatorNotFound
}
reactionUID := fmt.Sprintf("%d", reaction.ID)
return &v1pb.Reaction{
Name: fmt.Sprintf("%s/%s%s", memoName, ReactionNamePrefix, reactionUID),
Creator: BuildUserName(creator.Username),
ReactionType: reaction.ReactionType,
CreateTime: timestamppb.New(time.Unix(reaction.CreatedTs, 0)),
}, nil
}
// batchConvertMemoRelations batch-loads relations for a list of memos and returns
// a map from memo ID to its converted relations. This avoids N+1 queries when listing memos.
func (s *APIV1Service) batchConvertMemoRelations(ctx context.Context, memos []*store.Memo, includeSnippets bool) (map[int32][]*v1pb.MemoRelation, error) {
if len(memos) == 0 {
return map[int32][]*v1pb.MemoRelation{}, nil
}
accessScope, _, err := s.resolveMemoAccessScope(ctx)
if err != nil {
return nil, err
}
memoIDs := make([]int32, len(memos))
memoIDSet := make(map[int32]bool, len(memos))
for i, m := range memos {
memoIDs[i] = m.ID
memoIDSet[m.ID] = true
}
outgoingRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
SourceMemoIDList: memoIDs,
})
if err != nil {
return nil, errors.Wrap(err, "failed to batch list outgoing memo relations")
}
incomingRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
RelatedMemoIDList: memoIDs,
})
if err != nil {
return nil, errors.Wrap(err, "failed to batch list incoming memo relations")
}
allRelations := mergeMemoRelations(outgoingRelations, incomingRelations)
// Collect all memo IDs referenced in relations that we need to resolve.
neededIDs := make(map[int32]bool)
for _, r := range allRelations {
neededIDs[r.MemoID] = true
neededIDs[r.RelatedMemoID] = true
}
// Build ID→UID map from the memos we already have.
memoIDToUID := make(map[int32]string, len(memos))
memoIDToSnippet := make(map[int32]string, len(memos))
for _, m := range memos {
memoIDToUID[m.ID] = m.UID
if includeSnippets {
snippet, err := s.getMemoContentSnippet(m.Content)
if err != nil {
return nil, errors.Wrap(err, "failed to get memo content snippet")
}
memoIDToSnippet[m.ID] = snippet
}
delete(neededIDs, m.ID)
}
// Batch fetch any additional memos referenced by relations that we don't already have.
if len(neededIDs) > 0 {
extraIDs := make([]int32, 0, len(neededIDs))
for id := range neededIDs {
extraIDs = append(extraIDs, id)
}
extraFind := &store.FindMemo{IDList: extraIDs, ExcludeContent: !includeSnippets, Access: accessScope}
extraMemos, err := s.Store.ListMemos(ctx, extraFind)
if err != nil {
return nil, errors.Wrap(err, "failed to batch fetch related memos")
}
for _, m := range extraMemos {
memoIDToUID[m.ID] = m.UID
if includeSnippets {
snippet, err := s.getMemoContentSnippet(m.Content)
if err != nil {
return nil, errors.Wrap(err, "failed to get related memo content snippet")
}
memoIDToSnippet[m.ID] = snippet
}
}
}
// Build the result map: memo ID → its relations (both directions).
result := make(map[int32][]*v1pb.MemoRelation, len(memos))
for _, r := range allRelations {
memoUID, ok1 := memoIDToUID[r.MemoID]
relatedUID, ok2 := memoIDToUID[r.RelatedMemoID]
if !ok1 || !ok2 {
continue
}
relation := &v1pb.MemoRelation{
Memo: &v1pb.MemoRelation_Memo{
Name: fmt.Sprintf("%s%s", MemoNamePrefix, memoUID),
Snippet: memoIDToSnippet[r.MemoID],
},
RelatedMemo: &v1pb.MemoRelation_Memo{
Name: fmt.Sprintf("%s%s", MemoNamePrefix, relatedUID),
Snippet: memoIDToSnippet[r.RelatedMemoID],
},
Type: convertMemoRelationTypeFromStore(r.Type),
}
// Add to the memo that owns this relation (both directions).
if memoIDSet[r.MemoID] {
result[r.MemoID] = append(result[r.MemoID], relation)
}
if memoIDSet[r.RelatedMemoID] {
result[r.RelatedMemoID] = append(result[r.RelatedMemoID], relation)
}
}
return result, nil
}
// loadMemoRelations loads relations for a single memo and converts them to API format.
func (s *APIV1Service) loadMemoRelations(ctx context.Context, memo *store.Memo) ([]*v1pb.MemoRelation, error) {
relationMap, err := s.batchConvertMemoRelations(ctx, []*store.Memo{memo}, true)
if err != nil {
return nil, err
}
return relationMap[memo.ID], nil
}
func mergeMemoRelations(groups ...[]*store.MemoRelation) []*store.MemoRelation {
seen := make(map[string]struct{})
merged := make([]*store.MemoRelation, 0)
for _, relations := range groups {
for _, relation := range relations {
key := fmt.Sprintf("%d:%d:%s", relation.MemoID, relation.RelatedMemoID, relation.Type)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
merged = append(merged, relation)
}
}
return merged
}
func convertMemoPropertyFromStore(property *storepb.MemoPayload_Property) *v1pb.Memo_Property {
if property == nil {
return nil
}
return &v1pb.Memo_Property{
HasLink: property.HasLink,
HasTaskList: property.HasTaskList,
HasCode: property.HasCode,
HasIncompleteTasks: property.HasIncompleteTasks,
Title: property.Title,
}
}
func convertLocationFromStore(location *storepb.MemoPayload_Location) *v1pb.Location {
if location == nil {
return nil
}
return &v1pb.Location{
Placeholder: location.Placeholder,
Latitude: location.Latitude,
Longitude: location.Longitude,
}
}
func convertLocationToStore(location *v1pb.Location) *storepb.MemoPayload_Location {
if location == nil {
return nil
}
return &storepb.MemoPayload_Location{
Placeholder: location.Placeholder,
Latitude: location.Latitude,
Longitude: location.Longitude,
}
}
func convertVisibilityFromStore(visibility store.Visibility) v1pb.Visibility {
switch visibility {
case store.Private:
return v1pb.Visibility_PRIVATE
case store.Protected:
return v1pb.Visibility_PROTECTED
case store.Public:
return v1pb.Visibility_PUBLIC
case store.SpaceAudience:
return v1pb.Visibility_SPACE
default:
return v1pb.Visibility_VISIBILITY_UNSPECIFIED
}
}
func validateCreateMemoVisibility(visibility v1pb.Visibility) (store.Visibility, error) {
switch visibility {
case v1pb.Visibility_VISIBILITY_UNSPECIFIED, v1pb.Visibility_PRIVATE:
return store.Private, nil
case v1pb.Visibility_PROTECTED:
return store.Protected, nil
case v1pb.Visibility_PUBLIC:
return store.Public, nil
case v1pb.Visibility_SPACE:
return store.SpaceAudience, nil
default:
return "", status.Errorf(codes.InvalidArgument, "invalid memo visibility")
}
}
func validateUpdateMemoVisibility(visibility v1pb.Visibility) (store.Visibility, error) {
if visibility == v1pb.Visibility_VISIBILITY_UNSPECIFIED {
return "", status.Errorf(codes.InvalidArgument, "visibility must be specified")
}
return validateCreateMemoVisibility(visibility)
}