refactor: initial frontend wiring for new api

This commit is contained in:
2025-12-15 00:13:10 +00:00
parent 528aa943fa
commit 05edf69ca7
63 changed files with 1876 additions and 1991 deletions

View File

@@ -14,14 +14,19 @@ type Account struct {
// Unique account identifier
ID uuid.UUID `bun:",pk,type:uuid" json:"id" example:"550e8400-e29b-41d4-a716-446655440000"`
// ID of the user who owns this account
UserID uuid.UUID `bun:"user_id,notnull,type:uuid" json:"userId" example:"550e8400-e29b-41d4-a716-446655440001"`
// Current storage usage in bytes
StorageUsageBytes int64 `bun:"storage_usage_bytes,notnull" json:"storageUsageBytes" example:"1073741824"`
// Maximum storage quota in bytes
StorageQuotaBytes int64 `bun:"storage_quota_bytes,notnull" json:"storageQuotaBytes" example:"10737418240"`
// When the account was created (ISO 8601)
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt" example:"2024-12-13T15:04:05Z"`
// When the account was last updated (ISO 8601)
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt" example:"2024-12-13T16:30:00Z"`
}

View File

@@ -54,6 +54,7 @@ func NewHTTPHandler(accountService *Service, authService *auth.Service, db *bun.
}
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) fiber.Router {
api.Get("/accounts", h.authMiddleware, h.listAccounts)
api.Post("/accounts", h.registerAccount)
account := api.Group("/accounts/:accountID")
@@ -86,6 +87,24 @@ func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error {
return c.Next()
}
// listAccounts lists all accounts for the authenticated user
// @Summary List accounts
// @Description Retrieve all accounts for the authenticated user
// @Tags accounts
// @Produce json
// @Security BearerAuth
// @Success 200 {array} Account "List of accounts for the authenticated user"
// @Failure 401 {string} string "Not authenticated"
// @Router /accounts [get]
func (h *HTTPHandler) listAccounts(c *fiber.Ctx) error {
u := reqctx.AuthenticatedUser(c).(*user.User)
accounts, err := h.accountService.ListAccounts(c.Context(), h.db, u.ID)
if err != nil {
return httperr.Internal(err)
}
return c.JSON(accounts)
}
// getAccount retrieves account information
// @Summary Get account
// @Description Retrieve account details including storage usage and quota

View File

@@ -90,6 +90,18 @@ func (s *Service) CreateAccount(ctx context.Context, db bun.IDB, userID uuid.UUI
return account, nil
}
func (s *Service) ListAccounts(ctx context.Context, db bun.IDB, userID uuid.UUID) ([]*Account, error) {
var accounts []*Account
err := db.NewSelect().Model(&accounts).Where("user_id = ?", userID).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return make([]*Account, 0), nil
}
return nil, err
}
return accounts, nil
}
func (s *Service) AccountByUserID(ctx context.Context, db bun.IDB, userID uuid.UUID) (*Account, error) {
var account Account
err := db.NewSelect().Model(&account).Where("user_id = ?", userID).Scan(ctx)

View File

@@ -11,6 +11,10 @@ type CookieConfig struct {
// Domain for cross-subdomain cookies (e.g., "app.com" for web.app.com + api.app.com).
// Leave empty for same-host cookies (localhost, single domain).
Domain string
// Secure controls whether cookies are only sent over HTTPS.
// If nil, automatically set based on request protocol (true for HTTPS, false for HTTP).
// If explicitly set, this value is used regardless of protocol.
Secure *bool
}
// authCookies returns auth cookies from the given fiber context.
@@ -29,28 +33,37 @@ func authCookies(c *fiber.Ctx) map[string]string {
}
// setAuthCookies sets HTTP-only auth cookies with security settings derived from the request.
// Secure flag is based on actual protocol (works automatically with proxies/tunnels).
// Secure flag is based on actual protocol (works automatically with proxies/tunnels),
// unless explicitly set in cfg.Secure.
func setAuthCookies(c *fiber.Ctx, accessToken, refreshToken string, cfg CookieConfig) {
secure := c.Protocol() == "https"
c.Cookie(&fiber.Cookie{
accessTokenCookie := &fiber.Cookie{
Name: cookieKeyAccessToken,
Value: accessToken,
Path: "/",
Domain: cfg.Domain,
Expires: time.Now().Add(accessTokenValidFor),
SameSite: fiber.CookieSameSiteLaxMode,
HTTPOnly: true,
Secure: secure,
})
c.Cookie(&fiber.Cookie{
}
if cfg.Domain != "" {
accessTokenCookie.Domain = cfg.Domain
}
refreshTokenCookie := &fiber.Cookie{
Name: cookieKeyRefreshToken,
Value: refreshToken,
Path: "/",
Domain: cfg.Domain,
Expires: time.Now().Add(refreshTokenValidFor),
SameSite: fiber.CookieSameSiteLaxMode,
HTTPOnly: true,
Secure: secure,
})
}
if cfg.Domain != "" {
refreshTokenCookie.Domain = cfg.Domain
}
c.Cookie(accessTokenCookie)
c.Cookie(refreshTokenCookie)
}

View File

@@ -85,12 +85,23 @@ func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
}
directoryID := c.Params("directoryID")
node, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, directoryID)
if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound)
var node *virtualfs.Node
if directoryID == "root" {
n, err := h.vfs.FindRootDirectory(c.Context(), h.db, account.ID)
if err != nil {
return httperr.Internal(err)
}
return httperr.Internal(err)
node = n
} else {
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, directoryID)
if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
node = n
}
c.Locals("directory", node)
@@ -349,23 +360,117 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
shouldTrash := c.Query("trash") == "true"
if shouldTrash {
err = h.vfs.SoftDeleteNode(c.Context(), h.db, node)
_, err := h.vfs.SoftDeleteNode(c.Context(), tx, node)
if err != nil {
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
return c.JSON(directoryInfoFromNode(node))
} else {
err = h.vfs.PermanentlyDeleteNode(c.Context(), h.db, node)
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node)
if err != nil {
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
return c.SendStatus(fiber.StatusNoContent)
}
err = tx.Commit()
}
// deleteDirectories removes multiple directories
// @Summary Bulk delete directories
// @Description Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories.
// @Tags directories
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param id query string true "Comma-separated list of directory IDs to delete" example:"kRp2XYTq9A55,xYz123AbC456"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 204 {string} string "Directories deleted"
// @Failure 400 {object} map[string]string "All items must be directories"
// @Failure 401 {string} string "Not authenticated"
// @Router /accounts/{accountID}/directories [delete]
func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
account := account.CurrentAccount(c)
if account == nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
idq := c.Query("id", "")
if idq == "" {
return c.SendStatus(fiber.StatusNoContent)
}
ids := strings.Split(idq, ",")
if len(ids) == 0 {
return c.SendStatus(fiber.StatusNoContent)
}
shouldTrash := c.Query("trash") == "true"
tx, err := h.db.BeginTx(c.Context(), nil)
if err != nil {
return httperr.Internal(err)
}
defer tx.Rollback()
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids)
if err != nil {
return httperr.Internal(err)
}
return c.SendStatus(fiber.StatusNoContent)
if len(nodes) == 0 {
return c.SendStatus(fiber.StatusNoContent)
}
for _, node := range nodes {
if node.Kind != virtualfs.NodeKindDirectory {
return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be directories", nil)
}
}
if shouldTrash {
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes)
if err != nil {
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
res := make([]DirectoryInfo, 0, len(deleted))
for _, node := range deleted {
res = append(res, directoryInfoFromNode(node))
}
return c.JSON(deleted)
} else {
for _, node := range nodes {
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node)
if err != nil {
return httperr.Internal(err)
}
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
return c.SendStatus(fiber.StatusNoContent)
}
}
// moveItemsToDirectory moves files and directories into this directory

