Files
drive/apps/backend/internal/virtualfs/vfs.go

1016 lines
24 KiB
Go
Raw Normal View History

2025-11-27 20:49:58 +00:00
package virtualfs
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/binary"
"errors"
2025-12-17 22:59:18 +00:00
"fmt"
2025-11-27 20:49:58 +00:00
"io"
2025-12-02 22:08:50 +00:00
"time"
2025-11-27 20:49:58 +00:00
"github.com/gabriel-vasile/mimetype"
"github.com/get-drexa/drexa/internal/blob"
"github.com/get-drexa/drexa/internal/database"
2025-11-28 22:31:00 +00:00
"github.com/get-drexa/drexa/internal/ioext"
2025-11-27 20:49:58 +00:00
"github.com/google/uuid"
"github.com/sqids/sqids-go"
"github.com/uptrace/bun"
)
2025-12-17 22:59:18 +00:00
type ListChildrenOrder string
const (
ListChildrenOrderByName ListChildrenOrder = "name"
ListChildrenOrderByCreatedAt ListChildrenOrder = "created_at"
ListChildrenOrderByUpdatedAt ListChildrenOrder = "updated_at"
)
type ListChildrenDirection int
const (
ListChildrenDirectionAsc ListChildrenDirection = iota
ListChildrenDirectionDesc
)
const listChildrenDefaultLimit = 50
2025-11-27 20:49:58 +00:00
type VirtualFS struct {
blobStore blob.Store
keyResolver BlobKeyResolver
sqid *sqids.Sqids
}
2025-11-28 22:31:00 +00:00
type CreateFileOptions struct {
2025-11-27 20:49:58 +00:00
ParentID uuid.UUID
Name string
}
type MoveFileError struct {
Node *Node
Error error
}
type MoveFilesResult struct {
Moved []*Node
Conflicts []*Node
Errors []MoveFileError
}
2025-12-17 22:59:18 +00:00
type ListChildrenOptions struct {
Limit int
OrderBy ListChildrenOrder
OrderDirection ListChildrenDirection
Cursor *ListChildrenCursor
}
type ListChildrenCursor struct {
Node *Node
OrderBy ListChildrenOrder
OrderDirection ListChildrenDirection
}
const RootDirectoryName = "root"
2025-12-02 22:08:50 +00:00
func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) {
2025-11-28 22:31:00 +00:00
sqid, err := sqids.New()
if err != nil {
return nil, err
}
2025-11-27 20:49:58 +00:00
return &VirtualFS{
blobStore: blobStore,
keyResolver: keyResolver,
2025-11-28 22:31:00 +00:00
sqid: sqid,
}, nil
2025-11-27 20:49:58 +00:00
}
2025-12-27 19:27:08 +00:00
func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, fileID string, scope *Scope) (*Node, error) {
if !isScopeSet(scope) {
return nil, ErrAccessDenied
}
2025-11-27 20:49:58 +00:00
var node Node
err := db.NewSelect().Model(&node).
2025-12-27 19:27:08 +00:00
Where("account_id = ?", scope.AccountID).
2025-11-27 20:49:58 +00:00
Where("id = ?", fileID).
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNodeNotFound
}
return nil, err
}
2025-12-27 19:27:08 +00:00
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
return nil, err
} else if !ok {
return nil, ErrAccessDenied
}
2025-11-27 20:49:58 +00:00
return &node, nil
}
2025-12-27 19:27:08 +00:00
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
}
if len(nodes) == 0 {
return nil, ErrNodeNotFound
}
return nodes[0], nil
}
2025-12-27 19:27:08 +00:00
func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, publicIDs []string, scope *Scope) ([]*Node, error) {
if len(publicIDs) == 0 {
return nil, nil
}
2025-12-27 19:27:08 +00:00
if !isScopeSet(scope) {
return nil, ErrAccessDenied
}
var nodes []*Node
err := db.NewSelect().Model(&nodes).
2025-12-27 19:27:08 +00:00
Where("account_id = ?", scope.AccountID).
Where("public_id IN (?)", bun.In(publicIDs)).
2025-11-27 20:49:58 +00:00
Where("status = ?", NodeStatusReady).
Scan(ctx)
if err != nil {
return nil, err
}
2025-12-27 19:27:08 +00:00
return vfs.filterNodesByScope(ctx, db, scope, nodes)
2025-11-27 20:49:58 +00:00
}
func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
root := new(Node)
err := db.NewSelect().Model(root).
Where("account_id = ?", accountID).
Where("parent_id IS NULL").
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
Scan(ctx)
if err != nil {
return nil, err
}
if root.Kind != NodeKindDirectory {
return nil, ErrNodeNotFound
}
return root, nil
}
2025-12-27 19:27:08 +00:00
// 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
}
2025-12-17 22:59:18 +00:00
// ListChildren returns the children of a directory node with optional sorting and cursor-based pagination.
2025-12-27 19:27:08 +00:00
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions, scope *Scope) ([]*Node, *ListChildrenCursor, error) {
2025-11-27 22:44:13 +00:00
if !node.IsAccessible() {
2025-12-17 22:59:18 +00:00
return nil, nil, ErrNodeNotFound
2025-11-27 22:44:13 +00:00
}
2025-12-27 19:27:08 +00:00
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
return nil, nil, err
} else if !ok {
return nil, nil, ErrAccessDenied
}
2025-11-27 22:44:13 +00:00
var nodes []*Node
2025-12-17 22:59:18 +00:00
q := db.NewSelect().Model(&nodes).
2025-11-30 17:12:50 +00:00
Where("account_id = ?", node.AccountID).
2025-11-27 22:44:13 +00:00
Where("parent_id = ?", node.ID).
Where("status = ?", NodeStatusReady).
2025-12-17 22:59:18 +00:00
Where("deleted_at IS NULL")
2025-12-27 19:27:08 +00:00
dir := "ASC"
if opts.OrderDirection == ListChildrenDirectionDesc {
dir = "DESC"
2025-12-17 22:59:18 +00:00
}
// Apply sorting with directories always first, then ID as tiebreaker.
//
// Cursor-based pagination implementation notes:
// - The cursor contains the last node from the previous page along with the sort configuration
// - The WHERE clause uses tuple comparison (kind, field, id) to filter results after the cursor position
// - Directories are always ordered before files (kind ASC puts 'directory' before 'file' alphabetically)
// - ID is always sorted ASC as a tiebreaker, regardless of the main sort direction
//
// Why ID is always ASC:
// - Ensures deterministic ordering when multiple items have the same sort field value
// - Maintains consistent tiebreaker behavior across different sort directions
// - Prevents pagination inconsistencies where items with the same name/date appear in different orders
// depending on whether sorting ASC or DESC
// - The tuple comparison in the WHERE clause correctly handles the direction for the main field,
// while ID provides a stable secondary sort
switch opts.OrderBy {
case ListChildrenOrderByName:
q = q.Order("kind ASC", "name "+dir, "id ASC")
case ListChildrenOrderByCreatedAt:
q = q.Order("kind ASC", "created_at "+dir, "id ASC")
case ListChildrenOrderByUpdatedAt:
q = q.Order("kind ASC", "updated_at "+dir, "id ASC")
}
// Apply cursor filter for pagination.
// The cursor contains the last node from the previous page. We use tuple comparison
// (kind, field, id) to find all rows that come after the cursor position in the sorted order.
// Kind is included to handle pagination across the directory/file boundary correctly.
// For ASC: use > to get rows after cursor
// For DESC: use < to get rows after cursor (because "after" in descending order means lesser values)
if opts.Cursor != nil {
if opts.Cursor.OrderBy != opts.OrderBy {
return nil, nil, ErrCursorMismatchedOrderField
}
if opts.Cursor.OrderDirection != opts.OrderDirection {
return nil, nil, ErrCursorMismatchedDirection
}
var op string
switch opts.Cursor.OrderDirection {
case ListChildrenDirectionAsc:
op = ">"
case ListChildrenDirectionDesc:
op = "<"
}
// Include kind in tuple comparison to handle pagination across directory/file boundary
switch opts.Cursor.OrderBy {
case ListChildrenOrderByName:
q = q.Where("(kind, name, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.Name, opts.Cursor.Node.ID)
case ListChildrenOrderByCreatedAt:
q = q.Where("(kind, created_at, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.CreatedAt, opts.Cursor.Node.ID)
case ListChildrenOrderByUpdatedAt:
q = q.Where("(kind, updated_at, id) "+op+" (?, ?, ?)", opts.Cursor.Node.Kind, opts.Cursor.Node.UpdatedAt, opts.Cursor.Node.ID)
}
}
if opts.Limit > 0 {
q = q.Limit(opts.Limit)
} else {
q = q.Limit(listChildrenDefaultLimit)
}
if err := q.Scan(ctx); err != nil {
2025-11-27 22:44:13 +00:00
if errors.Is(err, sql.ErrNoRows) {
2025-12-17 22:59:18 +00:00
return make([]*Node, 0), nil, nil
2025-11-27 22:44:13 +00:00
}
2025-12-17 22:59:18 +00:00
return nil, nil, err
2025-11-27 22:44:13 +00:00
}
if len(nodes) == 0 {
return make([]*Node, 0), nil, nil
}
2025-12-17 22:59:18 +00:00
c := &ListChildrenCursor{
Node: nodes[len(nodes)-1],
OrderBy: opts.OrderBy,
OrderDirection: opts.OrderDirection,
}
return nodes, c, nil
2025-11-27 22:44:13 +00:00
}
2025-12-27 19:27:08 +00:00
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
}
2025-11-27 20:49:58 +00:00
pid, err := vfs.generatePublicID()
if err != nil {
return nil, err
}
id, err := newNodeID()
if err != nil {
return nil, err
}
2025-11-27 20:49:58 +00:00
node := Node{
ID: id,
2025-11-30 17:12:50 +00:00
PublicID: pid,
2025-12-27 19:27:08 +00:00
AccountID: scope.AccountID,
2025-11-30 17:12:50 +00:00
ParentID: opts.ParentID,
Kind: NodeKindFile,
Status: NodeStatusPending,
Name: opts.Name,
2025-11-27 20:49:58 +00:00
}
if vfs.keyResolver.ShouldPersistKey() {
2025-12-02 22:08:50 +00:00
node.BlobKey, err = vfs.keyResolver.Resolve(ctx, db, &node)
2025-11-28 22:31:00 +00:00
if err != nil {
return nil, err
}
}
2025-12-17 22:59:18 +00:00
_, err = db.NewInsert().Model(&node).On("CONFLICT DO NOTHING").Returning("*").Exec(ctx)
2025-11-27 20:49:58 +00:00
if err != nil {
if database.IsUniqueViolation(err) {
return nil, ErrNodeConflict
}
return nil, err
}
2025-11-28 22:31:00 +00:00
return &node, nil
}
2025-11-27 20:49:58 +00:00
2025-12-27 19:27:08 +00:00
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
}
2025-12-02 22:08:50 +00:00
if content.Reader == nil && content.BlobKey.IsNil() {
2025-11-28 22:31:00 +00:00
return blob.ErrInvalidFileContent
2025-11-27 20:49:58 +00:00
}
2025-12-02 22:08:50 +00:00
if node.DeletedAt != nil {
2025-11-28 22:31:00 +00:00
return ErrNodeNotFound
2025-11-27 20:49:58 +00:00
}
2025-11-28 22:31:00 +00:00
setCols := make([]string, 0, 4)
2025-11-27 20:49:58 +00:00
2025-12-02 22:08:50 +00:00
if content.Reader != nil {
key, err := vfs.keyResolver.Resolve(ctx, db, node)
2025-11-28 22:31:00 +00:00
if err != nil {
return err
}
buf := make([]byte, 3072)
2025-12-02 22:08:50 +00:00
n, err := io.ReadFull(content.Reader, buf)
2025-11-28 22:31:00 +00:00
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return err
}
buf = buf[:n]
mt := mimetype.Detect(buf)
2025-12-02 22:08:50 +00:00
cr := ioext.NewCountingReader(io.MultiReader(bytes.NewReader(buf), content.Reader))
2025-11-27 20:49:58 +00:00
2025-11-28 22:31:00 +00:00
err = vfs.blobStore.Put(ctx, key, cr)
if err != nil {
return err
}
if vfs.keyResolver.ShouldPersistKey() {
2025-11-28 22:31:00 +00:00
node.BlobKey = key
setCols = append(setCols, "blob_key")
}
node.MimeType = mt.String()
node.Size = cr.Count()
node.Status = NodeStatusReady
setCols = append(setCols, "mime_type", "size", "status")
} else {
2025-12-02 22:08:50 +00:00
node.BlobKey = content.BlobKey
2025-11-28 22:31:00 +00:00
2025-12-02 22:08:50 +00:00
b, err := vfs.blobStore.ReadRange(ctx, content.BlobKey, 0, 3072)
2025-11-28 22:31:00 +00:00
if err != nil {
return err
}
defer b.Close()
buf := make([]byte, 3072)
n, err := io.ReadFull(b, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return err
}
buf = buf[:n]
mt := mimetype.Detect(buf)
node.MimeType = mt.String()
node.Status = NodeStatusReady
2025-12-02 22:08:50 +00:00
s, err := vfs.blobStore.ReadSize(ctx, content.BlobKey)
2025-11-28 22:31:00 +00:00
if err != nil {
return err
}
node.Size = s
setCols = append(setCols, "mime_type", "blob_key", "size", "status")
}
2025-11-27 20:49:58 +00:00
2025-12-27 19:27:08 +00:00
if _, err := db.NewUpdate().Model(node).
2025-11-28 22:31:00 +00:00
Column(setCols...).
2025-11-27 20:49:58 +00:00
WherePK().
2025-12-27 19:27:08 +00:00
Exec(ctx); err != nil {
2025-11-28 22:31:00 +00:00
return err
2025-11-27 20:49:58 +00:00
}
2025-11-28 22:31:00 +00:00
return nil
2025-11-27 20:49:58 +00:00
}
2025-12-27 19:27:08 +00:00
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
}
2025-12-02 22:08:50 +00:00
if node.Kind != NodeKindFile {
return EmptyFileContent(), ErrUnsupportedOperation
}
key, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return EmptyFileContent(), err
}
if vfs.blobStore.SupportsDirectDownload() {
url, err := vfs.blobStore.GenerateDownloadURL(ctx, key, blob.DownloadURLOptions{
Duration: 1 * time.Hour,
})
if err != nil {
return EmptyFileContent(), err
}
return FileContentFromURL(url), nil
}
reader, err := vfs.blobStore.Read(ctx, key)
if err != nil {
return EmptyFileContent(), err
}
return FileContentFromReaderWithSize(reader, node.Size), nil
}
2025-12-27 19:27:08 +00:00
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
}
2025-11-27 20:49:58 +00:00
pid, err := vfs.generatePublicID()
if err != nil {
return nil, err
}
id, err := newNodeID()
if err != nil {
return nil, err
}
2025-12-03 00:07:39 +00:00
node := &Node{
ID: id,
2025-11-30 17:12:50 +00:00
PublicID: pid,
2025-12-27 19:27:08 +00:00
AccountID: scope.AccountID,
2025-11-30 17:12:50 +00:00
ParentID: parentID,
Kind: NodeKindDirectory,
Status: NodeStatusReady,
Name: name,
2025-11-27 20:49:58 +00:00
}
2025-11-30 19:39:47 +00:00
_, err = db.NewInsert().Model(node).Exec(ctx)
2025-11-27 20:49:58 +00:00
if err != nil {
if database.IsUniqueViolation(err) {
return nil, ErrNodeConflict
}
return nil, err
}
2025-12-03 00:07:39 +00:00
return node, nil
2025-11-27 20:49:58 +00:00
}
2025-12-27 19:27:08 +00:00
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
2025-11-27 20:49:58 +00:00
}
if len(deleted) == 0 {
return nil, ErrNodeNotFound
}
return deleted[0], nil
}
2025-11-27 20:49:58 +00:00
2025-12-27 19:27:08 +00:00
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
}
2025-12-27 19:27:08 +00:00
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)
}
}
2025-12-27 19:27:08 +00:00
_, err = db.NewUpdate().Model(&deletableNodes).
Where("id IN (?)", bun.In(nodeIDs)).
2025-11-27 20:49:58 +00:00
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
2025-11-27 20:49:58 +00:00
Set("deleted_at = NOW()").
Returning("deleted_at").
Exec(ctx)
if err != nil {
2025-12-17 22:59:18 +00:00
return nil, fmt.Errorf("failed to soft delete nodes: %w", err)
2025-11-27 20:49:58 +00:00
}
return deletableNodes, nil
2025-11-27 20:49:58 +00:00
}
2025-12-27 19:27:08 +00:00
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
}
2025-11-28 22:31:00 +00:00
if node.Status != NodeStatusReady {
2025-11-27 22:44:13 +00:00
return ErrNodeNotFound
}
_, err := db.NewUpdate().Model(node).
2025-11-27 22:44:13 +00:00
WherePK().
Where("deleted_at IS NOT NULL").
Set("deleted_at = NULL").
Returning("deleted_at").
Exec(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNodeNotFound
}
return err
}
return nil
}
2025-12-27 19:27:08 +00:00
func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, name string, scope *Scope) error {
2025-11-27 20:49:58 +00:00
if !node.IsAccessible() {
return ErrNodeNotFound
}
2025-12-27 19:27:08 +00:00
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, node.ID); err != nil {
return err
} else if !ok {
return ErrAccessDenied
}
2025-11-27 20:49:58 +00:00
2025-12-02 22:08:50 +00:00
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
_, err = db.NewUpdate().Model(node).
2025-11-27 20:49:58 +00:00
WherePK().
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
Set("name = ?", name).
Returning("name, updated_at").
Exec(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNodeNotFound
}
return err
}
2025-12-02 22:08:50 +00:00
newKey, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
if oldKey != newKey {
err = vfs.blobStore.Move(ctx, oldKey, newKey)
if err != nil {
return err
}
if vfs.keyResolver.ShouldPersistKey() {
node.BlobKey = newKey
_, err = db.NewUpdate().Model(node).
WherePK().
Set("blob_key = ?", newKey).
Exec(ctx)
if err != nil {
return err
}
}
}
2025-11-27 20:49:58 +00:00
return nil
}
2025-12-27 19:27:08 +00:00
func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, parentID uuid.UUID, scope *Scope) error {
2025-11-27 20:49:58 +00:00
if !node.IsAccessible() {
return ErrNodeNotFound
}
2025-12-27 19:27:08 +00:00
// 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
}
2025-12-02 22:08:50 +00:00
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
2025-11-27 20:49:58 +00:00
if err != nil {
return err
}
_, err = db.NewUpdate().Model(node).
2025-11-27 20:49:58 +00:00
WherePK().
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
Set("parent_id = ?", parentID).
Returning("parent_id, updated_at").
Exec(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNodeNotFound
}
if database.IsUniqueViolation(err) {
return ErrNodeConflict
}
return err
}
2025-12-02 22:08:50 +00:00
newKey, err := vfs.keyResolver.Resolve(ctx, db, node)
2025-11-27 20:49:58 +00:00
if err != nil {
return err
}
2025-11-28 22:31:00 +00:00
err = vfs.blobStore.Move(ctx, oldKey, newKey)
if err != nil {
return err
}
2025-11-27 22:44:13 +00:00
if vfs.keyResolver.ShouldPersistKey() {
2025-11-27 20:49:58 +00:00
node.BlobKey = newKey
_, err = db.NewUpdate().Model(node).
2025-11-27 22:44:13 +00:00
WherePK().
Set("blob_key = ?", newKey).
Exec(ctx)
if err != nil {
return err
}
}
return nil
}
// MoveNodesInSameDirectory moves multiple nodes to a new parent directory in a single operation.
// 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).
2025-12-27 19:27:08 +00:00
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
}
2025-12-27 19:27:08 +00:00
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
2025-12-27 19:27:08 +00:00
nodeIDs := make([]uuid.UUID, len(allowedNodes))
nodeNames := make([]string, len(allowedNodes))
for i, node := range allowedNodes {
if !node.IsAccessible() {
return nil, ErrNodeNotFound
}
nodeIDs[i] = node.ID
nodeNames[i] = node.Name
}
var conflicts []*Node
2025-12-27 19:27:08 +00:00
err = db.NewSelect().Model(&conflicts).
Where("account_id = ?", allowedNodes[0].AccountID).
Where("parent_id = ?", newParentID).
Where("name IN (?)", bun.In(nodeNames)).
Scan(ctx)
if err != nil {
return nil, err
}
conflictID := make(map[uuid.UUID]struct{})
for _, c := range conflicts {
conflictID[c.ID] = struct{}{}
}
2025-12-27 19:27:08 +00:00
movableNodes := make([]*Node, 0, len(allowedNodes)-len(conflicts))
for _, node := range allowedNodes {
if _, ok := conflictID[node.ID]; !ok {
movableNodes = append(movableNodes, node)
}
}
if len(movableNodes) == 0 {
return &MoveFilesResult{
Conflicts: conflicts,
}, nil
}
moveOps, err := vfs.keyResolver.ResolveBulkMoveOps(ctx, db, movableNodes, newParentID)
if err != nil {
return nil, err
}
_, err = db.NewUpdate().
Model((*Node)(nil)).
Where("id IN (?)", bun.In(nodeIDs)).
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
Set("parent_id = ?", newParentID).
Exec(ctx)
if err != nil {
return nil, err
}
errs := []MoveFileError{}
for _, op := range moveOps {
if op.OldKey != op.NewKey {
err = vfs.blobStore.Move(ctx, op.OldKey, op.NewKey)
if err != nil {
if errors.Is(err, blob.ErrConflict) {
// somehow the node is not conflicting in vfs
// but is conflicting in the blob store
// this is a catatrophic error, so the whole operation
// is considered a failure
return nil, ErrNodeConflict
}
errs = append(errs, MoveFileError{Node: op.Node, Error: err})
}
}
}
2025-12-27 19:27:08 +00:00
for _, node := range allowedNodes {
node.ParentID = newParentID
}
return &MoveFilesResult{
Moved: movableNodes,
Conflicts: conflicts,
Errors: errs,
}, nil
}
2025-12-27 19:27:08 +00:00
func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (Path, error) {
2025-11-27 22:44:13 +00:00
if !node.IsAccessible() {
return nil, ErrNodeNotFound
2025-11-27 22:44:13 +00:00
}
2025-12-27 19:27:08 +00:00
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
return nil, err
} else if !ok {
return nil, ErrAccessDenied
}
return buildNodeAbsolutePath(ctx, db, node, scope.RootNodeID)
2025-11-27 22:44:13 +00:00
}
2025-12-27 19:27:08 +00:00
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
}
2025-12-27 19:27:08 +00:00
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
}
}
2025-12-27 19:27:08 +00:00
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) {
// no op if the blob does not exist
continue
}
return err
} else {
deletedIDs = append(deletedIDs, n.ID)
}
}
if len(deletedIDs) == 0 {
return nil
}
2025-12-27 19:27:08 +00:00
_, err = db.NewDelete().Model((*Node)(nil)).
Where("id IN (?)", bun.In(deletedIDs)).
Exec(ctx)
if err != nil {
return err
}
return nil
}
2025-12-27 19:27:08 +00:00
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
}
2025-11-30 01:16:44 +00:00
switch node.Kind {
case NodeKindFile:
return vfs.permanentlyDeleteFileNode(ctx, db, node)
2025-11-30 01:16:44 +00:00
case NodeKindDirectory:
return vfs.permanentlyDeleteDirectoryNode(ctx, db, node)
2025-11-30 01:16:44 +00:00
default:
return ErrUnsupportedOperation
}
}
func (vfs *VirtualFS) permanentlyDeleteFileNode(ctx context.Context, db bun.IDB, node *Node) error {
2025-12-02 22:08:50 +00:00
key, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
err = vfs.blobStore.Delete(ctx, key)
2025-11-30 01:16:44 +00:00
if err != nil {
if errors.Is(err, blob.ErrNotFound) {
// no op if the blob does not exist
return nil
}
2025-11-30 01:16:44 +00:00
return err
}
_, err = db.NewDelete().Model(node).WherePK().Exec(ctx)
2025-11-30 01:16:44 +00:00
if err != nil {
return err
}
2025-11-27 22:44:13 +00:00
2025-11-30 01:16:44 +00:00
return nil
}
func (vfs *VirtualFS) permanentlyDeleteDirectoryNode(ctx context.Context, db bun.IDB, node *Node) error {
2025-11-27 22:44:13 +00:00
const descendantsQuery = `WITH RECURSIVE descendants AS (
SELECT id, blob_key FROM vfs_nodes WHERE id = ?
UNION ALL
SELECT n.id, n.blob_key FROM vfs_nodes n
JOIN descendants d ON n.parent_id = d.id
)
SELECT id, blob_key FROM descendants`
type nodeRecord struct {
ID uuid.UUID `bun:"id"`
BlobKey blob.Key `bun:"blob_key"`
}
// If db is already a transaction, use it directly; otherwise start a new transaction
var tx bun.IDB
var startedTx *bun.Tx
switch v := db.(type) {
case *bun.DB:
newTx, err := v.BeginTx(ctx, nil)
if err != nil {
return err
}
startedTx = &newTx
tx = newTx
defer func() {
if startedTx != nil {
(*startedTx).Rollback()
}
}()
default:
// Assume it's already a transaction
tx = db
2025-11-30 01:16:44 +00:00
}
2025-11-27 22:44:13 +00:00
2025-11-30 01:16:44 +00:00
var records []nodeRecord
err := tx.NewRaw(descendantsQuery, node.ID).Scan(ctx, &records)
2025-11-30 01:16:44 +00:00
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
2025-11-27 22:44:13 +00:00
return ErrNodeNotFound
}
2025-11-30 01:16:44 +00:00
return err
}
if len(records) == 0 {
return ErrNodeNotFound
}
2025-11-27 22:44:13 +00:00
2025-11-30 01:16:44 +00:00
nodeIDs := make([]uuid.UUID, 0, len(records))
blobKeys := make([]blob.Key, 0, len(records))
for _, r := range records {
nodeIDs = append(nodeIDs, r.ID)
if !r.BlobKey.IsNil() {
blobKeys = append(blobKeys, r.BlobKey)
2025-11-27 22:44:13 +00:00
}
2025-11-30 01:16:44 +00:00
}
2025-11-27 22:44:13 +00:00
2025-11-30 01:16:44 +00:00
plan, err := vfs.keyResolver.ResolveDeletionKeys(ctx, node, blobKeys)
if err != nil {
2025-11-27 22:44:13 +00:00
return err
2025-11-30 01:16:44 +00:00
}
_, err = tx.NewDelete().
Model((*Node)(nil)).
Where("id IN (?)", bun.In(nodeIDs)).
Exec(ctx)
2025-11-27 22:44:13 +00:00
if err != nil {
return err
}
2025-11-30 01:16:44 +00:00
if !plan.Prefix.IsNil() {
_ = vfs.blobStore.DeletePrefix(ctx, plan.Prefix)
} else {
for _, key := range plan.Keys {
_ = vfs.blobStore.Delete(ctx, key)
}
2025-11-27 20:49:58 +00:00
}
// Only commit if we started the transaction
if startedTx != nil {
err := (*startedTx).Commit()
startedTx = nil // Prevent defer from rolling back
return err
}
return nil
2025-11-27 20:49:58 +00:00
}
func (vfs *VirtualFS) generatePublicID() (string, error) {
var b [8]byte
_, err := rand.Read(b[:])
if err != nil {
return "", err
}
n := binary.BigEndian.Uint64(b[:])
return vfs.sqid.Encode([]uint64{n})
}