package v1 import ( "context" "fmt" "log/slog" "time" "unicode/utf8" "github.com/lithammer/shortuuid/v4" "github.com/pkg/errors" "github.com/usememos/gomark/ast" "github.com/usememos/gomark/parser" "github.com/usememos/gomark/parser/tokenizer" "github.com/usememos/gomark/renderer" "github.com/usememos/gomark/restore" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/usememos/memos/plugin/webhook" v1pb "github.com/usememos/memos/proto/gen/api/v1" storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/server/runner/memopayload" "github.com/usememos/memos/store" ) func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoRequest) (*v1pb.Memo, error) { user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user") } create := &store.Memo{ UID: shortuuid.New(), CreatorID: user.ID, Content: request.Memo.Content, Visibility: convertVisibilityToStore(request.Memo.Visibility), } workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting") } if workspaceMemoRelatedSetting.DisallowPublicVisibility && create.Visibility == store.Public { return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled") } contentLengthLimit, err := s.getContentLengthLimit(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get content length limit") } if len(create.Content) > contentLengthLimit { return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit) } if err := memopayload.RebuildMemoPayload(create); err != nil { return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) } if request.Memo.Location != nil { create.Payload.Location = convertLocationToStore(request.Memo.Location) } memo, err := s.Store.CreateMemo(ctx, create) if err != nil { return nil, err } if len(request.Memo.Resources) > 0 { _, err := s.SetMemoResources(ctx, &v1pb.SetMemoResourcesRequest{ Name: fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID), Resources: request.Memo.Resources, }) if err != nil { return nil, errors.Wrap(err, "failed to set memo resources") } } if len(request.Memo.Relations) > 0 { _, err := s.SetMemoRelations(ctx, &v1pb.SetMemoRelationsRequest{ Name: fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID), Relations: request.Memo.Relations, }) if err != nil { return nil, errors.Wrap(err, "failed to set memo relations") } } memoMessage, err := s.convertMemoFromStore(ctx, memo) if err != nil { return nil, errors.Wrap(err, "failed to convert memo") } // Try to dispatch webhook when memo is created. if err := s.DispatchMemoCreatedWebhook(ctx, memoMessage); err != nil { slog.Warn("Failed to dispatch memo created webhook", slog.Any("err", err)) } return memoMessage, nil } func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosRequest) (*v1pb.ListMemosResponse, error) { memoFind := &store.FindMemo{ // Exclude comments by default. ExcludeComments: true, } if err := s.buildMemoFindWithFilter(ctx, memoFind, request.OldFilter); err != nil { return nil, status.Errorf(codes.InvalidArgument, "failed to build find memos with filter: %v", err) } if request.Parent != "" && request.Parent != "users/-" { userID, err := ExtractUserIDFromName(request.Parent) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid parent: %v", err) } memoFind.CreatorID = &userID memoFind.OrderByPinned = true } if request.State == v1pb.State_ARCHIVED { state := store.Archived memoFind.RowStatus = &state } else { state := store.Normal memoFind.RowStatus = &state } if request.Direction == v1pb.Direction_ASC { memoFind.OrderByTimeAsc = true } if request.Filter != "" { memoFind.Filter = &request.Filter } currentUser, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user") } if currentUser == nil { memoFind.VisibilityList = []store.Visibility{store.Public} } else if memoFind.CreatorID == nil || *memoFind.CreatorID != currentUser.ID { memoFind.VisibilityList = []store.Visibility{store.Public, store.Protected} } workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting") } if workspaceMemoRelatedSetting.DisplayWithUpdateTime { memoFind.OrderByUpdatedTs = true } 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 = int(pageToken.Limit) offset = int(pageToken.Offset) } else { limit = int(request.PageSize) } if limit <= 0 { limit = DefaultPageSize } limitPlusOne := limit + 1 memoFind.Limit = &limitPlusOne memoFind.Offset = &offset memos, err := s.Store.ListMemos(ctx, memoFind) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err) } memoMessages := []*v1pb.Memo{} nextPageToken := "" if len(memos) == limitPlusOne { memos = memos[: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) } } for _, memo := range memos { memoMessage, err := s.convertMemoFromStore(ctx, memo) if err != nil { return nil, errors.Wrap(err, "failed to convert memo") } memoMessages = append(memoMessages, memoMessage) } response := &v1pb.ListMemosResponse{ Memos: memoMessages, NextPageToken: nextPageToken, } return response, nil } func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest) (*v1pb.Memo, 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, err } if memo == nil { return nil, status.Errorf(codes.NotFound, "memo not found") } if memo.Visibility != store.Public { user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user") } if user == nil { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } if memo.Visibility == store.Private && memo.CreatorID != user.ID { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } } memoMessage, err := s.convertMemoFromStore(ctx, memo) if err != nil { return nil, errors.Wrap(err, "failed to convert memo") } return memoMessage, nil } func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoRequest) (*v1pb.Memo, error) { memoUID, err := ExtractMemoUIDFromName(request.Memo.Name) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err) } if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 { return nil, status.Errorf(codes.InvalidArgument, "update mask is required") } memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID}) if err != nil { return nil, err } if memo == nil { return nil, status.Errorf(codes.NotFound, "memo not found") } user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user") } // Only the creator or admin can update the memo. if memo.CreatorID != user.ID && !isSuperUser(user) { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } update := &store.UpdateMemo{ ID: memo.ID, } for _, path := range request.UpdateMask.Paths { if path == "content" { contentLengthLimit, err := s.getContentLengthLimit(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get content length limit") } if len(request.Memo.Content) > contentLengthLimit { return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit) } memo.Content = request.Memo.Content if err := memopayload.RebuildMemoPayload(memo); err != nil { return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) } update.Content = &memo.Content update.Payload = memo.Payload } else if path == "visibility" { workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting") } visibility := convertVisibilityToStore(request.Memo.Visibility) if workspaceMemoRelatedSetting.DisallowPublicVisibility && visibility == store.Public { return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled") } update.Visibility = &visibility } else if path == "pinned" { update.Pinned = &request.Memo.Pinned } else if path == "state" { rowStatus := convertStateToStore(request.Memo.State) update.RowStatus = &rowStatus } else if path == "create_time" { createdTs := request.Memo.CreateTime.AsTime().Unix() update.CreatedTs = &createdTs } else if path == "update_time" { updatedTs := time.Now().Unix() if request.Memo.UpdateTime != nil { updatedTs = request.Memo.UpdateTime.AsTime().Unix() } update.UpdatedTs = &updatedTs } else if path == "display_time" { displayTs := request.Memo.DisplayTime.AsTime().Unix() memoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting") } if memoRelatedSetting.DisplayWithUpdateTime { update.UpdatedTs = &displayTs } else { update.CreatedTs = &displayTs } } else if path == "location" { payload := memo.Payload payload.Location = convertLocationToStore(request.Memo.Location) update.Payload = payload } else if path == "resources" { _, err := s.SetMemoResources(ctx, &v1pb.SetMemoResourcesRequest{ Name: request.Memo.Name, Resources: request.Memo.Resources, }) if err != nil { return nil, errors.Wrap(err, "failed to set memo resources") } } else if path == "relations" { _, err := s.SetMemoRelations(ctx, &v1pb.SetMemoRelationsRequest{ Name: request.Memo.Name, Relations: request.Memo.Relations, }) if err != nil { return nil, errors.Wrap(err, "failed to set memo relations") } } } if err = s.Store.UpdateMemo(ctx, update); err != nil { return nil, status.Errorf(codes.Internal, "failed to update memo") } memo, err = s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memo.ID, }) if err != nil { return nil, errors.Wrap(err, "failed to get memo") } memoMessage, err := s.convertMemoFromStore(ctx, memo) if err != nil { return nil, errors.Wrap(err, "failed to convert memo") } // Try to dispatch webhook when memo is updated. if err := s.DispatchMemoUpdatedWebhook(ctx, memoMessage); err != nil { slog.Warn("Failed to dispatch memo updated webhook", slog.Any("err", err)) } return memoMessage, nil } func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoRequest) (*emptypb.Empty, 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, err } if memo == nil { return nil, status.Errorf(codes.NotFound, "memo not found") } user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user") } // Only the creator or admin can update the memo. if memo.CreatorID != user.ID && !isSuperUser(user) { return nil, status.Errorf(codes.PermissionDenied, "permission denied") } if memoMessage, err := s.convertMemoFromStore(ctx, memo); err == nil { // Try to dispatch webhook when memo is deleted. if err := s.DispatchMemoDeletedWebhook(ctx, memoMessage); err != nil { slog.Warn("Failed to dispatch memo deleted webhook", slog.Any("err", err)) } } if err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}); err != nil { return nil, status.Errorf(codes.Internal, "failed to delete memo") } // Delete memo relation if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{MemoID: &memo.ID}); err != nil { return nil, status.Errorf(codes.Internal, "failed to delete memo relations") } // Delete related resources. resources, err := s.Store.ListResources(ctx, &store.FindResource{MemoID: &memo.ID}) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list resources") } for _, resource := range resources { if err := s.Store.DeleteResource(ctx, &store.DeleteResource{ID: resource.ID}); err != nil { return nil, status.Errorf(codes.Internal, "failed to delete resource") } } // Delete memo comments commentType := store.MemoRelationComment relations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{RelatedMemoID: &memo.ID, Type: &commentType}) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list memo comments") } for _, relation := range relations { if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: relation.MemoID}); err != nil { return nil, status.Errorf(codes.Internal, "failed to delete memo comment") } } // Delete memo references referenceType := store.MemoRelationReference if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{RelatedMemoID: &memo.ID, Type: &referenceType}); err != nil { return nil, status.Errorf(codes.Internal, "failed to delete memo references") } return &emptypb.Empty{}, nil } 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") } // Create the memo comment first. memoComment, err := s.CreateMemo(ctx, &v1pb.CreateMemoRequest{Memo: request.Comment}) 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") } creatorID, err := ExtractUserIDFromName(memoComment.Creator) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator") } if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID { activity, err := s.Store.CreateActivity(ctx, &store.Activity{ CreatorID: creatorID, Type: store.ActivityTypeMemoComment, Level: store.ActivityLevelInfo, Payload: &storepb.ActivityPayload{ MemoComment: &storepb.ActivityMemoCommentPayload{ MemoId: memo.ID, RelatedMemoId: relatedMemo.ID, }, }, }) if err != nil { return nil, status.Errorf(codes.Internal, "failed to create activity") } if _, err := s.Store.CreateInbox(ctx, &store.Inbox{ SenderID: creatorID, ReceiverID: relatedMemo.CreatorID, Status: store.UNREAD, Message: &storepb.InboxMessage{ Type: storepb.InboxMessage_MEMO_COMMENT, ActivityId: &activity.ID, }, }); err != nil { return nil, status.Errorf(codes.Internal, "failed to create inbox") } } 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") } memoRelationComment := store.MemoRelationComment memoRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{ RelatedMemoID: &memo.ID, Type: &memoRelationComment, }) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list memo relations") } var memos []*v1pb.Memo for _, memoRelation := range memoRelations { memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ ID: &memoRelation.MemoID, }) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get memo") } if memo != nil { memoMessage, err := s.convertMemoFromStore(ctx, memo) if err != nil { return nil, errors.Wrap(err, "failed to convert memo") } memos = append(memos, memoMessage) } } response := &v1pb.ListMemoCommentsResponse{ Memos: memos, } return response, nil } func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMemoTagRequest) (*emptypb.Empty, error) { user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user") } memoFind := &store.FindMemo{ CreatorID: &user.ID, PayloadFind: &store.FindMemoPayload{TagSearch: []string{request.OldTag}}, ExcludeComments: true, } if (request.Parent) != "memos/-" { memoUID, err := ExtractMemoUIDFromName(request.Parent) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err) } memoFind.UID = &memoUID } memos, err := s.Store.ListMemos(ctx, memoFind) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list memos") } for _, memo := range memos { nodes, err := parser.Parse(tokenizer.Tokenize(memo.Content)) if err != nil { return nil, status.Errorf(codes.Internal, "failed to parse memo: %v", err) } memopayload.TraverseASTNodes(nodes, func(node ast.Node) { if tag, ok := node.(*ast.Tag); ok && tag.Content == request.OldTag { tag.Content = request.NewTag } }) memo.Content = restore.Restore(nodes) if err := memopayload.RebuildMemoPayload(memo); err != nil { return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) } if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{ ID: memo.ID, Content: &memo.Content, Payload: memo.Payload, }); err != nil { return nil, status.Errorf(codes.Internal, "failed to update memo: %v", err) } } return &emptypb.Empty{}, nil } func (s *APIV1Service) DeleteMemoTag(ctx context.Context, request *v1pb.DeleteMemoTagRequest) (*emptypb.Empty, error) { user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user") } memoFind := &store.FindMemo{ CreatorID: &user.ID, PayloadFind: &store.FindMemoPayload{TagSearch: []string{request.Tag}}, ExcludeContent: true, ExcludeComments: true, } if request.Parent != "memos/-" { memoUID, err := ExtractMemoUIDFromName(request.Parent) if err != nil { return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err) } memoFind.UID = &memoUID } memos, err := s.Store.ListMemos(ctx, memoFind) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list memos") } for _, memo := range memos { if request.DeleteRelatedMemos { err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}) if err != nil { return nil, status.Errorf(codes.Internal, "failed to delete memo") } } else { archived := store.Archived err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{ ID: memo.ID, RowStatus: &archived, }) if err != nil { return nil, status.Errorf(codes.Internal, "failed to update memo") } } } return &emptypb.Empty{}, nil } func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) { workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx) if err != nil { return 0, status.Errorf(codes.Internal, "failed to get workspace memo related setting") } return int(workspaceMemoRelatedSetting.ContentLengthLimit), nil } // DispatchMemoCreatedWebhook dispatches webhook when memo is created. func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *v1pb.Memo) error { return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created") } // DispatchMemoUpdatedWebhook dispatches webhook when memo is updated. func (s *APIV1Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *v1pb.Memo) error { return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated") } // DispatchMemoDeletedWebhook dispatches webhook when memo is deleted. func (s *APIV1Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *v1pb.Memo) error { return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.deleted") } func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1pb.Memo, activityType string) error { creatorID, err := ExtractUserIDFromName(memo.Creator) if err != nil { return status.Errorf(codes.InvalidArgument, "invalid memo creator") } webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{ CreatorID: &creatorID, }) if err != nil { return err } for _, hook := range webhooks { payload, err := convertMemoToWebhookPayload(memo) if err != nil { return errors.Wrap(err, "failed to convert memo to webhook payload") } payload.ActivityType = activityType payload.Url = hook.URL if err := webhook.Post(payload); err != nil { return errors.Wrap(err, "failed to post webhook") } } return nil } func convertMemoToWebhookPayload(memo *v1pb.Memo) (*v1pb.WebhookRequestPayload, error) { creatorID, err := ExtractUserIDFromName(memo.Creator) if err != nil { return nil, errors.Wrap(err, "invalid memo creator") } return &v1pb.WebhookRequestPayload{ Creator: fmt.Sprintf("%s%d", UserNamePrefix, creatorID), CreateTime: timestamppb.New(time.Now()), Memo: memo, }, nil } func getMemoContentSnippet(content string) (string, error) { nodes, err := parser.Parse(tokenizer.Tokenize(content)) if err != nil { return "", errors.Wrap(err, "failed to parse content") } plainText := renderer.NewStringRenderer().Render(nodes) if len(plainText) > 64 { return substring(plainText, 64) + "...", nil } return plainText, nil } func substring(s string, length int) string { if length <= 0 { return "" } runeCount := 0 byteIndex := 0 for byteIndex < len(s) { _, size := utf8.DecodeRuneInString(s[byteIndex:]) byteIndex += size runeCount++ if runeCount == length { break } } return s[:byteIndex] }