mirror of
https://github.com/get-drexa/drive.git
synced 2025-12-01 05:51:39 +00:00
wip: vfs implementation
This commit is contained in:
22
apps/backend/internal/virtualfs/counting_reader.go
Normal file
22
apps/backend/internal/virtualfs/counting_reader.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package virtualfs
|
||||
|
||||
import "io"
|
||||
|
||||
type CountingReader struct {
|
||||
reader io.Reader
|
||||
count int64
|
||||
}
|
||||
|
||||
func NewCountingReader(reader io.Reader) *CountingReader {
|
||||
return &CountingReader{reader: reader}
|
||||
}
|
||||
|
||||
func (r *CountingReader) Read(p []byte) (n int, err error) {
|
||||
n, err = r.reader.Read(p)
|
||||
r.count += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *CountingReader) Count() int64 {
|
||||
return r.count
|
||||
}
|
||||
8
apps/backend/internal/virtualfs/err.go
Normal file
8
apps/backend/internal/virtualfs/err.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package virtualfs
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNodeNotFound = errors.New("node not found")
|
||||
ErrNodeConflict = errors.New("node conflict")
|
||||
)
|
||||
38
apps/backend/internal/virtualfs/key_resolver.go
Normal file
38
apps/backend/internal/virtualfs/key_resolver.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package virtualfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/get-drexa/drexa/internal/blob"
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type BlobKeyResolver interface {
|
||||
Resolve(ctx context.Context, node *Node) (blob.Key, error)
|
||||
}
|
||||
|
||||
type FlatKeyResolver struct{}
|
||||
|
||||
func (r *FlatKeyResolver) Resolve(ctx context.Context, node *Node) (blob.Key, error) {
|
||||
if node.BlobKey == "" {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return blob.Key(id.String()), nil
|
||||
}
|
||||
return node.BlobKey, nil
|
||||
}
|
||||
|
||||
type HierarchicalKeyResolver struct {
|
||||
db *bun.DB
|
||||
}
|
||||
|
||||
func (r *HierarchicalKeyResolver) Resolve(ctx context.Context, node *Node) (blob.Key, error) {
|
||||
path, err := buildNodeAbsolutePath(ctx, r.db, node.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return blob.Key(path), nil
|
||||
}
|
||||
49
apps/backend/internal/virtualfs/node.go
Normal file
49
apps/backend/internal/virtualfs/node.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package virtualfs
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/get-drexa/drexa/internal/blob"
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type NodeKind string
|
||||
|
||||
const (
|
||||
NodeKindFile NodeKind = "file"
|
||||
NodeKindDirectory NodeKind = "directory"
|
||||
)
|
||||
|
||||
type NodeStatus string
|
||||
|
||||
const (
|
||||
NodeStatusPending NodeStatus = "pending"
|
||||
NodeStatusReady NodeStatus = "ready"
|
||||
)
|
||||
|
||||
type Node struct {
|
||||
bun.BaseModel `bun:"vfs_nodes"`
|
||||
|
||||
ID uuid.UUID `bun:",pk,type:uuid"`
|
||||
PublicID string `bun:"public_id,notnull"`
|
||||
UserID uuid.UUID `bun:"user_id,notnull"`
|
||||
ParentID uuid.UUID `bun:"parent_id,notnull"`
|
||||
Kind NodeKind `bun:"kind,notnull"`
|
||||
Status NodeStatus `bun:"status,notnull"`
|
||||
Name string `bun:"name,notnull"`
|
||||
|
||||
BlobKey blob.Key `bun:"blob_key"`
|
||||
Size int64 `bun:"size"`
|
||||
MimeType string `bun:"mime_type"`
|
||||
|
||||
CreatedAt time.Time `bun:"created_at,notnull"`
|
||||
UpdatedAt time.Time `bun:"updated_at,notnull"`
|
||||
DeletedAt time.Time `bun:"deleted_at"`
|
||||
}
|
||||
|
||||
// IsAccessible returns true if the node can be accessed.
|
||||
// If the node is not ready or if it is soft deleted, it cannot be accessed.
|
||||
func (n *Node) IsAccessible() bool {
|
||||
return n.DeletedAt.IsZero() && n.Status == NodeStatusReady
|
||||
}
|
||||
42
apps/backend/internal/virtualfs/path.go
Normal file
42
apps/backend/internal/virtualfs/path.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package virtualfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
const absolutePathQuery = `WITH RECURSIVE path AS (
|
||||
SELECT id, parent_id, name, 1 as depth
|
||||
FROM vfs_nodes WHERE id = $1 AND deleted_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT n.id, n.parent_id, n.name, p.depth + 1
|
||||
FROM vfs_nodes n
|
||||
JOIN path p ON n.id = p.parent_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
)
|
||||
SELECT name FROM path
|
||||
WHERE EXISTS (SELECT 1 FROM path WHERE parent_id IS NULL)
|
||||
ORDER BY depth DESC;`
|
||||
|
||||
func JoinPath(parts ...string) string {
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func buildNodeAbsolutePath(ctx context.Context, db *bun.DB, nodeID uuid.UUID) (string, error) {
|
||||
var path []string
|
||||
err := db.NewRaw(absolutePathQuery, nodeID).Scan(ctx, &path)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrNodeNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return JoinPath(path...), nil
|
||||
}
|
||||
270
apps/backend/internal/virtualfs/vfs.go
Normal file
270
apps/backend/internal/virtualfs/vfs.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package virtualfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/get-drexa/drexa/internal/blob"
|
||||
"github.com/get-drexa/drexa/internal/database"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqids/sqids-go"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type VirtualFS struct {
|
||||
db *bun.DB
|
||||
blobStore blob.Store
|
||||
keyResolver BlobKeyResolver
|
||||
|
||||
sqid *sqids.Sqids
|
||||
}
|
||||
|
||||
type CreateNodeOptions struct {
|
||||
ParentID uuid.UUID
|
||||
Kind NodeKind
|
||||
Name string
|
||||
}
|
||||
|
||||
type WriteFileOptions struct {
|
||||
ParentID uuid.UUID
|
||||
Name string
|
||||
}
|
||||
|
||||
func NewVirtualFS(db *bun.DB, blobStore blob.Store, keyResolver BlobKeyResolver) *VirtualFS {
|
||||
return &VirtualFS{
|
||||
db: db,
|
||||
blobStore: blobStore,
|
||||
keyResolver: keyResolver,
|
||||
}
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) FindNode(ctx context.Context, userID, fileID string) (*Node, error) {
|
||||
var node Node
|
||||
err := vfs.db.NewSelect().Model(&node).
|
||||
Where("user_id = ?", userID).
|
||||
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
|
||||
}
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, userID uuid.UUID, publicID string) (*Node, error) {
|
||||
var node Node
|
||||
err := vfs.db.NewSelect().Model(&node).
|
||||
Where("user_id = ?", userID).
|
||||
Where("public_id = ?", publicID).
|
||||
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
|
||||
}
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) WriteFile(ctx context.Context, userID uuid.UUID, reader io.Reader, opts WriteFileOptions) (*Node, error) {
|
||||
pid, err := vfs.generatePublicID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node := Node{
|
||||
PublicID: pid,
|
||||
UserID: userID,
|
||||
ParentID: opts.ParentID,
|
||||
Kind: NodeKindFile,
|
||||
Status: NodeStatusPending,
|
||||
Name: opts.Name,
|
||||
}
|
||||
|
||||
_, err = vfs.db.NewInsert().Model(&node).Exec(ctx)
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
return nil, ErrNodeConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = vfs.db.NewDelete().Model(&node).WherePK().Exec(ctx)
|
||||
}
|
||||
|
||||
key, err := vfs.keyResolver.Resolve(ctx, &node)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h := make([]byte, 3072)
|
||||
n, err := io.ReadFull(reader, h)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
h = h[:n]
|
||||
|
||||
mt := mimetype.Detect(h)
|
||||
cr := NewCountingReader(io.MultiReader(bytes.NewReader(h), reader))
|
||||
|
||||
err = vfs.blobStore.Put(ctx, key, cr)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node.BlobKey = key
|
||||
node.Size = cr.Count()
|
||||
node.MimeType = mt.String()
|
||||
node.Status = NodeStatusReady
|
||||
|
||||
_, err = vfs.db.NewUpdate().Model(&node).
|
||||
Column("status", "blob_key", "size", "mime_type").
|
||||
WherePK().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) CreateDirectory(ctx context.Context, userID uuid.UUID, parentID uuid.UUID, name string) (*Node, error) {
|
||||
pid, err := vfs.generatePublicID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
node := Node{
|
||||
PublicID: pid,
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
Kind: NodeKindDirectory,
|
||||
Status: NodeStatusReady,
|
||||
Name: name,
|
||||
}
|
||||
|
||||
_, err = vfs.db.NewInsert().Model(&node).Exec(ctx)
|
||||
if err != nil {
|
||||
if database.IsUniqueViolation(err) {
|
||||
return nil, ErrNodeConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &node, nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, node *Node) error {
|
||||
if !node.IsAccessible() {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
|
||||
_, err := vfs.db.NewUpdate().Model(node).
|
||||
WherePK().
|
||||
Where("deleted_at IS NULL").
|
||||
Where("status = ?", NodeStatusReady).
|
||||
Set("deleted_at = NOW()").
|
||||
Returning("deleted_at").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) RenameNode(ctx context.Context, node *Node, name string) error {
|
||||
if !node.IsAccessible() {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
|
||||
_, err := vfs.db.NewUpdate().Model(node).
|
||||
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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (vfs *VirtualFS) MoveNode(ctx context.Context, node *Node, parentID uuid.UUID) error {
|
||||
if !node.IsAccessible() {
|
||||
return ErrNodeNotFound
|
||||
}
|
||||
|
||||
oldKey, err := vfs.keyResolver.Resolve(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = vfs.db.NewUpdate().Model(node).
|
||||
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
|
||||
}
|
||||
|
||||
newKey, err := vfs.keyResolver.Resolve(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if node
|
||||
|
||||
if oldKey != newKey {
|
||||
err = vfs.blobStore.Move(ctx, oldKey, newKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node.BlobKey = newKey
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user