memos/store/memo.go

388 lines
11 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"github.com/usememos/memos/internal/base"
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
SpaceID *int32
CommentContextMemoID *int32
Unassigned bool
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
}
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
}
// MemoWriteSnapshot is the current database state used to validate a
// MemoWritePolicy. Driver packages populate it in the mutation transaction.
type MemoWriteSnapshot struct {
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 !base.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)
}
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 && !base.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)
}
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")
}
if 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.SourceMemberActive {
return ErrMemoSpaceMembershipRequired
}
}
resultSpaceID := snapshot.SpaceID
resultVisibility := snapshot.Visibility
if update != nil {
if update.SpaceID != nil {
if !snapshot.TargetSpaceExists {
return ErrMemoSpaceNotWritable
}
if !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)
}