View File

@@ -2,7 +2,7 @@ package catalog
import (
"errors"
"fmt"
"strings"
"time"
"github.com/get-drexa/drexa/internal/account"
@@ -168,8 +168,6 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
return httperr.Internal(err)
}
fmt.Printf("node deleted at: %v\n", node.DeletedAt)
return c.JSON(FileInfo{
ID: node.PublicID,
Name: node.Name,
@@ -206,19 +204,20 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
shouldTrash := c.Query("trash") == "true"
if shouldTrash {
err = h.vfs.SoftDeleteNode(c.Context(), tx, node)
deleted, err := h.vfs.SoftDeleteNode(c.Context(), tx, node)
if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
return c.JSON(FileInfo{
ID: node.PublicID,
Name: node.Name,
Size: node.Size,
MimeType: node.MimeType,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
DeletedAt: node.DeletedAt,
})
return c.JSON(fileInfoFromNode(deleted))
} else {
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node)
if err != nil {
@@ -233,3 +232,85 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
}
// deleteFiles removes multiple files
// @Summary Bulk delete files
// @Description Delete multiple files permanently or move them to trash. All items must be files.
// @Tags files
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param id query string true "Comma-separated list of file IDs to delete" example:"mElnUNCm8F22,kRp2XYTq9A55"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 204 {string} string "Files deleted"
// @Failure 400 {object} map[string]string "All items must be files"
// @Failure 401 {string} string "Not authenticated"
// @Router /accounts/{accountID}/files [delete]
func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
account := account.CurrentAccount(c)
if account == nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
idq := c.Query("id", "")
if idq == "" {
return c.SendStatus(fiber.StatusNoContent)
}
ids := strings.Split(idq, ",")
if len(ids) == 0 {
return c.SendStatus(fiber.StatusNoContent)
}
shouldTrash := c.Query("trash") == "true"
tx, err := h.db.BeginTx(c.Context(), nil)
if err != nil {
return httperr.Internal(err)
}
defer tx.Rollback()
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids)
if err != nil {
return httperr.Internal(err)
}
if len(nodes) == 0 {
return c.SendStatus(fiber.StatusNoContent)
}
if shouldTrash {
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes)
if err != nil {
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
res := make([]FileInfo, 0, len(deleted))
for _, node := range deleted {
res = append(res, fileInfoFromNode(node))
}
return c.JSON(res)
} else {
err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes)
if err != nil {
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be files", err)
}
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
return c.SendStatus(fiber.StatusNoContent)
}
}

