mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 13:11:18 +00:00
refactor: initial frontend wiring for new api
This commit is contained in:
@@ -218,6 +218,83 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories.",
|
||||
"tags": [
|
||||
"directories"
|
||||
],
|
||||
"summary": "Bulk delete directories",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Account ID",
|
||||
"name": "accountID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Comma-separated list of directory IDs to delete",
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Move to trash instead of permanent delete",
|
||||
"name": "trash",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Directories deleted",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "All items must be directories",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{accountID}/directories/{directoryID}": {
|
||||
@@ -621,6 +698,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{accountID}/files": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Delete multiple files permanently or move them to trash. All items must be files.",
|
||||
"tags": [
|
||||
"files"
|
||||
],
|
||||
"summary": "Bulk delete files",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Account ID",
|
||||
"name": "accountID",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Comma-separated list of file IDs to delete",
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Move to trash instead of permanent delete",
|
||||
"name": "trash",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Files deleted",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "All items must be files",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"content": {
|
||||
"*/*": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{accountID}/files/{fileID}": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
||||
@@ -31,5 +31,22 @@ storage:
|
||||
cookie:
|
||||
# Domain for cross-subdomain auth cookies.
|
||||
# Set this when frontend and API are on different subdomains (e.g., "app.com" for web.app.com + api.app.com).
|
||||
# Leave empty for single-domain or localhost setups.
|
||||
# Leave empty for same-host cookies (localhost, single domain).
|
||||
# domain: app.com
|
||||
# Secure flag for cookies. If not set, automatically determined from request protocol (true for HTTPS, false for HTTP).
|
||||
# Set explicitly to override automatic detection (useful for local development with HTTPS).
|
||||
# secure: false
|
||||
|
||||
cors:
|
||||
# Allowed origins for cross-origin requests.
|
||||
# Required when frontend and API are on different domains.
|
||||
# If not specified, CORS will be restrictive (only same-origin requests allowed).
|
||||
# Example for cross-domain setup:
|
||||
# allow_origins:
|
||||
# - http://localhost:3000
|
||||
# - https://app.example.com
|
||||
# Allow credentials (cookies, authorization headers) in cross-origin requests.
|
||||
# Should be true when using cookies for authentication in cross-domain setups.
|
||||
# Note: When allow_credentials is true, you must explicitly specify allow_origins
|
||||
# (wildcard "*" is not allowed with credentials for security reasons).
|
||||
# allow_credentials: true
|
||||
|
||||
@@ -13,3 +13,8 @@ storage:
|
||||
mode: hierarchical
|
||||
backend: fs
|
||||
root_path: ./data
|
||||
|
||||
cors:
|
||||
allow_origins:
|
||||
- http://localhost:3000
|
||||
allow_credentials: true
|
||||
|
||||
@@ -447,10 +447,13 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Move one or more files or directories into this directory. All items must currently be in the same source directory.",
|
||||
"description": "Move one or more files or directories into this directory. Returns detailed status for each item including which were successfully moved, which had conflicts, and which encountered errors.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"directories"
|
||||
],
|
||||
@@ -482,10 +485,10 @@ const docTemplate = `{
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Items moved successfully",
|
||||
"200": {
|
||||
"description": "Move operation results with moved, conflict, and error states",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/internal_catalog.moveItemsToDirectoryResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
@@ -511,15 +514,6 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Name conflict in target directory",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1433,6 +1427,109 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_catalog.moveItemError": {
|
||||
"description": "Error details for a failed item move",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"description": "Error message describing what went wrong",
|
||||
"type": "string",
|
||||
"example": "permission denied"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID of the item that failed to move",
|
||||
"type": "string",
|
||||
"example": "mElnUNCm8F22"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_catalog.moveItemsToDirectoryResponse": {
|
||||
"description": "Response from moving items to a directory with status for each item",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"conflicts": {
|
||||
"description": "Array of IDs of items that conflicted with existing items in the target directory",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"xYz123AbC456"
|
||||
]
|
||||
},
|
||||
"errors": {
|
||||
"description": "Array of errors that occurred during the move operation",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/internal_catalog.moveItemError"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"description": "Array of items included in the request (files and directories)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/internal_catalog.moveResponseItem"
|
||||
}
|
||||
},
|
||||
"moved": {
|
||||
"description": "Array of IDs of successfully moved items",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"mElnUNCm8F22",
|
||||
"kRp2XYTq9A55"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_catalog.moveResponseItem": {
|
||||
"description": "Item included in the move operation. Check \"kind\" field to determine type: \"file\" (has size, mimeType) or \"directory\"",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"description": "When the item was created (ISO 8601)",
|
||||
"type": "string",
|
||||
"example": "2024-12-13T15:04:05Z"
|
||||
},
|
||||
"deletedAt": {
|
||||
"description": "When the item was trashed, null if not trashed (ISO 8601)",
|
||||
"type": "string",
|
||||
"example": "2024-12-14T10:00:00Z"
|
||||
},
|
||||
"id": {
|
||||
"description": "Unique item identifier",
|
||||
"type": "string",
|
||||
"example": "mElnUNCm8F22"
|
||||
},
|
||||
"kind": {
|
||||
"description": "Item type: \"file\" or \"directory\"",
|
||||
"type": "string",
|
||||
"example": "file"
|
||||
},
|
||||
"mimeType": {
|
||||
"description": "MIME type (only for files)",
|
||||
"type": "string",
|
||||
"example": "application/pdf"
|
||||
},
|
||||
"name": {
|
||||
"description": "Item name",
|
||||
"type": "string",
|
||||
"example": "document.pdf"
|
||||
},
|
||||
"size": {
|
||||
"description": "File size in bytes (only for files)",
|
||||
"type": "integer",
|
||||
"example": 1048576
|
||||
},
|
||||
"updatedAt": {
|
||||
"description": "When the item was last updated (ISO 8601)",
|
||||
"type": "string",
|
||||
"example": "2024-12-13T16:30:00Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"internal_catalog.patchDirectoryRequest": {
|
||||
"description": "Request to update directory properties",
|
||||
"type": "object",
|
||||
|
||||
@@ -187,6 +187,65 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories.",
|
||||
"tags": [
|
||||
"directories"
|
||||
],
|
||||
"summary": "Bulk delete directories",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Account ID",
|
||||
"name": "accountID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of directory IDs to delete",
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Move to trash instead of permanent delete",
|
||||
"name": "trash",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Directories deleted",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "All items must be directories",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{accountID}/directories/{directoryID}": {
|
||||
@@ -512,6 +571,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{accountID}/files": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Delete multiple files permanently or move them to trash. All items must be files.",
|
||||
"tags": [
|
||||
"files"
|
||||
],
|
||||
"summary": "Bulk delete files",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Account ID",
|
||||
"name": "accountID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of file IDs to delete",
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Move to trash instead of permanent delete",
|
||||
"name": "trash",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Files deleted",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "All items must be files",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Not authenticated",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/accounts/{accountID}/files/{fileID}": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
||||
@@ -225,6 +225,85 @@ definitions:
|
||||
example: kRp2XYTq9A55
|
||||
type: string
|
||||
type: object
|
||||
internal_catalog.moveItemError:
|
||||
description: Error details for a failed item move
|
||||
properties:
|
||||
error:
|
||||
description: Error message describing what went wrong
|
||||
example: permission denied
|
||||
type: string
|
||||
id:
|
||||
description: ID of the item that failed to move
|
||||
example: mElnUNCm8F22
|
||||
type: string
|
||||
type: object
|
||||
internal_catalog.moveItemsToDirectoryResponse:
|
||||
description: Response from moving items to a directory with status for each item
|
||||
properties:
|
||||
conflicts:
|
||||
description: Array of IDs of items that conflicted with existing items in
|
||||
the target directory
|
||||
example:
|
||||
- xYz123AbC456
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
errors:
|
||||
description: Array of errors that occurred during the move operation
|
||||
items:
|
||||
$ref: '#/definitions/internal_catalog.moveItemError'
|
||||
type: array
|
||||
items:
|
||||
description: Array of items included in the request (files and directories)
|
||||
items:
|
||||
$ref: '#/definitions/internal_catalog.moveResponseItem'
|
||||
type: array
|
||||
moved:
|
||||
description: Array of IDs of successfully moved items
|
||||
example:
|
||||
- mElnUNCm8F22
|
||||
- kRp2XYTq9A55
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
type: object
|
||||
internal_catalog.moveResponseItem:
|
||||
description: 'Item included in the move operation. Check "kind" field to determine
|
||||
type: "file" (has size, mimeType) or "directory"'
|
||||
properties:
|
||||
createdAt:
|
||||
description: When the item was created (ISO 8601)
|
||||
example: "2024-12-13T15:04:05Z"
|
||||
type: string
|
||||
deletedAt:
|
||||
description: When the item was trashed, null if not trashed (ISO 8601)
|
||||
example: "2024-12-14T10:00:00Z"
|
||||
type: string
|
||||
id:
|
||||
description: Unique item identifier
|
||||
example: mElnUNCm8F22
|
||||
type: string
|
||||
kind:
|
||||
description: 'Item type: "file" or "directory"'
|
||||
example: file
|
||||
type: string
|
||||
mimeType:
|
||||
description: MIME type (only for files)
|
||||
example: application/pdf
|
||||
type: string
|
||||
name:
|
||||
description: Item name
|
||||
example: document.pdf
|
||||
type: string
|
||||
size:
|
||||
description: File size in bytes (only for files)
|
||||
example: 1048576
|
||||
type: integer
|
||||
updatedAt:
|
||||
description: When the item was last updated (ISO 8601)
|
||||
example: "2024-12-13T16:30:00Z"
|
||||
type: string
|
||||
type: object
|
||||
internal_catalog.patchDirectoryRequest:
|
||||
description: Request to update directory properties
|
||||
properties:
|
||||
@@ -616,8 +695,9 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Move one or more files or directories into this directory. All
|
||||
items must currently be in the same source directory.
|
||||
description: Move one or more files or directories into this directory. Returns
|
||||
detailed status for each item including which were successfully moved, which
|
||||
had conflicts, and which encountered errors.
|
||||
parameters:
|
||||
- description: Account ID
|
||||
format: uuid
|
||||
@@ -636,11 +716,13 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/internal_catalog.postDirectoryContentRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: Items moved successfully
|
||||
"200":
|
||||
description: Move operation results with moved, conflict, and error states
|
||||
schema:
|
||||
type: string
|
||||
$ref: '#/definitions/internal_catalog.moveItemsToDirectoryResponse'
|
||||
"400":
|
||||
description: Invalid request or items not in same directory
|
||||
schema:
|
||||
@@ -657,12 +739,6 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"409":
|
||||
description: Name conflict in target directory
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Move items to directory
|
||||
|
||||
BIN
apps/backend/drexa
Executable file
BIN
apps/backend/drexa
Executable file
Binary file not shown.
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user