package store import ( "context" "database/sql" "encoding/json" "github.com/pkg/errors" "google.golang.org/protobuf/encoding/protojson" "github.com/usememos/memos/internal/identifier" storepb "github.com/usememos/memos/proto/gen/store" ) // ErrMemoSpaceNotWritable indicates that a memo targets a missing or otherwise // invalid Space. var ErrMemoSpaceNotWritable = errors.New("memo space is not writable") // ErrMemoSpaceMembershipRequired indicates that the memo creator is not an // active member of its assigned space. var ErrMemoSpaceMembershipRequired = errors.New("active space membership required") // ErrMemoPermissionDenied indicates that the actor no longer owns the memo // being mutated. var ErrMemoPermissionDenied = errors.New("memo mutation permission denied") // ErrMemoShareConflict indicates that an active share conflicts with a // SPACE audience transition. var ErrMemoShareConflict = errors.New("active memo share conflicts with audience") // Visibility is the type of a visibility. type Visibility string const ( // Public is the PUBLIC visibility. Public Visibility = "PUBLIC" // Protected is the PROTECTED visibility. Protected Visibility = "PROTECTED" // Private is the PRIVATE visibility. Private Visibility = "PRIVATE" // SpaceAudience is visible only to active members of its space. SpaceAudience Visibility = "SPACE" ) func (v Visibility) String() string { return string(v) } type Memo struct { // ID is the system generated unique identifier for the memo. ID int32 // UID is the user defined unique identifier for the memo. UID string // Standard fields RowStatus RowStatus CreatorID int32 CreatedTs int64 UpdatedTs int64 // Domain specific fields Content string Visibility Visibility Pinned bool Payload *storepb.MemoPayload SpaceID *int32 // Composed fields ParentUID *string } type FindMemo struct { ID *int32 UID *string IDList []int32 UIDList []string // Standard fields RowStatus *RowStatus CreatorID *int32 // Domain specific fields VisibilityList []Visibility CommentContextMemoID *int32 Access *MemoAccessScope ExcludeContent bool ExcludeComments bool Filters []string // Pagination Limit *int Offset *int // Ordering OrderByPinned bool OrderByUpdatedTs bool OrderByTimeAsc bool } type FindMemoPayload struct { Raw *string TagSearch []string HasLink bool HasTaskList bool HasCode bool HasIncompleteTasks bool } // RefreshMemoDerivedPayload replaces the content-derived part of a memo's // payload, its tags and property, computed from Content. type RefreshMemoDerivedPayload struct { ID int32 // Content is the memo content the tags and property were computed from. // The refresh applies only while the stored content still equals it. Content string Tags []string Property *storepb.MemoPayload_Property } // JSON returns the tags and property encoded the way memo payloads are stored. func (r *RefreshMemoDerivedPayload) JSON() (tags string, property string, err error) { tagList := r.Tags if tagList == nil { tagList = []string{} } tagBytes, err := json.Marshal(tagList) if err != nil { return "", "", errors.Wrap(err, "failed to marshal memo tags") } prop := r.Property if prop == nil { prop = &storepb.MemoPayload_Property{} } propertyBytes, err := protojson.Marshal(prop) if err != nil { return "", "", errors.Wrap(err, "failed to marshal memo property") } return string(tagBytes), string(propertyBytes), nil } type UpdateMemo struct { ID int32 UID *string CreatedTs *int64 UpdatedTs *int64 RowStatus *RowStatus Content *string Visibility *Visibility Pinned *bool Payload *storepb.MemoPayload SpaceID *int32 ClearSpace bool // Policy is set by transport-facing author mutations. Drivers revalidate it // in the same transaction as the update; nil preserves trusted internal and // migration callers. Policy *MemoWritePolicy } // MemoWritePolicy identifies the actor and the intended kind of // transport-facing mutation. Drivers discover and authorize the current memo, // Space, membership, and share state in the write transaction. type MemoWritePolicy struct { ActorUserID int32 // LifecycleOnly permits an author who is no longer a member of the source // Space to move or withdraw the memo. It never permits content, metadata, // attachment, relation, or share mutations. LifecycleOnly bool // CreatingShare rejects a share if the memo's current audience is // SPACE. Share revocation deliberately leaves this false. CreatingShare bool } // MemoActorState is the actor's user row state as read inside a mutation // transaction. Admin marks an active instance administrator; see // access.IsInstanceAdmin for what that grants. A missing user is the zero // value. type MemoActorState struct { Active bool Admin bool } // NewMemoActorState derives the actor state from a user row. An archived // administrator is neither active nor privileged. func NewMemoActorState(rowStatus RowStatus, role Role) MemoActorState { active := rowStatus == Normal return MemoActorState{Active: active, Admin: active && role == RoleAdmin} } // MemoWriteSnapshot is the current database state used to validate a // MemoWritePolicy. Driver packages populate it in the mutation transaction. type MemoWriteSnapshot struct { // ActorIsAdmin is true when the policy actor is an active instance // administrator. It bypasses authorship and membership checks only. ActorIsAdmin bool CreatorID int32 RowStatus RowStatus SpaceID *int32 Visibility Visibility SourceSpaceExists bool SourceMemberActive bool TargetSpaceExists bool TargetMemberActive bool HasActiveShare bool } // MemoAccessScope is a typed, fail-closed read authorization predicate. When // present on FindMemo, drivers apply it in SQL before pagination. type MemoAccessScope struct { UserID *int32 AllowPublic bool AllowProtected bool } type DeleteMemo struct { ID int32 } func (s *Store) CreateMemo(ctx context.Context, create *Memo) (*Memo, error) { if err := validateMemoCreate(create); err != nil { return nil, err } return s.driver.CreateMemo(ctx, create) } // CreateMemoComment atomically creates one independent memo and its immutable // COMMENT relation to a context memo. func (s *Store) CreateMemoComment(ctx context.Context, create *Memo, contextMemoID, actorUserID int32) (*Memo, error) { if create == nil || contextMemoID <= 0 || actorUserID <= 0 || create.CreatorID != actorUserID { return nil, errors.New("comment creation requires memo, context, and matching actor") } if create.Visibility == "" { create.Visibility = Private } if err := s.ApplyMemoMutation(ctx, &MemoMutation{ MemoCreate: create, CommentContextMemoID: &contextMemoID, MemoCreatorID: actorUserID, ExpectedMemoContent: create.Content, }); err != nil { return nil, err } return create, nil } func validateMemoCreate(create *Memo) error { if create == nil { return errors.New("memo is required") } if !identifier.UIDMatcher.MatchString(create.UID) { return errors.New("invalid uid") } if !isValidVisibility(create.Visibility) { return errors.New("invalid visibility") } if create.Visibility == SpaceAudience && create.SpaceID == nil { return errors.New("SPACE visibility requires a space") } return nil } func (s *Store) ListMemos(ctx context.Context, find *FindMemo) ([]*Memo, error) { return s.driver.ListMemos(ctx, find) } // CountUserMemos counts the memos a user created, comments included, in every // row status and Space. func (s *Store) CountUserMemos(ctx context.Context, creatorID int32) (int64, error) { return s.driver.CountUserMemos(ctx, creatorID) } func (s *Store) GetMemo(ctx context.Context, find *FindMemo) (*Memo, error) { list, err := s.ListMemos(ctx, find) if err != nil { return nil, err } if len(list) == 0 { return nil, nil } memo := list[0] return memo, nil } func (s *Store) UpdateMemo(ctx context.Context, update *UpdateMemo) error { if update.UID != nil && !identifier.UIDMatcher.MatchString(*update.UID) { return errors.New("invalid uid") } if update.Visibility != nil && !isValidVisibility(*update.Visibility) { return errors.New("invalid visibility") } if update.ClearSpace && update.Visibility != nil && *update.Visibility == SpaceAudience { return errors.New("SPACE visibility requires a space") } if err := validateMemoWritePolicy(update.Policy); err != nil { return err } if update.Policy == nil { return s.driver.UpdateMemo(ctx, update) } return s.driver.UpdateMemo(ctx, update) } // RefreshMemoDerivedPayload rewrites a memo's payload tags and property in // place, and only if the memo's content has not changed since it was read. It // leaves every other payload field, such as the location, and updated_ts as // they are, so a background rebuild can never undo a concurrent edit. A memo // that was edited or deleted in the meantime is left alone. func (s *Store) RefreshMemoDerivedPayload(ctx context.Context, refresh *RefreshMemoDerivedPayload) error { return s.driver.RefreshMemoDerivedPayload(ctx, refresh) } func validateMemoWritePolicy(policy *MemoWritePolicy) error { if policy == nil { return nil } if policy.ActorUserID <= 0 { return errors.New("memo write policy requires actor") } if policy.LifecycleOnly && policy.CreatingShare { return errors.New("memo write policy has conflicting actions") } return nil } // ValidateMemoWriteSnapshot applies the transport-independent write // invariants to current database state. func ValidateMemoWriteSnapshot(policy *MemoWritePolicy, update *UpdateMemo, snapshot *MemoWriteSnapshot) error { if err := validateMemoWritePolicy(policy); err != nil { return err } if policy == nil || snapshot == nil { return errors.New("memo write policy snapshot is required") } // An instance administrator is the superuser for named memo operations and // is not held to authorship or Space membership. Structural validity below // still applies to every actor. if !snapshot.ActorIsAdmin && snapshot.CreatorID != policy.ActorUserID { return ErrMemoPermissionDenied } if snapshot.RowStatus != Normal && snapshot.RowStatus != Archived { return ErrMemoMutationConflict } if policy.CreatingShare && snapshot.RowStatus != Normal { return ErrMemoMutationConflict } if !isValidVisibility(snapshot.Visibility) { return ErrMemoMutationConflict } if policy.LifecycleOnly && !isLifecycleOnlyMemoUpdate(update) { // The source-writability exception exists only for an author's explicit // move or withdrawal after membership removal. It must never authorize a // content or metadata mutation in the same transaction. return ErrMemoSpaceMembershipRequired } if snapshot.SpaceID != nil { if !snapshot.SourceSpaceExists { return ErrMemoSpaceNotWritable } if !policy.LifecycleOnly && !snapshot.ActorIsAdmin && !snapshot.SourceMemberActive { return ErrMemoSpaceMembershipRequired } } resultSpaceID := snapshot.SpaceID resultVisibility := snapshot.Visibility if update != nil { if update.SpaceID != nil { if !snapshot.TargetSpaceExists { return ErrMemoSpaceNotWritable } if !snapshot.ActorIsAdmin && !snapshot.TargetMemberActive { return ErrMemoSpaceMembershipRequired } resultSpaceID = update.SpaceID } else if update.ClearSpace { resultSpaceID = nil } if update.Visibility != nil { resultVisibility = *update.Visibility } } if !isValidVisibility(resultVisibility) { return ErrMemoMutationConflict } placementChanged := !sameMemoSpace(resultSpaceID, snapshot.SpaceID) if placementChanged && snapshot.Visibility == SpaceAudience && (update == nil || update.Visibility == nil) { // Moving a current SPACE memo changes which membership grants // access. Require the audience to be explicitly confirmed in the same // mutation, even if a caller read an older audience before the transaction. return ErrMemoMutationConflict } if policy.LifecycleOnly && !placementChanged { return ErrMemoSpaceMembershipRequired } if resultVisibility == SpaceAudience && resultSpaceID == nil { return ErrMemoSpaceNotWritable } if policy.CreatingShare && resultVisibility == SpaceAudience { return ErrMemoShareConflict } if update != nil && update.Visibility != nil && *update.Visibility == SpaceAudience && snapshot.HasActiveShare { return ErrMemoShareConflict } return nil } func isLifecycleOnlyMemoUpdate(update *UpdateMemo) bool { if update == nil || (!update.ClearSpace && update.SpaceID == nil) || (update.ClearSpace && update.SpaceID != nil) { return false } return update.UID == nil && update.CreatedTs == nil && update.UpdatedTs == nil && update.RowStatus == nil && update.Content == nil && update.Pinned == nil && update.Payload == nil } // NullInt32Pointer converts a nullable SQL integer to an optional int32. func NullInt32Pointer(value sql.NullInt64) *int32 { if !value.Valid { return nil } result := int32(value.Int64) return &result } func sameMemoSpace(left, right *int32) bool { if left == nil || right == nil { return left == nil && right == nil } return *left == *right } func isValidVisibility(visibility Visibility) bool { return visibility == Public || visibility == Protected || visibility == Private || visibility == SpaceAudience } func (s *Store) DeleteMemo(ctx context.Context, delete *DeleteMemo) error { if delete == nil || delete.ID <= 0 { return errors.New("memo deletion requires a memo") } memo, err := s.GetMemo(ctx, &FindMemo{ID: &delete.ID}) if err != nil { return err } if memo == nil { return nil } result, err := s.DeleteMemoWithPolicy(ctx, &DeleteMemoWithPolicy{MemoID: memo.ID, ActorUserID: memo.CreatorID}) if err != nil { return err } return s.deleteAttachmentStorageSnapshots(ctx, result.Attachments) }