View File

@@ -30,6 +30,8 @@ func NewHTTPHandler(vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler {
}
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
api.Delete("/files", h.deleteFiles)
fg := api.Group("/files/:fileID")
fg.Use(h.currentFileMiddleware)
fg.Get("/", h.fetchFile)
@@ -38,6 +40,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
fg.Delete("/", h.deleteFile)
api.Post("/directories", h.createDirectory)
api.Delete("/directories", h.deleteDirectories)
dg := api.Group("/directories/:directoryID")
dg.Use(h.currentDirectoryMiddleware)
@@ -47,3 +50,35 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
dg.Patch("/", h.patchDirectory)
dg.Delete("/", h.deleteDirectory)
}
func fileInfoFromNode(node *virtualfs.Node) FileInfo {
return FileInfo{
Kind: DirItemKindFile,
ID: node.PublicID,
Name: node.Name,
Size: node.Size,
MimeType: node.MimeType,
}
}
func directoryInfoFromNode(node *virtualfs.Node) DirectoryInfo {
return DirectoryInfo{
Kind: DirItemKindDirectory,
ID: node.PublicID,
Name: node.Name,
CreatedAt: node.CreatedAt,
UpdatedAt: node.UpdatedAt,
DeletedAt: node.DeletedAt,
}
}
func toDirectoryItem(node *virtualfs.Node) any {
switch node.Kind {
default:
return FileInfo{}
case virtualfs.NodeKindDirectory:
return directoryInfoFromNode(node)
case virtualfs.NodeKindFile:
return fileInfoFromNode(node)
}
}

View File

