mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 17:41:18 +00:00
feat: initial sharing impl
This commit is contained in:
@@ -6,6 +6,7 @@ var (
|
||||
ErrNodeNotFound = errors.New("node not found")
|
||||
ErrNodeConflict = errors.New("node conflict")
|
||||
ErrUnsupportedOperation = errors.New("unsupported operation")
|
||||
ErrAccessDenied = errors.New("access denied")
|
||||
ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field")
|
||||
ErrCursorMismatchedDirection = errors.New("cursor mismatched direction")
|
||||
)
|
||||
|
||||
59
apps/backend/internal/virtualfs/scope.go
Normal file
59
apps/backend/internal/virtualfs/scope.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package virtualfs
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// Scope defines the bounded view of the virtual filesystem that a caller is allowed to operate on.
|
||||
// It is populated by higher layers (account/share middleware) and enforced by VFS methods.
|
||||
type Scope struct {
|
||||
// AccountID is the owner of the storage. It stays constant even when a share actor accesses it.
|
||||
AccountID uuid.UUID
|
||||
|
||||
// RootNodeID is the top-most node the caller is allowed to traverse; all accesses must stay under it.
|
||||
// It must be set for all VFS access operations.
|
||||
RootNodeID uuid.UUID
|
||||
|
||||
// AllowedOps lists which operations this scope may perform (read, write, delete, etc).
|
||||
AllowedOps map[Operation]bool
|
||||
|
||||
// AllowedNodes is an optional allowlist of node IDs permitted within RootNodeID.
|
||||
// When nil or empty, the full subtree is allowed; when set, only allowlisted nodes (and descendants) are allowed.
|
||||
AllowedNodes map[uuid.UUID]struct{}
|
||||
|
||||
// ActorKind identifies who performs the action (user vs share link) for auditing.
|
||||
ActorKind ScopeActorKind
|
||||
|
||||
// ActorID is the identifier of the actor (user ID, share ID, etc).
|
||||
ActorID uuid.UUID
|
||||
}
|
||||
|
||||
var AllAllowedOps = map[Operation]bool{
|
||||
OperationRead: true,
|
||||
OperationWrite: true,
|
||||
OperationDelete: true,
|
||||
OperationUpload: true,
|
||||
OperationShare: true,
|
||||
}
|
||||
|
||||
// Allows reports whether the scope permits the given operation.
|
||||
func (s *Scope) Allows(op Operation) bool {
|
||||
return s != nil && s.AllowedOps[op]
|
||||
}
|
||||
|
||||
// Operation enumerates supported actions.
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
OperationRead Operation = "read"
|
||||
OperationWrite Operation = "write"
|
||||
OperationDelete Operation = "delete"
|
||||
OperationUpload Operation = "upload"
|
||||
OperationShare Operation = "share"
|
||||
)
|
||||
|
||||
// ScopeActorKind labels the type of actor behind the request.
|
||||
type ScopeActorKind string
|
||||
|
||||
const (
|
||||
ScopeActorAccount ScopeActorKind = "account"
|
||||
ScopeActorShare ScopeActorKind = "share"
|
||||
)
|
||||
201
apps/backend/internal/virtualfs/scope_access.go
Normal file
201
apps/backend/internal/virtualfs/scope_access.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package virtualfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// nodeWithinRootQuery checks if a node is within a root by traversing ancestors.
|
||||
const nodeWithinRootQuery = `WITH RECURSIVE path AS (
|
||||
SELECT id, parent_id
|
||||
FROM vfs_nodes
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT n.id, n.parent_id
|
||||
FROM vfs_nodes n
|
||||
JOIN path p ON n.id = p.parent_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT 1 FROM path WHERE id = ? LIMIT 1;`
|
||||
|
||||
// nodeWithinRootOrAllowedQuery checks if a node is:
|
||||
// 1. Within the root node (has root in its ancestry)
|
||||
// 2. Either directly in the allowed list, OR has an ancestor in the allowed list
|
||||
// This combines two checks into a single recursive query.
|
||||
const nodeWithinRootOrAllowedQuery = `WITH RECURSIVE path AS (
|
||||
SELECT id, parent_id
|
||||
FROM vfs_nodes
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT n.id, n.parent_id
|
||||
FROM vfs_nodes n
|
||||
JOIN path p ON n.id = p.parent_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM path WHERE id = ?) AS within_root,
|
||||
EXISTS(SELECT 1 FROM path WHERE id IN (?)) AS within_allowed;`
|
||||
|
||||
// filterNodesWithAllowlistQuery filters multiple nodes in a single query.
|
||||
// It returns node IDs that are within the root AND have an ancestor (or self) in the allowed list.
|
||||
const filterNodesWithAllowlistQuery = `WITH RECURSIVE node_paths AS (
|
||||
-- Start from all input nodes
|
||||
SELECT id AS original_id, id, parent_id
|
||||
FROM vfs_nodes
|
||||
WHERE id IN (?) AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
-- Traverse up to ancestors
|
||||
SELECT np.original_id, n.id, n.parent_id
|
||||
FROM vfs_nodes n
|
||||
JOIN node_paths np ON n.id = np.parent_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT DISTINCT original_id
|
||||
FROM node_paths
|
||||
WHERE original_id IN (
|
||||
-- Nodes that have root in their path
|
||||
SELECT original_id FROM node_paths WHERE id = ?
|
||||
)
|
||||
AND original_id IN (
|
||||
-- Nodes that have an allowed node in their path
|
||||
SELECT original_id FROM node_paths WHERE id IN (?)
|
||||
);`
|
||||
|
||||
// filterNodesWithinRootQuery filters multiple nodes that are within the root (no allowlist).
|
||||
const filterNodesWithinRootQuery = `WITH RECURSIVE node_paths AS (
|
||||
SELECT id AS original_id, id, parent_id
|
||||
FROM vfs_nodes
|
||||
WHERE id IN (?) AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT np.original_id, n.id, n.parent_id
|
||||
FROM vfs_nodes n
|
||||
JOIN node_paths np ON n.id = np.parent_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT DISTINCT original_id
|
||||
FROM node_paths
|
||||
WHERE id = ?;`
|
||||
|
||||
func isScopeSet(scope *Scope) bool {
|
||||
return scope != nil && scope.AccountID != uuid.Nil && scope.RootNodeID != uuid.Nil
|
||||
}
|
||||
|
||||
// canAccessNode checks if the scope permits the operation and allows access to the node.
|
||||
func (vfs *VirtualFS) canAccessNode(ctx context.Context, db bun.IDB, scope *Scope, op Operation, nodeID uuid.UUID) (bool, error) {
|
||||
if !scope.Allows(op) {
|
||||
return false, nil
|
||||
}
|
||||
return vfs.isNodeAllowedByScope(ctx, db, scope, nodeID)
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) isNodeWithinRoot(ctx context.Context, db bun.IDB, nodeID, rootID uuid.UUID) (bool, error) {
|
||||
var exists int
|
||||
err := db.NewRaw(nodeWithinRootQuery, nodeID, rootID).Scan(ctx, &exists)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isNodeAllowedByScope checks if a node is accessible under the given scope.
|
||||
// It verifies the node is within the root and (if an allowlist exists) within an allowed subtree.
|
||||
// Optimized to use a single query when an allowlist is present.
|
||||
func (vfs *VirtualFS) isNodeAllowedByScope(ctx context.Context, db bun.IDB, scope *Scope, nodeID uuid.UUID) (bool, error) {
|
||||
if !isScopeSet(scope) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Fast path: no allowlist means full subtree access under root
|
||||
if len(scope.AllowedNodes) == 0 {
|
||||
return vfs.isNodeWithinRoot(ctx, db, nodeID, scope.RootNodeID)
|
||||
}
|
||||
|
||||
// Quick check: if nodeID is directly in the allowlist, just check root containment
|
||||
if _, ok := scope.AllowedNodes[nodeID]; ok {
|
||||
return vfs.isNodeWithinRoot(ctx, db, nodeID, scope.RootNodeID)
|
||||
}
|
||||
|
||||
// Single query: build ancestry path and check both root and allowlist membership
|
||||
allowedIDs := scopeAllowedNodesList(scope)
|
||||
|
||||
var result struct {
|
||||
WithinRoot bool `bun:"within_root"`
|
||||
WithinAllowed bool `bun:"within_allowed"`
|
||||
}
|
||||
|
||||
err := db.NewRaw(nodeWithinRootOrAllowedQuery, nodeID, scope.RootNodeID, bun.In(allowedIDs)).Scan(ctx, &result)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return result.WithinRoot && result.WithinAllowed, nil
|
||||
}
|
||||
|
||||
// filterNodesByScope filters a list of nodes to only those accessible under the scope.
|
||||
// Optimized to use a single batch query instead of per-node queries.
|
||||
func (vfs *VirtualFS) filterNodesByScope(ctx context.Context, db bun.IDB, scope *Scope, nodes []*Node) ([]*Node, error) {
|
||||
if len(nodes) == 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
if !isScopeSet(scope) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
nodeIDs := make([]uuid.UUID, len(nodes))
|
||||
nodeMap := make(map[uuid.UUID]*Node, len(nodes))
|
||||
for i, node := range nodes {
|
||||
nodeIDs[i] = node.ID
|
||||
nodeMap[node.ID] = node
|
||||
}
|
||||
|
||||
var allowedIDs []uuid.UUID
|
||||
var err error
|
||||
|
||||
if len(scope.AllowedNodes) == 0 {
|
||||
// No allowlist: just check nodes are within root
|
||||
err = db.NewRaw(filterNodesWithinRootQuery, bun.In(nodeIDs), scope.RootNodeID).Scan(ctx, &allowedIDs)
|
||||
} else {
|
||||
// With allowlist: check nodes are within root AND within an allowed subtree
|
||||
allowedNodesList := scopeAllowedNodesList(scope)
|
||||
err = db.NewRaw(filterNodesWithAllowlistQuery, bun.In(nodeIDs), scope.RootNodeID, bun.In(allowedNodesList)).Scan(ctx, &allowedIDs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return make([]*Node, 0), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowed := make([]*Node, 0, len(allowedIDs))
|
||||
for _, id := range allowedIDs {
|
||||
if node, ok := nodeMap[id]; ok {
|
||||
allowed = append(allowed, node)
|
||||
}
|
||||
}
|
||||
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// scopeAllowedNodesList converts the AllowedNodes map to a slice for use in queries.
|
||||
func scopeAllowedNodesList(scope *Scope) []uuid.UUID {
|
||||
if scope == nil || len(scope.AllowedNodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(scope.AllowedNodes))
|
||||
for id := range scope.AllowedNodes {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
13
apps/backend/internal/virtualfs/scoped_router.go
Normal file
13
apps/backend/internal/virtualfs/scoped_router.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package virtualfs
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
// ScopedRouter is a router that guarantees reqctx.VFSAccessScope(c)
|
||||
// returns a valid *Scope for all registered routes.
|
||||
//
|
||||
// This is the base type for routers that provide VFS access scope.
|
||||
// More specific router types (like account.ScopedRouter) may embed this
|
||||
// to provide additional guarantees.
|
||||
type ScopedRouter struct {
|
||||
fiber.Router
|
||||
}
|
||||
@@ -44,12 +44,6 @@ type VirtualFS struct {
|
||||
sqid *sqids.Sqids
|
||||
}
|
||||
|
||||
type CreateNodeOptions struct {
|
||||
ParentID uuid.UUID
|
||||
Kind NodeKind
|
||||
Name string
|
||||
}
|
||||
|
||||
type CreateFileOptions struct {
|
||||
ParentID uuid.UUID
|
||||
Name string
|
||||
@@ -93,10 +87,14 @@ func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, accountID, fileID string) (*Node, error) {
|
||||
func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, fileID string, scope *Scope) (*Node, error) {
|
||||
if !isScopeSet(scope) {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
var node Node
|
||||
err := db.NewSelect().Model(&node).
|
||||
Where("account_id = ?", accountID).
|
||||
Where("account_id = ?", scope.AccountID).
|
||||
Where("id = ?", fileID).
|
||||
Where("status = ?", NodeStatusReady).
|
||||
Where("deleted_at IS NULL").
|
||||
@@ -107,11 +105,17 @@ func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, accountID, fileI
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accountID uuid.UUID, publicID string) (*Node, error) {
|
||||
nodes, err := vfs.FindNodesByPublicID(ctx, db, accountID, []string{publicID})
|
||||
func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, publicID string, scope *Scope) (*Node, error) {
|
||||
nodes, err := vfs.FindNodesByPublicID(ctx, db, []string{publicID}, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,14 +125,17 @@ func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accoun
|
||||
return nodes[0], nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accountID uuid.UUID, publicIDs []string) ([]*Node, error) {
|
||||
func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, publicIDs []string, scope *Scope) ([]*Node, error) {
|
||||
if len(publicIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if !isScopeSet(scope) {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
var nodes []*Node
|
||||
err := db.NewSelect().Model(&nodes).
|
||||
Where("account_id = ?", accountID).
|
||||
Where("account_id = ?", scope.AccountID).
|
||||
Where("public_id IN (?)", bun.In(publicIDs)).
|
||||
Where("status = ?", NodeStatusReady).
|
||||
Scan(ctx)
|
||||
@@ -136,7 +143,7 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accou
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
return vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
|
||||
@@ -159,11 +166,49 @@ func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, account
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// CreateRootDirectory creates the account root directory node.
|
||||
func (vfs *VirtualFS) CreateRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
|
||||
pid, err := vfs.generatePublicID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := newNodeID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node := &Node{
|
||||
ID: id,
|
||||
PublicID: pid,
|
||||
AccountID: accountID,
|
||||
ParentID: uuid.Nil,
|
||||
Kind: NodeKindDirectory,
|
||||
Status: NodeStatusReady,
|
||||
Name: RootDirectoryName,
|
||||
}
|
||||
|
||||
_, err = db.NewInsert().Model(node).Exec(ctx)
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
return nil, ErrNodeConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// ListChildren returns the children of a directory node with optional sorting and cursor-based pagination.
|
||||
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions) ([]*Node, *ListChildrenCursor, error) {
|
||||
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions, scope *Scope) ([]*Node, *ListChildrenCursor, error) {
|
||||
if !node.IsAccessible() {
|
||||
return nil, nil, ErrNodeNotFound
|
||||
}
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||
return nil, nil, err
|
||||
} else if !ok {
|
||||
return nil, nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
var nodes []*Node
|
||||
q := db.NewSelect().Model(&nodes).
|
||||
@@ -172,16 +217,9 @@ func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node,
|
||||
Where("status = ?", NodeStatusReady).
|
||||
Where("deleted_at IS NULL")
|
||||
|
||||
var dir string
|
||||
if opts.OrderBy != "" {
|
||||
switch opts.OrderDirection {
|
||||
default:
|
||||
dir = "ASC"
|
||||
case ListChildrenDirectionAsc:
|
||||
dir = "ASC"
|
||||
case ListChildrenDirectionDesc:
|
||||
dir = "DESC"
|
||||
}
|
||||
dir := "ASC"
|
||||
if opts.OrderDirection == ListChildrenDirectionDesc {
|
||||
dir = "DESC"
|
||||
}
|
||||
|
||||
// Apply sorting with directories always first, then ID as tiebreaker.
|
||||
@@ -267,7 +305,16 @@ func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node,
|
||||
return nodes, c, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateFileOptions) (*Node, error) {
|
||||
func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, opts CreateFileOptions, scope *Scope) (*Node, error) {
|
||||
if !isScopeSet(scope) {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationUpload, opts.ParentID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
pid, err := vfs.generatePublicID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -281,7 +328,7 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
|
||||
node := Node{
|
||||
ID: id,
|
||||
PublicID: pid,
|
||||
AccountID: accountID,
|
||||
AccountID: scope.AccountID,
|
||||
ParentID: opts.ParentID,
|
||||
Kind: NodeKindFile,
|
||||
Status: NodeStatusPending,
|
||||
@@ -306,7 +353,12 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, content FileContent) error {
|
||||
func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, content FileContent, scope *Scope) error {
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationUpload, node.ID); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
if content.Reader == nil && content.BlobKey.IsNil() {
|
||||
return blob.ErrInvalidFileContent
|
||||
}
|
||||
@@ -376,18 +428,22 @@ func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, con
|
||||
setCols = append(setCols, "mime_type", "blob_key", "size", "status")
|
||||
}
|
||||
|
||||
_, err := db.NewUpdate().Model(node).
|
||||
if _, err := db.NewUpdate().Model(node).
|
||||
Column(setCols...).
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node) (FileContent, error) {
|
||||
func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (FileContent, error) {
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||
return EmptyFileContent(), err
|
||||
} else if !ok {
|
||||
return EmptyFileContent(), ErrAccessDenied
|
||||
}
|
||||
if node.Kind != NodeKindFile {
|
||||
return EmptyFileContent(), ErrUnsupportedOperation
|
||||
}
|
||||
@@ -415,7 +471,16 @@ func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node) (Fil
|
||||
return FileContentFromReaderWithSize(reader, node.Size), nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID, parentID uuid.UUID, name string) (*Node, error) {
|
||||
func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, parentID uuid.UUID, name string, scope *Scope) (*Node, error) {
|
||||
if !isScopeSet(scope) {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, parentID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
pid, err := vfs.generatePublicID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -429,7 +494,7 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID
|
||||
node := &Node{
|
||||
ID: id,
|
||||
PublicID: pid,
|
||||
AccountID: accountID,
|
||||
AccountID: scope.AccountID,
|
||||
ParentID: parentID,
|
||||
Kind: NodeKindDirectory,
|
||||
Status: NodeStatusReady,
|
||||
@@ -447,8 +512,8 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node) (*Node, error) {
|
||||
deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node})
|
||||
func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (*Node, error) {
|
||||
deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node}, scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -458,21 +523,32 @@ func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node
|
||||
return deleted[0], nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node) ([]*Node, error) {
|
||||
func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node, scope *Scope) ([]*Node, error) {
|
||||
if !scope.Allows(OperationDelete) {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deletableNodes := make([]*Node, 0, len(nodes))
|
||||
nodeIDs := make([]uuid.UUID, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
allowed, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil, ErrNodeNotFound
|
||||
}
|
||||
|
||||
deletableNodes := make([]*Node, 0, len(allowed))
|
||||
nodeIDs := make([]uuid.UUID, 0, len(allowed))
|
||||
for _, node := range allowed {
|
||||
if node.IsAccessible() {
|
||||
nodeIDs = append(nodeIDs, node.ID)
|
||||
deletableNodes = append(deletableNodes, node)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.NewUpdate().Model(&deletableNodes).
|
||||
_, err = db.NewUpdate().Model(&deletableNodes).
|
||||
Where("id IN (?)", bun.In(nodeIDs)).
|
||||
Where("status = ?", NodeStatusReady).
|
||||
Where("deleted_at IS NULL").
|
||||
@@ -486,7 +562,12 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*
|
||||
return deletableNodes, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) error {
|
||||
func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) error {
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationDelete, node.ID); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
if node.Status != NodeStatusReady {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
@@ -507,10 +588,15 @@ func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, name string) error {
|
||||
func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, name string, scope *Scope) error {
|
||||
if !node.IsAccessible() {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, node.ID); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
|
||||
if err != nil {
|
||||
@@ -557,11 +643,25 @@ func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, na
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, parentID uuid.UUID) error {
|
||||
func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, parentID uuid.UUID, scope *Scope) error {
|
||||
if !node.IsAccessible() {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
|
||||
// check if the node is accessible
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, node.ID); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// check if the new parent is accessible
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, parentID); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -612,15 +712,28 @@ func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, pare
|
||||
// All nodes MUST have the same current parent directory; this constraint enables an
|
||||
// optimization where parent paths are computed only once (2 recursive queries total)
|
||||
// rather than computing full paths for each node individually (N queries).
|
||||
func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID) (*MoveFilesResult, error) {
|
||||
func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID, scope *Scope) (*MoveFilesResult, error) {
|
||||
if len(nodes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, newParentID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
allowedNodes, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(allowedNodes) == 0 {
|
||||
return nil, ErrNodeNotFound
|
||||
}
|
||||
|
||||
// Validate all nodes are accessible
|
||||
nodeIDs := make([]uuid.UUID, len(nodes))
|
||||
nodeNames := make([]string, len(nodes))
|
||||
for i, node := range nodes {
|
||||
nodeIDs := make([]uuid.UUID, len(allowedNodes))
|
||||
nodeNames := make([]string, len(allowedNodes))
|
||||
for i, node := range allowedNodes {
|
||||
if !node.IsAccessible() {
|
||||
return nil, ErrNodeNotFound
|
||||
}
|
||||
@@ -629,8 +742,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
||||
}
|
||||
|
||||
var conflicts []*Node
|
||||
err := db.NewSelect().Model(&conflicts).
|
||||
Where("account_id = ?", nodes[0].AccountID).
|
||||
err = db.NewSelect().Model(&conflicts).
|
||||
Where("account_id = ?", allowedNodes[0].AccountID).
|
||||
Where("parent_id = ?", newParentID).
|
||||
Where("name IN (?)", bun.In(nodeNames)).
|
||||
Scan(ctx)
|
||||
@@ -643,8 +756,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
||||
conflictID[c.ID] = struct{}{}
|
||||
}
|
||||
|
||||
movableNodes := make([]*Node, 0, len(nodes)-len(conflicts))
|
||||
for _, node := range nodes {
|
||||
movableNodes := make([]*Node, 0, len(allowedNodes)-len(conflicts))
|
||||
for _, node := range allowedNodes {
|
||||
if _, ok := conflictID[node.ID]; !ok {
|
||||
movableNodes = append(movableNodes, node)
|
||||
}
|
||||
@@ -690,7 +803,7 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
||||
}
|
||||
}
|
||||
|
||||
for _, node := range nodes {
|
||||
for _, node := range allowedNodes {
|
||||
node.ParentID = newParentID
|
||||
}
|
||||
|
||||
@@ -701,26 +814,42 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node) (Path, error) {
|
||||
func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (Path, error) {
|
||||
if !node.IsAccessible() {
|
||||
return nil, ErrNodeNotFound
|
||||
}
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||
return nil, err
|
||||
} else if !ok {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
return buildNoteAbsolutePath(ctx, db, node)
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node) error {
|
||||
func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node, scope *Scope) error {
|
||||
if !scope.Allows(OperationDelete) {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, n := range nodes {
|
||||
allowed, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
|
||||
for _, n := range allowed {
|
||||
if n.Kind != NodeKindFile {
|
||||
return ErrUnsupportedOperation
|
||||
}
|
||||
}
|
||||
|
||||
deletedIDs := make([]uuid.UUID, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
deletedIDs := make([]uuid.UUID, 0, len(allowed))
|
||||
for _, n := range allowed {
|
||||
err := vfs.permanentlyDeleteFileNode(ctx, db, n)
|
||||
if err != nil {
|
||||
if errors.Is(err, blob.ErrNotFound) {
|
||||
@@ -737,7 +866,7 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := db.NewDelete().Model((*Node)(nil)).
|
||||
_, err = db.NewDelete().Model((*Node)(nil)).
|
||||
Where("id IN (?)", bun.In(deletedIDs)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
@@ -747,7 +876,13 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node) error {
|
||||
func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) error {
|
||||
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationDelete, node.ID); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case NodeKindFile:
|
||||
return vfs.permanentlyDeleteFileNode(ctx, db, node)
|
||||
|
||||
Reference in New Issue
Block a user