feat: impl files endpoints

This commit is contained in:
2025-12-02 22:08:50 +00:00
parent e8a558d652
commit ca9dfd90b2
13 changed files with 372 additions and 61 deletions

View File

@@ -0,0 +1,34 @@
package virtualfs
import (
"io"
"github.com/get-drexa/drexa/internal/blob"
)
type FileContent struct {
Size int64
Reader io.ReadCloser
BlobKey blob.Key
URL string
}
func EmptyFileContent() FileContent {
return FileContent{}
}
func FileContentFromReader(reader io.Reader) FileContent {
return FileContent{Reader: io.NopCloser(reader)}
}
func FileContentFromReaderWithSize(reader io.Reader, size int64) FileContent {
return FileContent{Reader: io.NopCloser(reader), Size: size}
}
func FileContentFromBlobKey(blobKey blob.Key) FileContent {
return FileContent{BlobKey: blobKey}
}
func FileContentFromURL(url string) FileContent {
return FileContent{URL: url}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/get-drexa/drexa/internal/blob"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type FlatKeyResolver struct{}
@@ -19,7 +20,7 @@ func (r *FlatKeyResolver) ShouldPersistKey() bool {
return true
}
func (r *FlatKeyResolver) Resolve(ctx context.Context, node *Node) (blob.Key, error) {
func (r *FlatKeyResolver) Resolve(ctx context.Context, db bun.IDB, node *Node) (blob.Key, error) {
if node.BlobKey == "" {
id, err := uuid.NewV7()
if err != nil {

View File

@@ -22,8 +22,8 @@ func (r *HierarchicalKeyResolver) ShouldPersistKey() bool {
return false
}
func (r *HierarchicalKeyResolver) Resolve(ctx context.Context, node *Node) (blob.Key, error) {
path, err := buildNodeAbsolutePath(ctx, r.db, node.ID)
func (r *HierarchicalKeyResolver) Resolve(ctx context.Context, db bun.IDB, node *Node) (blob.Key, error) {
path, err := buildNodeAbsolutePath(ctx, db, node)
if err != nil {
return "", err
}
@@ -32,7 +32,7 @@ func (r *HierarchicalKeyResolver) Resolve(ctx context.Context, node *Node) (blob
}
func (r *HierarchicalKeyResolver) ResolveDeletionKeys(ctx context.Context, node *Node, allKeys []blob.Key) (*DeletionPlan, error) {
path, err := buildNodeAbsolutePath(ctx, r.db, node.ID)
path, err := buildNodeAbsolutePath(ctx, r.db, node)
if err != nil {
return nil, err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/get-drexa/drexa/internal/blob"
"github.com/uptrace/bun"
)
type BlobKeyResolver interface {
@@ -11,7 +12,7 @@ type BlobKeyResolver interface {
// Flat keys (e.g. UUIDs) return true - key is generated once and stored.
// Hierarchical keys return false - key is derived from path each time.
ShouldPersistKey() bool
Resolve(ctx context.Context, node *Node) (blob.Key, error)
Resolve(ctx context.Context, db bun.IDB, node *Node) (blob.Key, error)
ResolveDeletionKeys(ctx context.Context, node *Node, allKeys []blob.Key) (*DeletionPlan, error)
}

View File

@@ -37,9 +37,9 @@ type Node struct {
Size int64 `bun:"size"`
MimeType string `bun:"mime_type,nullzero"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero"`
DeletedAt time.Time `bun:"deleted_at,nullzero"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero"`
DeletedAt *time.Time `bun:"deleted_at,nullzero"`
}
func newNodeID() (uuid.UUID, error) {
@@ -49,5 +49,5 @@ func newNodeID() (uuid.UUID, error) {
// 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
return n.DeletedAt == nil && n.Status == NodeStatusReady
}

View File

@@ -6,7 +6,6 @@ import (
"errors"
"strings"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
@@ -19,7 +18,6 @@ const absolutePathQuery = `WITH RECURSIVE path AS (
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)
@@ -29,9 +27,9 @@ func JoinPath(parts ...string) string {
return strings.Join(parts, "/")
}
func buildNodeAbsolutePath(ctx context.Context, db bun.IDB, nodeID uuid.UUID) (string, error) {
func buildNodeAbsolutePath(ctx context.Context, db bun.IDB, node *Node) (string, error) {
var path []string
err := db.NewRaw(absolutePathQuery, nodeID).Scan(ctx, &path)
err := db.NewRaw(absolutePathQuery, node.ID).Scan(ctx, &path)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNodeNotFound

View File

@@ -8,6 +8,7 @@ import (
"encoding/binary"
"errors"
"io"
"time"
"github.com/gabriel-vasile/mimetype"
"github.com/get-drexa/drexa/internal/blob"
@@ -36,22 +37,9 @@ type CreateFileOptions struct {
Name string
}
type FileContent struct {
reader io.Reader
blobKey blob.Key
}
const RootDirectoryName = "root"
func FileContentFromReader(reader io.Reader) FileContent {
return FileContent{reader: reader}
}
func FileContentFromBlobKey(blobKey blob.Key) FileContent {
return FileContent{blobKey: blobKey}
}
func NewVirtualFS(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) {
func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) {
sqid, err := sqids.New()
if err != nil {
return nil, err
@@ -86,7 +74,6 @@ func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accoun
Where("account_id = ?", accountID).
Where("public_id = ?", publicID).
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -141,7 +128,7 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
}
if vfs.keyResolver.ShouldPersistKey() {
node.BlobKey, err = vfs.keyResolver.Resolve(ctx, &node)
node.BlobKey, err = vfs.keyResolver.Resolve(ctx, db, &node)
if err != nil {
return nil, err
}
@@ -159,31 +146,31 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
}
func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, content FileContent) error {
if content.reader == nil && content.blobKey.IsNil() {
if content.Reader == nil && content.BlobKey.IsNil() {
return blob.ErrInvalidFileContent
}
if !node.DeletedAt.IsZero() {
if node.DeletedAt != nil {
return ErrNodeNotFound
}
setCols := make([]string, 0, 4)
if content.reader != nil {
key, err := vfs.keyResolver.Resolve(ctx, node)
if content.Reader != nil {
key, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
buf := make([]byte, 3072)
n, err := io.ReadFull(content.reader, buf)
n, err := io.ReadFull(content.Reader, buf)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return err
}
buf = buf[:n]
mt := mimetype.Detect(buf)
cr := ioext.NewCountingReader(io.MultiReader(bytes.NewReader(buf), content.reader))
cr := ioext.NewCountingReader(io.MultiReader(bytes.NewReader(buf), content.Reader))
err = vfs.blobStore.Put(ctx, key, cr)
if err != nil {
@@ -201,9 +188,9 @@ func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, con
setCols = append(setCols, "mime_type", "size", "status")
} else {
node.BlobKey = content.blobKey
node.BlobKey = content.BlobKey
b, err := vfs.blobStore.ReadRange(ctx, content.blobKey, 0, 3072)
b, err := vfs.blobStore.ReadRange(ctx, content.BlobKey, 0, 3072)
if err != nil {
return err
}
@@ -219,7 +206,7 @@ func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, con
node.MimeType = mt.String()
node.Status = NodeStatusReady
s, err := vfs.blobStore.ReadSize(ctx, content.blobKey)
s, err := vfs.blobStore.ReadSize(ctx, content.BlobKey)
if err != nil {
return err
}
@@ -239,6 +226,34 @@ func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, con
return nil
}
func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node) (FileContent, error) {
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
}
func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID, parentID uuid.UUID, name string) (*Node, error) {
pid, err := vfs.generatePublicID()
if err != nil {
@@ -319,7 +334,12 @@ func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, na
return ErrNodeNotFound
}
_, err := db.NewUpdate().Model(node).
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
_, err = db.NewUpdate().Model(node).
WherePK().
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
@@ -332,6 +352,30 @@ func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, na
}
return err
}
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
}
}
}
return nil
}
@@ -340,7 +384,7 @@ func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, pare
return ErrNodeNotFound
}
oldKey, err := vfs.keyResolver.Resolve(ctx, node)
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
@@ -362,7 +406,7 @@ func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, pare
return err
}
newKey, err := vfs.keyResolver.Resolve(ctx, node)
newKey, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
@@ -390,13 +434,10 @@ func (vfs *VirtualFS) AbsolutePath(ctx context.Context, db bun.IDB, node *Node)
if !node.IsAccessible() {
return "", ErrNodeNotFound
}
return buildNodeAbsolutePath(ctx, db, node.ID)
return buildNodeAbsolutePath(ctx, db, node)
}
func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node) error {
if !node.IsAccessible() {
return ErrNodeNotFound
}
switch node.Kind {
case NodeKindFile:
return vfs.permanentlyDeleteFileNode(ctx, db, node)
@@ -408,7 +449,12 @@ func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, nod
}
func (vfs *VirtualFS) permanentlyDeleteFileNode(ctx context.Context, db bun.IDB, node *Node) error {
err := vfs.blobStore.Delete(ctx, node.BlobKey)
key, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil {
return err
}
err = vfs.blobStore.Delete(ctx, key)
if err != nil {
return err
}