@@ -28,6 +28,7 @@ type Config struct {
JWT JWTConfig `yaml:"jwt"`
Storage StorageConfig `yaml:"storage"`
Cookie CookieConfig `yaml:"cookie"`
CORS CORSConfig `yaml:"cors"`
}
type ServerConfig struct {
@@ -55,9 +56,20 @@ type StorageConfig struct {
// CookieConfig controls auth cookie behavior.
// Domain is optional - only needed for cross-subdomain setups (e.g., "app.com" for web.app.com + api.app.com).
// Secure flag is derived from the request protocol automatically.
// Secure flag is derived from the request protocol automatically, unless explicitly set.
type CookieConfig struct {
Domain string `yaml:"domain"`
Secure *bool `yaml:"secure"`
}
// CORSConfig controls Cross-Origin Resource Sharing behavior.
// AllowOrigins specifies which origins are allowed to make cross-origin requests.
// If empty, CORS will allow all origins (not recommended for production).
// AllowCredentials enables sending credentials (cookies, authorization headers) in cross-origin requests.
// This should be true when using cookies for authentication in cross-domain setups.
type CORSConfig struct {
AllowOrigins []string `yaml:"allow_origins"`
AllowCredentials bool `yaml:"allow_credentials"`
}
// ConfigFromFile loads configuration from a YAML file.
@@ -159,5 +171,10 @@ func (c *Config) Validate() []error {
}
}
// CORS validation
if c.CORS.AllowCredentials && len(c.CORS.AllowOrigins) == 0 {
errs = append(errs, errors.New("cors.allow_origins is required when cors.allow_credentials is true (cannot use wildcard '*' with credentials)"))
}
return errs
}

View File

