570 lines
19 KiB
Go
570 lines
19 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/types/known/emptypb"
|
|
|
|
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"
|
|
)
|
|
|
|
const maxBatchGetLinkMetadata = 10
|
|
|
|
func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoRequest) (*v1pb.Memo, error) {
|
|
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 request.Memo == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "memo is required")
|
|
}
|
|
|
|
memoUID, err := ValidateAndGenerateUID(request.MemoId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
prepared, err := s.prepareMemoCreate(ctx, user, request.Memo, memoUID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.createMemoWithMutation(ctx, user, prepared.memo, nil, prepared.attachments, prepared.requiredAttachmentIDs, prepared.referenceRelations); err != nil {
|
|
return nil, mapMemoCreateError(err, memoUID, "failed to create memo")
|
|
}
|
|
memo := prepared.memo
|
|
|
|
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
|
MemoID: &memo.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to get memo attachments")
|
|
}
|
|
|
|
relations, err := s.loadMemoRelations(ctx, memo)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to load memo relations")
|
|
}
|
|
memoMessage, err := s.convertMemoFromStore(ctx, memo, nil, attachments, relations)
|
|
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))
|
|
}
|
|
|
|
s.SSEHub.publishMemoChanged()
|
|
|
|
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, nil, "")
|
|
|
|
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,
|
|
}
|
|
accessScope, currentUser, err := s.resolveMemoAccessScope(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "%v", err)
|
|
}
|
|
// An anonymous caller may only list at all when the instance permits it.
|
|
if currentUser == nil && !accessScope.AllowPublic {
|
|
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
memoFind.Access = accessScope
|
|
|
|
if request.State == v1pb.State_ARCHIVED {
|
|
state := store.Archived
|
|
memoFind.RowStatus = &state
|
|
// Archived memos are only visible to their creator.
|
|
if currentUser == nil {
|
|
return &v1pb.ListMemosResponse{}, nil
|
|
}
|
|
memoFind.CreatorID = ¤tUser.ID
|
|
} else {
|
|
state := store.Normal
|
|
memoFind.RowStatus = &state
|
|
}
|
|
|
|
// Parse order_by field (replaces the old sort and direction fields)
|
|
if request.OrderBy != "" {
|
|
if err := s.parseMemoOrderBy(request.OrderBy, memoFind); err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid order_by: %v", err)
|
|
}
|
|
} else {
|
|
// Default ordering by create_time desc.
|
|
memoFind.OrderByTimeAsc = false
|
|
}
|
|
|
|
if request.Filter != "" {
|
|
if err := s.validateMemoFilterForUser(ctx, request.Filter, currentUser); err != nil {
|
|
return nil, err
|
|
}
|
|
memoFind.Filters = append(memoFind.Filters, request.Filter)
|
|
}
|
|
|
|
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)
|
|
}
|
|
limit = min(limit, MaxPageSize)
|
|
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)
|
|
}
|
|
}
|
|
|
|
if len(memos) == 0 {
|
|
response := &v1pb.ListMemosResponse{
|
|
Memos: memoMessages,
|
|
NextPageToken: nextPageToken,
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
reactionMap := make(map[int32][]*store.Reaction)
|
|
|
|
attachmentMap := make(map[int32][]*store.Attachment)
|
|
memoIDs := make([]int32, 0, len(memos))
|
|
|
|
for _, m := range memos {
|
|
memoIDs = append(memoIDs, m.ID)
|
|
}
|
|
|
|
// REACTIONS
|
|
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{MemoIDList: memoIDs})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
|
}
|
|
for _, reaction := range reactions {
|
|
reactionMap[reaction.MemoID] = append(reactionMap[reaction.MemoID], reaction)
|
|
}
|
|
|
|
// ATTACHMENTS
|
|
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDs})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
|
}
|
|
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)
|
|
}
|
|
for _, memo := range memos {
|
|
reactions := reactionMap[memo.ID]
|
|
attachments := attachmentMap[memo.ID]
|
|
relations := relationMap[memo.ID]
|
|
|
|
memoMessage, err := s.convertMemoFromStoreWithCreators(ctx, memo, reactions, attachments, relations, creatorMap)
|
|
if err != nil {
|
|
if stderrors.Is(err, errMemoCreatorNotFound) {
|
|
slog.Warn("Skipping memo with missing creator",
|
|
slog.Int64("memo_id", int64(memo.ID)),
|
|
slog.String("memo_uid", memo.UID),
|
|
slog.Int64("creator_id", int64(memo.CreatorID)),
|
|
)
|
|
continue
|
|
}
|
|
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 err := s.checkMemoReadAccess(ctx, memo); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
|
MemoID: &memo.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
|
}
|
|
|
|
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
|
MemoID: &memo.ID,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
|
}
|
|
|
|
relations, err := s.loadMemoRelations(ctx, memo)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to load memo relations")
|
|
}
|
|
memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments, relations)
|
|
if err != nil {
|
|
if stderrors.Is(err, errMemoCreatorNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "memo creator not found")
|
|
}
|
|
return nil, errors.Wrap(err, "failed to convert memo")
|
|
}
|
|
return memoMessage, nil
|
|
}
|
|
|
|
// UpdateMemo updates an existing memo.
|
|
func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoRequest) (*v1pb.Memo, error) {
|
|
if request.Memo == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "memo is required")
|
|
}
|
|
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, status.Errorf(codes.Internal, "failed to get memo: %v", err)
|
|
}
|
|
if memo == nil {
|
|
return nil, status.Errorf(codes.NotFound, "memo not found")
|
|
}
|
|
|
|
user, err := s.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
|
}
|
|
if user == nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
// Application administrators are not implicit memo collaborators. Ordinary
|
|
// content updates remain author-controlled.
|
|
if memo.CreatorID != user.ID {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
|
|
update := &store.UpdateMemo{
|
|
ID: memo.ID,
|
|
}
|
|
updatePaths := make(map[string]bool, len(request.UpdateMask.Paths))
|
|
for _, path := range request.UpdateMask.Paths {
|
|
updatePaths[path] = true
|
|
}
|
|
lifecycleOnly := updatePaths["space"]
|
|
for path := range updatePaths {
|
|
if path != "space" && path != "visibility" {
|
|
lifecycleOnly = false
|
|
}
|
|
}
|
|
nextSpaceID := memo.SpaceID
|
|
if updatePaths["space"] {
|
|
if request.Memo.Space == nil || request.Memo.GetSpace() == "" {
|
|
nextSpaceID = nil
|
|
update.ClearSpace = true
|
|
} else {
|
|
target, err := s.resolveWritableSpaceByName(ctx, request.Memo.GetSpace(), user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nextSpaceID = &target.ID
|
|
update.SpaceID = &target.ID
|
|
update.ClearSpace = false
|
|
}
|
|
}
|
|
|
|
nextVisibility := memo.Visibility
|
|
if updatePaths["visibility"] {
|
|
nextVisibility, err = validateUpdateMemoVisibility(request.Memo.Visibility)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
update.Visibility = &nextVisibility
|
|
}
|
|
spaceChanged := !sameOptionalInt32(memo.SpaceID, nextSpaceID)
|
|
// A removed author receives a lifecycle exception only for a real withdraw
|
|
// or move. Merely including the current Space in the mask must not smuggle
|
|
// an audience-only mutation past membership checks.
|
|
lifecycleOnly = lifecycleOnly && spaceChanged
|
|
if nextVisibility == store.SpaceAudience && nextSpaceID == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "SPACE visibility requires a space")
|
|
}
|
|
if memo.Visibility == store.SpaceAudience && spaceChanged && !updatePaths["visibility"] {
|
|
return nil, status.Errorf(codes.InvalidArgument, "moving a memo with SPACE visibility requires an explicit visibility update")
|
|
}
|
|
if memo.Visibility == store.SpaceAudience && nextSpaceID == nil && nextVisibility == store.SpaceAudience {
|
|
return nil, status.Errorf(codes.InvalidArgument, "unassigning a memo with SPACE visibility requires a replacement visibility")
|
|
}
|
|
update.Policy = memoWritePolicy(user.ID, lifecycleOnly)
|
|
previousContent := memo.Content
|
|
contentUpdated := false
|
|
attachmentsUpdated := false
|
|
relationsUpdated := false
|
|
nextMemo := *memo
|
|
if memo.Payload != nil {
|
|
nextMemo.Payload = &storepb.MemoPayload{}
|
|
proto.Merge(nextMemo.Payload, memo.Payload)
|
|
}
|
|
|
|
for _, path := range request.UpdateMask.Paths {
|
|
// Collaboration fields were validated together above so placement and
|
|
// audience transitions cannot be observed independently.
|
|
if path == "visibility" || path == "space" {
|
|
continue
|
|
}
|
|
if path == "content" {
|
|
contentUpdated = true
|
|
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)
|
|
}
|
|
nextMemo.Content = request.Memo.Content
|
|
if err := memopayload.RebuildMemoPayload(ctx, &nextMemo, s.MarkdownService); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
|
|
}
|
|
update.Content = &nextMemo.Content
|
|
update.Payload = nextMemo.Payload
|
|
} 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" {
|
|
if request.Memo.CreateTime == nil || !request.Memo.CreateTime.IsValid() {
|
|
return nil, status.Errorf(codes.InvalidArgument, "create_time is invalid")
|
|
}
|
|
createdTs := request.Memo.CreateTime.AsTime().Unix()
|
|
update.CreatedTs = &createdTs
|
|
} else if path == "update_time" {
|
|
updatedTsSec := time.Now().Unix()
|
|
if request.Memo.UpdateTime != nil {
|
|
if !request.Memo.UpdateTime.IsValid() {
|
|
return nil, status.Errorf(codes.InvalidArgument, "update_time is invalid")
|
|
}
|
|
updatedTsSec = request.Memo.UpdateTime.AsTime().Unix()
|
|
}
|
|
update.UpdatedTs = &updatedTsSec
|
|
} else if path == "display_time" {
|
|
return nil, status.Errorf(codes.InvalidArgument, "display_time is not supported")
|
|
} else if path == "location" {
|
|
if nextMemo.Payload == nil {
|
|
nextMemo.Payload = &storepb.MemoPayload{}
|
|
}
|
|
nextMemo.Payload.Location = convertLocationToStore(request.Memo.Location)
|
|
update.Payload = nextMemo.Payload
|
|
} else if path == "attachments" {
|
|
attachmentsUpdated = true
|
|
} else if path == "relations" {
|
|
relationsUpdated = true
|
|
} else {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", path)
|
|
}
|
|
}
|
|
|
|
var preparedAttachments *preparedMemoAttachments
|
|
if attachmentsUpdated {
|
|
preparedAttachments, err = s.prepareMemoAttachments(ctx, user, memo, request.Memo.Attachments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var preparedRelations []*store.MemoRelation
|
|
if relationsUpdated {
|
|
preparedRelations, err = s.prepareMemoRelations(ctx, memo, request.Memo.Relations)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
var requiredAttachmentIDs []int32
|
|
if contentUpdated || attachmentsUpdated {
|
|
var finalAttachments []*store.Attachment
|
|
if preparedAttachments != nil {
|
|
finalAttachments = preparedAttachments.normalized
|
|
} else {
|
|
finalAttachments, err = s.Store.ListAttachments(ctx, &store.FindAttachment{MemoID: &memo.ID})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
|
}
|
|
}
|
|
requiredAttachmentIDs, err = s.resolveMemoAttachmentReferences(nextMemo.Content, finalAttachments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if contentUpdated || attachmentsUpdated || relationsUpdated {
|
|
var relations *[]*store.MemoRelation
|
|
if relationsUpdated {
|
|
relations = &preparedRelations
|
|
}
|
|
if err := s.applyMemoMutation(ctx, memo, preparedAttachments, update, requiredAttachmentIDs, relations); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err = s.Store.UpdateMemo(ctx, update); err != nil {
|
|
return nil, mapMemoWriteError(err, "failed to update memo")
|
|
}
|
|
|
|
memo, commentContext, memoMessage, err := s.buildUpdatedMemoState(ctx, memo.ID)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to build updated memo state")
|
|
}
|
|
if contentUpdated {
|
|
s.dispatchMemoMentionNotificationsBestEffort(ctx, memo, commentContext, previousContent)
|
|
}
|
|
s.dispatchMemoUpdatedSideEffects(ctx, memoMessage)
|
|
|
|
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.fetchCurrentUser(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
|
}
|
|
if user == nil {
|
|
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
|
}
|
|
// Application administrators do not receive ordinary content-deletion
|
|
// authority merely from their instance role.
|
|
if memo.CreatorID != user.ID {
|
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
|
}
|
|
var deletedMemoMessage *v1pb.Memo
|
|
// Deletion is a narrow author lifecycle capability and may remain available
|
|
// after the author loses read access to a memo with the SPACE audience. Only
|
|
// build a content-bearing webhook payload when the author can still read the
|
|
// memo immediately before deletion.
|
|
if s.checkMemoReadAccess(ctx, memo) == nil {
|
|
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{MemoID: &memo.ID})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
|
}
|
|
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoID: &memo.ID})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
|
}
|
|
deleteRelations, _ := s.loadMemoRelations(ctx, memo)
|
|
if memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments, deleteRelations); err == nil {
|
|
deletedMemoMessage = memoMessage
|
|
}
|
|
}
|
|
|
|
deleteResult, err := s.Store.DeleteMemoWithPolicy(ctx, &store.DeleteMemoWithPolicy{MemoID: memo.ID, ActorUserID: user.ID})
|
|
if err != nil {
|
|
switch {
|
|
case stderrors.Is(err, store.ErrMemoPermissionDenied):
|
|
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
|
case stderrors.Is(err, store.ErrMemoMutationConflict):
|
|
return nil, status.Error(codes.FailedPrecondition, "memo changed or no longer exists")
|
|
default:
|
|
return nil, status.Errorf(codes.Internal, "failed to delete memo: %v", err)
|
|
}
|
|
}
|
|
if deleteResult == nil {
|
|
return nil, status.Error(codes.Internal, "memo was deleted without authorization state")
|
|
}
|
|
|
|
s.SSEHub.publishMemoChanged()
|
|
if deletedMemoMessage != nil && deleteResult.ActorCanRead {
|
|
if err := s.DispatchMemoDeletedWebhook(ctx, deletedMemoMessage); err != nil {
|
|
slog.Warn("Failed to dispatch memo deleted webhook", slog.Any("err", err))
|
|
}
|
|
}
|
|
if err := s.cleanupDeletedAttachmentStorage(ctx, deleteResult.Attachments); err != nil {
|
|
return nil, status.Errorf(codes.Internal, "memo was deleted but attachment storage cleanup failed: %v", err)
|
|
}
|
|
|
|
return &emptypb.Empty{}, nil
|
|
}
|
|
|
|
func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) {
|
|
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
|
|
if err != nil {
|
|
return 0, status.Errorf(codes.Internal, "failed to get instance memo related setting")
|
|
}
|
|
return int(instanceMemoRelatedSetting.ContentLengthLimit), nil
|
|
}
|
|
|
|
// DispatchMemoCreatedWebhook dispatches webhook when memo is created.
|