@@ -3,6 +3,7 @@ package drexa
import (
"context"
"fmt"
"strings"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/auth"
@@ -14,6 +15,7 @@ import (
"github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/uptrace/bun"
"github.com/uptrace/bun/extra/bundebug"
@@ -44,6 +46,16 @@ func NewServer(c Config) (*Server, error) {
})
app.Use(logger.New())
// Configure CORS middleware
corsConfig := cors.Config{
AllowOrigins: "",
AllowCredentials: c.CORS.AllowCredentials,
}
if len(c.CORS.AllowOrigins) > 0 {
corsConfig.AllowOrigins = strings.Join(c.CORS.AllowOrigins, ",")
}
app.Use(cors.New(corsConfig))
db := database.NewFromPostgres(c.Database.PostgresURL)
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
@@ -92,6 +104,7 @@ func NewServer(c Config) (*Server, error) {
cookieConfig := auth.CookieConfig{
Domain: c.Cookie.Domain,
Secure: c.Cookie.Secure,
}
authMiddleware := auth.NewAuthMiddleware(authService, db, cookieConfig)

View File

@@ -18,10 +18,10 @@ type User struct {
// User's display name
DisplayName string `bun:"display_name" json:"displayName" example:"John Doe"`
// User's email address
Email string `bun:"email,unique,notnull" json:"email" example:"john@example.com"`
Password password.Hashed `bun:"password,notnull" json:"-" swaggerignore:"true"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"-" swaggerignore:"true"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"-" swaggerignore:"true"`
Email string `bun:"email,unique,notnull" json:"email" example:"john@example.com"`
Password password.Hashed `bun:"password,notnull" json:"-" swaggerignore:"true"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"-" swaggerignore:"true"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"-" swaggerignore:"true"`
}
func newUserID() (uuid.UUID, error) {

View File

@@ -72,7 +72,7 @@ func (r *HierarchicalKeyResolver) ResolveBulkMoveOps(ctx context.Context, db bun
for i, node := range nodes {
oldKey := blob.Key(fmt.Sprintf("%s/%s/%s", accountID, oldParentPath, node.Name))
newKey := blob.Key(fmt.Sprintf("%s/%s/%s", accountID, newParentPath, node.Name))
ops[i] = BlobMoveOp{OldKey: oldKey, NewKey: newKey}
ops[i] = BlobMoveOp{Node: node, OldKey: oldKey, NewKey: newKey}
}
return ops, nil

View File

@@ -29,6 +29,7 @@ type DeletionPlan struct {
// BlobMoveOp represents a blob move operation from OldKey to NewKey.
type BlobMoveOp struct {
Node *Node
OldKey blob.Key
NewKey blob.Key
}

View File

@@ -37,6 +37,17 @@ type CreateFileOptions struct {
Name string
}
type MoveFileError struct {
Node *Node
Error error
}
type MoveFilesResult struct {
Moved []*Node
Conflicts []*Node
Errors []MoveFileError
}
const RootDirectoryName = "root"
func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) {
@@ -97,6 +108,26 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accou
return nodes, nil
}
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
}
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node) ([]*Node, error) {
if !node.IsAccessible() {
return nil, ErrNodeNotFound
@@ -299,26 +330,43 @@ 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) error {
if !node.IsAccessible() {
return ErrNodeNotFound
func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node) (*Node, error) {
deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node})
if err != nil {
return nil, err
}
if len(deleted) == 0 {
return nil, ErrNodeNotFound
}
return deleted[0], nil
}
func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node) ([]*Node, error) {
if len(nodes) == 0 {
return nil, nil
}
_, err := db.NewUpdate().Model(node).
WherePK().
Where("deleted_at IS NULL").
deletableNodes := make([]*Node, 0, len(nodes))
nodeIDs := make([]uuid.UUID, 0, len(nodes))
for _, node := range nodes {
if node.IsAccessible() {
nodeIDs = append(nodeIDs, node.ID)
deletableNodes = append(deletableNodes, node)
}
}
_, err := db.NewUpdate().Model(deletableNodes).
Where("id IN (?)", bun.In(nodeIDs)).
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
Set("deleted_at = NOW()").
Returning("deleted_at").
Exec(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNodeNotFound
}
return err
return nil, err
}
return nil
return deletableNodes, nil
}
func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) error {
@@ -447,23 +495,53 @@ 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) error {
func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID) (*MoveFilesResult, error) {
if len(nodes) == 0 {
return nil
return nil, nil
}
// Validate all nodes are accessible
nodeIDs := make([]uuid.UUID, len(nodes))
nodeNames := make([]string, len(nodes))
for i, node := range nodes {
if !node.IsAccessible() {
return ErrNodeNotFound
return nil, ErrNodeNotFound
}
nodeIDs[i] = node.ID
nodeNames[i] = node.Name
}
moveOps, err := vfs.keyResolver.ResolveBulkMoveOps(ctx, db, nodes, newParentID)
var conflicts []*Node
err := db.NewSelect().Model(&conflicts).
Where("account_id = ?", nodes[0].AccountID).
Where("parent_id = ?", newParentID).
Where("name IN (?)", bun.In(nodeNames)).
Scan(ctx)
if err != nil {
return err
return nil, err
}
conflictID := make(map[uuid.UUID]struct{})
for _, c := range conflicts {
conflictID[c.ID] = struct{}{}
}
movableNodes := make([]*Node, 0, len(nodes)-len(conflicts))
for _, node := range nodes {
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().
@@ -474,17 +552,23 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
Set("parent_id = ?", newParentID).
Exec(ctx)
if err != nil {
if database.IsUniqueViolation(err) {
return ErrNodeConflict
}
return err
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 {
return err
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})
}
}
}
@@ -493,7 +577,11 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
node.ParentID = newParentID
}
return nil
return &MoveFilesResult{
Moved: movableNodes,
Conflicts: conflicts,
Errors: errs,
}, nil
}
func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node) (Path, error) {
@@ -503,6 +591,45 @@ func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node) (Pat
return buildNoteAbsolutePath(ctx, db, node)
}
func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node) error {
if len(nodes) == 0 {
return nil
}
for _, n := range nodes {
if n.Kind != NodeKindFile {
return ErrUnsupportedOperation
}
}
deletedIDs := make([]uuid.UUID, 0, len(nodes))
for _, n := range nodes {
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
}
_, err := db.NewDelete().Model((*Node)(nil)).
Where("id IN (?)", bun.In(deletedIDs)).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node) error {
switch node.Kind {
case NodeKindFile:
@@ -522,6 +649,10 @@ func (vfs *VirtualFS) permanentlyDeleteFileNode(ctx context.Context, db bun.IDB,
err = vfs.blobStore.Delete(ctx, key)
if err != nil {
if errors.Is(err, blob.ErrNotFound) {
// no op if the blob does not exist
return nil
}
return err
}