feat: initial sharing impl

This commit is contained in:
2025-12-27 19:27:08 +00:00
parent 94458c2f1e
commit 1a1fc4743a
23 changed files with 4019 additions and 1232 deletions

View File

@@ -298,6 +298,19 @@
} }
], ],
"responses": { "responses": {
"200": {
"description": "Trashed directories (when trash=true)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/internal_catalog.DirectoryInfo"
}
}
}
}
},
"204": { "204": {
"description": "Directories deleted", "description": "Directories deleted",
"content": { "content": {
@@ -453,6 +466,16 @@
} }
], ],
"responses": { "responses": {
"200": {
"description": "Trashed directory info (when trash=true)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_catalog.DirectoryInfo"
}
}
}
},
"204": { "204": {
"description": "Directory deleted", "description": "Directory deleted",
"content": { "content": {
@@ -582,7 +605,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "Get all files and subdirectories within a directory", "description": "Get all files and subdirectories within a directory with optional pagination and sorting",
"tags": [ "tags": [
"directories" "directories"
], ],
@@ -599,23 +622,76 @@
} }
}, },
{ {
"description": "Directory ID", "description": "Directory ID (use 'root' for the root directory)",
"name": "directoryID", "name": "directoryID",
"in": "path", "in": "path",
"required": true, "required": true,
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"description": "Sort field: name, createdAt, or updatedAt",
"name": "orderBy",
"in": "query",
"schema": {
"type": "string",
"enum": [
"name",
"createdAt",
"updatedAt"
]
}
},
{
"description": "Sort direction: asc or desc",
"name": "dir",
"in": "query",
"schema": {
"type": "string",
"enum": [
"asc",
"desc"
]
}
},
{
"description": "Maximum number of items to return (default: 100, min: 1)",
"name": "limit",
"in": "query",
"schema": {
"type": "integer"
}
},
{
"description": "Cursor for pagination (base64-encoded cursor from previous response)",
"name": "cursor",
"in": "query",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "Array of FileInfo and DirectoryInfo objects", "description": "Paginated list of FileInfo and DirectoryInfo objects",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "array", "$ref": "#/components/schemas/internal_catalog.listDirectoryResponse"
"items": {} }
}
}
},
"400": {
"description": "Invalid limit or cursor",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
} }
} }
} }
@@ -735,6 +811,76 @@
} }
} }
}, },
"/accounts/{accountID}/directories/{directoryID}/shares": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Get all share links that include this directory",
"tags": [
"directories"
],
"summary": "List directory shares",
"parameters": [
{
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"description": "Directory ID",
"name": "directoryID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Array of shares",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/github_com_get-drexa_drexa_internal_sharing.Share"
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Directory not found",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/accounts/{accountID}/files": { "/accounts/{accountID}/files": {
"delete": { "delete": {
"security": [ "security": [
@@ -778,6 +924,19 @@
} }
], ],
"responses": { "responses": {
"200": {
"description": "Trashed files (when trash=true)",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/internal_catalog.FileInfo"
}
}
}
}
},
"204": { "204": {
"description": "Files deleted", "description": "Files deleted",
"content": { "content": {
@@ -1132,6 +1291,293 @@
} }
} }
}, },
"/accounts/{accountID}/files/{fileID}/shares": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Get all share links that include this file",
"tags": [
"files"
],
"summary": "List file shares",
"parameters": [
{
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"description": "File ID",
"name": "fileID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Array of shares",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/github_com_get-drexa_drexa_internal_sharing.Share"
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "File not found",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/accounts/{accountID}/shares": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Create a new share link for one or more files or directories",
"tags": [
"shares"
],
"summary": "Create share",
"parameters": [
{
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_sharing.createShareRequest"
}
}
},
"description": "Share details",
"required": true
},
"responses": {
"200": {
"description": "Created share",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_sharing.Share"
}
}
}
},
"400": {
"description": "Invalid request or no items provided",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "One or more items not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
},
"/accounts/{accountID}/shares/{shareID}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve share link details by ID",
"tags": [
"shares"
],
"summary": "Get share",
"parameters": [
{
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"description": "Share ID",
"name": "shareID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Share details",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/internal_sharing.Share"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Share not found",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Delete a share link, revoking access for all users",
"tags": [
"shares"
],
"summary": "Delete share",
"parameters": [
{
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"description": "Share ID",
"name": "shareID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Share deleted",
"content": {
"*/*": {
"schema": {
"type": "string"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"*/*": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Share not found",
"content": {
"*/*": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/accounts/{accountID}/uploads": { "/accounts/{accountID}/uploads": {
"post": { "post": {
"security": [ "security": [
@@ -1567,6 +2013,32 @@
} }
}, },
"schemas": { "schemas": {
"github_com_get-drexa_drexa_internal_sharing.Share": {
"description": "Share link information including expiration and timestamps",
"type": "object",
"properties": {
"createdAt": {
"description": "When the share was created (ISO 8601)",
"type": "string",
"example": "2024-12-13T15:04:05Z"
},
"expiresAt": {
"description": "When the share expires, null if it never expires (ISO 8601)",
"type": "string",
"example": "2025-01-15T00:00:00Z"
},
"id": {
"description": "Unique share identifier (public ID)",
"type": "string",
"example": "kRp2XYTq9A55"
},
"updatedAt": {
"description": "When the share was last updated (ISO 8601)",
"type": "string",
"example": "2024-12-13T16:30:00Z"
}
}
},
"github_com_get-drexa_drexa_internal_user.User": { "github_com_get-drexa_drexa_internal_user.User": {
"description": "User account information", "description": "User account information",
"type": "object", "type": "object",
@@ -1802,6 +2274,11 @@
"type": "string", "type": "string",
"example": "My Documents" "example": "My Documents"
}, },
"parentId": {
"description": "ParentID is the public ID of the directory this directory is in",
"type": "string",
"example": "kRp2XYTq9A55"
},
"path": { "path": {
"description": "Full path from root (included when ?include=path)", "description": "Full path from root (included when ?include=path)",
"type": "array", "type": "array",
@@ -1850,6 +2327,11 @@
"type": "string", "type": "string",
"example": "document.pdf" "example": "document.pdf"
}, },
"parentId": {
"description": "ParentID is the public ID of the directory this file is in",
"type": "string",
"example": "kRp2XYTq9A55"
},
"size": { "size": {
"description": "File size in bytes", "description": "File size in bytes",
"type": "integer", "type": "integer",
@@ -1878,6 +2360,21 @@
} }
} }
}, },
"internal_catalog.listDirectoryResponse": {
"description": "Response to a request to list the contents of a directory",
"type": "object",
"properties": {
"items": {
"description": "Items is the list of items in the directory, limited to the limit specified in the request",
"type": "array",
"items": {}
},
"nextCursor": {
"description": "NextCursor is the cursor to use to get the next page of results",
"type": "string"
}
}
},
"internal_catalog.moveItemError": { "internal_catalog.moveItemError": {
"description": "Error details for a failed item move", "description": "Error details for a failed item move",
"type": "object", "type": "object",
@@ -1988,6 +2485,54 @@
} }
} }
}, },
"internal_sharing.Share": {
"description": "Share link information including expiration and timestamps",
"type": "object",
"properties": {
"createdAt": {
"description": "When the share was created (ISO 8601)",
"type": "string",
"example": "2024-12-13T15:04:05Z"
},
"expiresAt": {
"description": "When the share expires, null if it never expires (ISO 8601)",
"type": "string",
"example": "2025-01-15T00:00:00Z"
},
"id": {
"description": "Unique share identifier (public ID)",
"type": "string",
"example": "kRp2XYTq9A55"
},
"updatedAt": {
"description": "When the share was last updated (ISO 8601)",
"type": "string",
"example": "2024-12-13T16:30:00Z"
}
}
},
"internal_sharing.createShareRequest": {
"description": "Request to create a new share link for files or directories",
"type": "object",
"properties": {
"expiresAt": {
"description": "Optional expiration time for the share (ISO 8601)",
"type": "string",
"example": "2025-01-15T00:00:00Z"
},
"items": {
"description": "Array of file/directory IDs to share",
"type": "array",
"items": {
"type": "string"
},
"example": [
"mElnUNCm8F22",
"kRp2XYTq9A55"
]
}
}
},
"internal_upload.Status": { "internal_upload.Status": {
"description": "Upload status enumeration", "description": "Upload status enumeration",
"type": "string", "type": "string",
@@ -2089,4 +2634,4 @@
} }
} }
} }
} }

View File

@@ -256,6 +256,15 @@
} }
], ],
"responses": { "responses": {
"200": {
"description": "Trashed directories (when trash=true)",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_catalog.DirectoryInfo"
}
}
},
"204": { "204": {
"description": "Directories deleted", "description": "Directories deleted",
"schema": { "schema": {
@@ -378,6 +387,12 @@
} }
], ],
"responses": { "responses": {
"200": {
"description": "Trashed directory info (when trash=true)",
"schema": {
"$ref": "#/definitions/internal_catalog.DirectoryInfo"
}
},
"204": { "204": {
"description": "Directory deleted", "description": "Directory deleted",
"schema": { "schema": {
@@ -479,7 +494,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "Get all files and subdirectories within a directory", "description": "Get all files and subdirectories within a directory with optional pagination and sorting",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -498,18 +513,59 @@
}, },
{ {
"type": "string", "type": "string",
"description": "Directory ID", "description": "Directory ID (use 'root' for the root directory)",
"name": "directoryID", "name": "directoryID",
"in": "path", "in": "path",
"required": true "required": true
},
{
"enum": [
"name",
"createdAt",
"updatedAt"
],
"type": "string",
"description": "Sort field: name, createdAt, or updatedAt",
"name": "orderBy",
"in": "query"
},
{
"enum": [
"asc",
"desc"
],
"type": "string",
"description": "Sort direction: asc or desc",
"name": "dir",
"in": "query"
},
{
"type": "integer",
"description": "Maximum number of items to return (default: 100, min: 1)",
"name": "limit",
"in": "query"
},
{
"type": "string",
"description": "Cursor for pagination (base64-encoded cursor from previous response)",
"name": "cursor",
"in": "query"
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "Array of FileInfo and DirectoryInfo objects", "description": "Paginated list of FileInfo and DirectoryInfo objects",
"schema": { "schema": {
"type": "array", "$ref": "#/definitions/internal_catalog.listDirectoryResponse"
"items": {} }
},
"400": {
"description": "Invalid limit or cursor",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
} }
}, },
"401": { "401": {
@@ -603,6 +659,63 @@
} }
} }
}, },
"/accounts/{accountID}/directories/{directoryID}/shares": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Get all share links that include this directory",
"produces": [
"application/json"
],
"tags": [
"directories"
],
"summary": "List directory shares",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Directory ID",
"name": "directoryID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Array of shares",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_get-drexa_drexa_internal_sharing.Share"
}
}
},
"401": {
"description": "Not authenticated",
"schema": {
"type": "string"
}
},
"404": {
"description": "Directory not found",
"schema": {
"type": "string"
}
}
}
}
},
"/accounts/{accountID}/files": { "/accounts/{accountID}/files": {
"delete": { "delete": {
"security": [ "security": [
@@ -640,6 +753,15 @@
} }
], ],
"responses": { "responses": {
"200": {
"description": "Trashed files (when trash=true)",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/internal_catalog.FileInfo"
}
}
},
"204": { "204": {
"description": "Files deleted", "description": "Files deleted",
"schema": { "schema": {
@@ -916,6 +1038,240 @@
} }
} }
}, },
"/accounts/{accountID}/files/{fileID}/shares": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Get all share links that include this file",
"produces": [
"application/json"
],
"tags": [
"files"
],
"summary": "List file shares",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "File ID",
"name": "fileID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Array of shares",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_get-drexa_drexa_internal_sharing.Share"
}
}
},
"401": {
"description": "Not authenticated",
"schema": {
"type": "string"
}
},
"404": {
"description": "File not found",
"schema": {
"type": "string"
}
}
}
}
},
"/accounts/{accountID}/shares": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Create a new share link for one or more files or directories",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"shares"
],
"summary": "Create share",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true
},
{
"description": "Share details",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/internal_sharing.createShareRequest"
}
}
],
"responses": {
"200": {
"description": "Created share",
"schema": {
"$ref": "#/definitions/internal_sharing.Share"
}
},
"400": {
"description": "Invalid request or no items provided",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Not authenticated",
"schema": {
"type": "string"
}
},
"404": {
"description": "One or more items not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/accounts/{accountID}/shares/{shareID}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Retrieve share link details by ID",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"shares"
],
"summary": "Get share",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Share ID",
"name": "shareID",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Share details",
"schema": {
"$ref": "#/definitions/internal_sharing.Share"
}
},
"401": {
"description": "Not authenticated",
"schema": {
"type": "string"
}
},
"404": {
"description": "Share not found",
"schema": {
"type": "string"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Delete a share link, revoking access for all users",
"tags": [
"shares"
],
"summary": "Delete share",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Account ID",
"name": "accountID",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Share ID",
"name": "shareID",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Share deleted",
"schema": {
"type": "string"
}
},
"401": {
"description": "Not authenticated",
"schema": {
"type": "string"
}
},
"404": {
"description": "Share not found",
"schema": {
"type": "string"
}
}
}
}
},
"/accounts/{accountID}/uploads": { "/accounts/{accountID}/uploads": {
"post": { "post": {
"security": [ "security": [
@@ -1271,6 +1627,32 @@
} }
}, },
"definitions": { "definitions": {
"github_com_get-drexa_drexa_internal_sharing.Share": {
"description": "Share link information including expiration and timestamps",
"type": "object",
"properties": {
"createdAt": {
"description": "When the share was created (ISO 8601)",
"type": "string",
"example": "2024-12-13T15:04:05Z"
},
"expiresAt": {
"description": "When the share expires, null if it never expires (ISO 8601)",
"type": "string",
"example": "2025-01-15T00:00:00Z"
},
"id": {
"description": "Unique share identifier (public ID)",
"type": "string",
"example": "kRp2XYTq9A55"
},
"updatedAt": {
"description": "When the share was last updated (ISO 8601)",
"type": "string",
"example": "2024-12-13T16:30:00Z"
}
}
},
"github_com_get-drexa_drexa_internal_user.User": { "github_com_get-drexa_drexa_internal_user.User": {
"description": "User account information", "description": "User account information",
"type": "object", "type": "object",
@@ -1506,6 +1888,11 @@
"type": "string", "type": "string",
"example": "My Documents" "example": "My Documents"
}, },
"parentId": {
"description": "ParentID is the public ID of the directory this directory is in",
"type": "string",
"example": "kRp2XYTq9A55"
},
"path": { "path": {
"description": "Full path from root (included when ?include=path)", "description": "Full path from root (included when ?include=path)",
"type": "array", "type": "array",
@@ -1554,6 +1941,11 @@
"type": "string", "type": "string",
"example": "document.pdf" "example": "document.pdf"
}, },
"parentId": {
"description": "ParentID is the public ID of the directory this file is in",
"type": "string",
"example": "kRp2XYTq9A55"
},
"size": { "size": {
"description": "File size in bytes", "description": "File size in bytes",
"type": "integer", "type": "integer",
@@ -1582,6 +1974,21 @@
} }
} }
}, },
"internal_catalog.listDirectoryResponse": {
"description": "Response to a request to list the contents of a directory",
"type": "object",
"properties": {
"items": {
"description": "Items is the list of items in the directory, limited to the limit specified in the request",
"type": "array",
"items": {}
},
"nextCursor": {
"description": "NextCursor is the cursor to use to get the next page of results",
"type": "string"
}
}
},
"internal_catalog.moveItemError": { "internal_catalog.moveItemError": {
"description": "Error details for a failed item move", "description": "Error details for a failed item move",
"type": "object", "type": "object",
@@ -1676,6 +2083,54 @@
} }
} }
}, },
"internal_sharing.Share": {
"description": "Share link information including expiration and timestamps",
"type": "object",
"properties": {
"createdAt": {
"description": "When the share was created (ISO 8601)",
"type": "string",
"example": "2024-12-13T15:04:05Z"
},
"expiresAt": {
"description": "When the share expires, null if it never expires (ISO 8601)",
"type": "string",
"example": "2025-01-15T00:00:00Z"
},
"id": {
"description": "Unique share identifier (public ID)",
"type": "string",
"example": "kRp2XYTq9A55"
},
"updatedAt": {
"description": "When the share was last updated (ISO 8601)",
"type": "string",
"example": "2024-12-13T16:30:00Z"
}
}
},
"internal_sharing.createShareRequest": {
"description": "Request to create a new share link for files or directories",
"type": "object",
"properties": {
"expiresAt": {
"description": "Optional expiration time for the share (ISO 8601)",
"type": "string",
"example": "2025-01-15T00:00:00Z"
},
"items": {
"description": "Array of file/directory IDs to share",
"type": "array",
"items": {
"type": "string"
},
"example": [
"mElnUNCm8F22",
"kRp2XYTq9A55"
]
}
}
},
"internal_upload.Status": { "internal_upload.Status": {
"description": "Upload status enumeration", "description": "Upload status enumeration",
"type": "string", "type": "string",
@@ -1784,4 +2239,4 @@
"in": "header" "in": "header"
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import (
"github.com/get-drexa/drexa/internal/httperr" "github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/reqctx" "github.com/get-drexa/drexa/internal/reqctx"
"github.com/get-drexa/drexa/internal/user" "github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/uptrace/bun" "github.com/uptrace/bun"
@@ -15,6 +16,7 @@ import (
type HTTPHandler struct { type HTTPHandler struct {
accountService *Service accountService *Service
authService *auth.Service authService *auth.Service
vfs *virtualfs.VirtualFS
db *bun.DB db *bun.DB
authMiddleware fiber.Handler authMiddleware fiber.Handler
cookieConfig auth.CookieConfig cookieConfig auth.CookieConfig
@@ -46,17 +48,11 @@ type registerAccountResponse struct {
RefreshToken string `json:"refreshToken,omitempty" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"` RefreshToken string `json:"refreshToken,omitempty" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"`
} }
const currentAccountKey = "currentAccount" func NewHTTPHandler(accountService *Service, authService *auth.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler, cookieConfig auth.CookieConfig) *HTTPHandler {
func CurrentAccount(c *fiber.Ctx) *Account {
return c.Locals(currentAccountKey).(*Account)
}
func NewHTTPHandler(accountService *Service, authService *auth.Service, db *bun.DB, authMiddleware fiber.Handler, cookieConfig auth.CookieConfig) *HTTPHandler {
return &HTTPHandler{accountService: accountService, authService: authService, db: db, authMiddleware: authMiddleware, cookieConfig: cookieConfig} return &HTTPHandler{accountService: accountService, authService: authService, db: db, authMiddleware: authMiddleware, cookieConfig: cookieConfig}
} }
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) fiber.Router { func (h *HTTPHandler) RegisterRoutes(api fiber.Router) *ScopedRouter {
api.Get("/accounts", h.authMiddleware, h.listAccounts) api.Get("/accounts", h.authMiddleware, h.listAccounts)
api.Post("/accounts", h.registerAccount) api.Post("/accounts", h.registerAccount)
@@ -66,7 +62,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) fiber.Router {
account.Get("/", h.getAccount) account.Get("/", h.getAccount)
return account return &ScopedRouter{virtualfs.ScopedRouter{account}}
} }
func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error { func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error {
@@ -85,7 +81,22 @@ func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error {
return httperr.Internal(err) return httperr.Internal(err)
} }
c.Locals(currentAccountKey, account) root, err := h.vfs.FindRootDirectory(c.Context(), h.db, account.ID)
if err != nil {
return httperr.Internal(err)
}
scope := &virtualfs.Scope{
AccountID: account.ID,
RootNodeID: root.ID,
AllowedOps: virtualfs.AllAllowedOps,
AllowedNodes: nil,
ActorKind: virtualfs.ScopeActorAccount,
ActorID: u.ID,
}
reqctx.SetVFSAccessScope(c, scope)
reqctx.SetCurrentAccount(c, account)
return c.Next() return c.Next()
} }
@@ -120,8 +131,8 @@ func (h *HTTPHandler) listAccounts(c *fiber.Ctx) error {
// @Failure 404 {string} string "Account not found" // @Failure 404 {string} string "Account not found"
// @Router /accounts/{accountID} [get] // @Router /accounts/{accountID} [get]
func (h *HTTPHandler) getAccount(c *fiber.Ctx) error { func (h *HTTPHandler) getAccount(c *fiber.Ctx) error {
account := CurrentAccount(c) account, ok := reqctx.CurrentAccount(c).(*Account)
if account == nil { if !ok || account == nil {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
return c.JSON(account) return c.JSON(account)

View File

@@ -0,0 +1,21 @@
package account
import "github.com/get-drexa/drexa/internal/virtualfs"
// ScopedRouter is a router with auth + account middleware applied.
// Routes registered on this router have access to:
// - The authenticated user via reqctx.AuthenticatedUser()
// - The current account via reqctx.CurrentAccount()
// - The VFS scope via reqctx.VFSAccessScope()
//
// This embeds virtualfs.ScopedRouter, so it can be passed to functions
// that only require VFS scope by calling VFSRouter().
type ScopedRouter struct {
virtualfs.ScopedRouter
}
// VFSRouter returns the embedded virtualfs.ScopedRouter for use with
// functions that only require VFS scope access.
func (r *ScopedRouter) VFSRouter() *virtualfs.ScopedRouter {
return &r.ScopedRouter
}

View File

@@ -59,7 +59,7 @@ func (s *Service) Register(ctx context.Context, db bun.IDB, opts RegisterOptions
return nil, nil, err return nil, nil, err
} }
_, err = s.vfs.CreateDirectory(ctx, db, acc.ID, uuid.Nil, virtualfs.RootDirectoryName) _, err = s.vfs.CreateRootDirectory(ctx, db, acc.ID)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@@ -9,8 +9,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/httperr" "github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/sharing"
"github.com/get-drexa/drexa/internal/virtualfs" "github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -99,8 +99,8 @@ type decodedListChildrenCursor struct {
} }
func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error { func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scope, ok := scopeFromCtx(c)
if account == nil { if !ok {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
@@ -108,13 +108,16 @@ func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
var node *virtualfs.Node var node *virtualfs.Node
if directoryID == "root" { if directoryID == "root" {
n, err := h.vfs.FindRootDirectory(c.Context(), h.db, account.ID) n, err := h.vfs.FindNode(c.Context(), h.db, scope.RootNodeID.String(), scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
node = n node = n
} else { } else {
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, directoryID) n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, directoryID, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
@@ -153,8 +156,8 @@ func includeParam(c *fiber.Ctx) []string {
// @Failure 409 {object} map[string]string "Directory already exists" // @Failure 409 {object} map[string]string "Directory already exists"
// @Router /accounts/{accountID}/directories [post] // @Router /accounts/{accountID}/directories [post]
func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error { func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scope, ok := scopeFromCtx(c)
if account == nil { if !ok {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
@@ -169,7 +172,7 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
} }
defer tx.Rollback() defer tx.Rollback()
parent, err := h.vfs.FindNodeByPublicID(c.Context(), tx, account.ID, req.ParentID) parent, err := h.vfs.FindNodeByPublicID(c.Context(), tx, req.ParentID, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent not found"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent not found"})
@@ -181,11 +184,14 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent is not a directory"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent is not a directory"})
} }
node, err := h.vfs.CreateDirectory(c.Context(), tx, account.ID, parent.ID, req.Name) node, err := h.vfs.CreateDirectory(c.Context(), tx, parent.ID, req.Name, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeConflict) { if errors.Is(err, virtualfs.ErrNodeConflict) {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Directory already exists"}) return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Directory already exists"})
} }
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -200,8 +206,11 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
include := includeParam(c) include := includeParam(c)
if slices.Contains(include, "path") { if slices.Contains(include, "path") {
p, err := h.vfs.RealPath(c.Context(), tx, node) p, err := h.vfs.RealPath(c.Context(), tx, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
i.Path = p i.Path = p
@@ -230,6 +239,10 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
// @Router /accounts/{accountID}/directories/{directoryID} [get] // @Router /accounts/{accountID}/directories/{directoryID} [get]
func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error { func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c) node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
i := DirectoryInfo{ i := DirectoryInfo{
Kind: DirItemKindDirectory, Kind: DirItemKindDirectory,
@@ -242,8 +255,11 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
include := includeParam(c) include := includeParam(c)
if slices.Contains(include, "path") { if slices.Contains(include, "path") {
p, err := h.vfs.RealPath(c.Context(), h.db, node) p, err := h.vfs.RealPath(c.Context(), h.db, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
i.Path = p i.Path = p
@@ -254,7 +270,7 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
// listDirectory returns directory contents // listDirectory returns directory contents
// @Summary List directory contents // @Summary List directory contents
// @Description Get all files and subdirectories within a directory with optional pagination, sorting, and filtering // @Description Get all files and subdirectories within a directory with optional pagination and sorting
// @Tags directories // @Tags directories
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -271,6 +287,10 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
// @Router /accounts/{accountID}/directories/{directoryID}/content [get] // @Router /accounts/{accountID}/directories/{directoryID}/content [get]
func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error { func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c) node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
opts := virtualfs.ListChildrenOptions{} opts := virtualfs.ListChildrenOptions{}
@@ -312,7 +332,7 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
} }
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, node.AccountID, dc.nodeID) n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, dc.nodeID, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
@@ -327,11 +347,14 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
} }
} }
children, cursor, err := h.vfs.ListChildren(c.Context(), h.db, node, opts) children, cursor, err := h.vfs.ListChildren(c.Context(), h.db, node, opts, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -392,6 +415,10 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
// @Router /accounts/{accountID}/directories/{directoryID} [patch] // @Router /accounts/{accountID}/directories/{directoryID} [patch]
func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error { func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c) node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
patch := new(patchDirectoryRequest) patch := new(patchDirectoryRequest)
if err := c.BodyParser(patch); err != nil { if err := c.BodyParser(patch); err != nil {
@@ -405,11 +432,14 @@ func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
defer tx.Rollback() defer tx.Rollback()
if patch.Name != "" { if patch.Name != "" {
err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name) err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
} }
@@ -437,12 +467,17 @@ func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
// @Param accountID path string true "Account ID" format(uuid) // @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Directory ID" // @Param directoryID path string true "Directory ID"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false) // @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 200 {object} DirectoryInfo "Trashed directory info (when trash=true)"
// @Success 204 {string} string "Directory deleted" // @Success 204 {string} string "Directory deleted"
// @Failure 401 {string} string "Not authenticated" // @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found" // @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID} [delete] // @Router /accounts/{accountID}/directories/{directoryID} [delete]
func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error { func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c) node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
tx, err := h.db.BeginTx(c.Context(), nil) tx, err := h.db.BeginTx(c.Context(), nil)
if err != nil { if err != nil {
@@ -452,8 +487,11 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
shouldTrash := c.Query("trash") == "true" shouldTrash := c.Query("trash") == "true"
if shouldTrash { if shouldTrash {
_, err := h.vfs.SoftDeleteNode(c.Context(), tx, node) _, err := h.vfs.SoftDeleteNode(c.Context(), tx, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -464,8 +502,11 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
return c.JSON(directoryInfoFromNode(node)) return c.JSON(directoryInfoFromNode(node))
} else { } else {
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -486,13 +527,14 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
// @Param accountID path string true "Account ID" format(uuid) // @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 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) // @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 200 {array} DirectoryInfo "Trashed directories (when trash=true)"
// @Success 204 {string} string "Directories deleted" // @Success 204 {string} string "Directories deleted"
// @Failure 400 {object} map[string]string "All items must be directories" // @Failure 400 {object} map[string]string "All items must be directories"
// @Failure 401 {string} string "Not authenticated" // @Failure 401 {string} string "Not authenticated"
// @Router /accounts/{accountID}/directories [delete] // @Router /accounts/{accountID}/directories [delete]
func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error { func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scope, ok := scopeFromCtx(c)
if account == nil { if !ok {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
@@ -514,7 +556,7 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
} }
defer tx.Rollback() defer tx.Rollback()
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids) nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, ids, scope)
if err != nil { if err != nil {
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -530,8 +572,11 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
} }
if shouldTrash { if shouldTrash {
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes) deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -545,11 +590,14 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
res = append(res, directoryInfoFromNode(node)) res = append(res, directoryInfoFromNode(node))
} }
return c.JSON(deleted) return c.JSON(res)
} else { } else {
for _, node := range nodes { for _, node := range nodes {
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
} }
@@ -580,8 +628,8 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
// @Failure 404 {object} map[string]string "One or more items not found" // @Failure 404 {object} map[string]string "One or more items not found"
// @Router /accounts/{accountID}/directories/{directoryID}/content [post] // @Router /accounts/{accountID}/directories/{directoryID}/content [post]
func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error { func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
acc := account.CurrentAccount(c) scope, ok := scopeFromCtx(c)
if acc == nil { if !ok {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
@@ -602,7 +650,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
} }
defer tx.Rollback() defer tx.Rollback()
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, acc.ID, req.Items) nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, req.Items, scope)
if err != nil { if err != nil {
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -611,7 +659,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
} }
// Move all nodes to the target directory // Move all nodes to the target directory
result, err := h.vfs.MoveNodesInSameDirectory(c.Context(), tx, nodes, targetDir.ID) result, err := h.vfs.MoveNodesInSameDirectory(c.Context(), tx, nodes, targetDir.ID, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrUnsupportedOperation) { if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "All items must be in the same directory"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "All items must be in the same directory"})
@@ -619,6 +667,9 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
if errors.Is(err, virtualfs.ErrNodeConflict) { if errors.Is(err, virtualfs.ErrNodeConflict) {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Name conflict in target directory"}) return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Name conflict in target directory"})
} }
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -712,3 +763,28 @@ func decodeListChildrenCursor(s string) (*decodedListChildrenCursor, error) {
return c, nil return c, nil
} }
// listDirectoryShares returns all shares that include this directory
// @Summary List directory shares
// @Description Get all share links that include this directory
// @Tags directories
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Success 200 {array} sharing.Share "Array of shares"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/directories/{directoryID}/shares [get]
func (h *HTTPHandler) listDirectoryShares(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c)
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.AccountID, sharing.ListSharesOptions{
Items: []*virtualfs.Node{node},
})
if err != nil {
return httperr.Internal(err)
}
return c.JSON(shares)
}

View File

@@ -5,8 +5,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/httperr" "github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/sharing"
"github.com/get-drexa/drexa/internal/virtualfs" "github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -39,13 +39,13 @@ func mustCurrentFileNode(c *fiber.Ctx) *virtualfs.Node {
} }
func (h *HTTPHandler) currentFileMiddleware(c *fiber.Ctx) error { func (h *HTTPHandler) currentFileMiddleware(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scope, ok := scopeFromCtx(c)
if account == nil { if !ok {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
fileID := c.Params("fileID") fileID := c.Params("fileID")
node, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, fileID) node, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, fileID, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
@@ -100,9 +100,16 @@ func (h *HTTPHandler) fetchFile(c *fiber.Ctx) error {
// @Router /accounts/{accountID}/files/{fileID}/content [get] // @Router /accounts/{accountID}/files/{fileID}/content [get]
func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error { func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error {
node := mustCurrentFileNode(c) node := mustCurrentFileNode(c)
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
content, err := h.vfs.ReadFile(c.Context(), h.db, node) content, err := h.vfs.ReadFile(c.Context(), h.db, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
if errors.Is(err, virtualfs.ErrUnsupportedOperation) { if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
@@ -143,6 +150,10 @@ func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error {
// @Router /accounts/{accountID}/files/{fileID} [patch] // @Router /accounts/{accountID}/files/{fileID} [patch]
func (h *HTTPHandler) patchFile(c *fiber.Ctx) error { func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
node := mustCurrentFileNode(c) node := mustCurrentFileNode(c)
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
patch := new(patchFileRequest) patch := new(patchFileRequest)
if err := c.BodyParser(patch); err != nil { if err := c.BodyParser(patch); err != nil {
@@ -156,11 +167,14 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
defer tx.Rollback() defer tx.Rollback()
if patch.Name != "" { if patch.Name != "" {
err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name) err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
} }
@@ -197,6 +211,10 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
// @Router /accounts/{accountID}/files/{fileID} [delete] // @Router /accounts/{accountID}/files/{fileID} [delete]
func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error { func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
node := mustCurrentFileNode(c) node := mustCurrentFileNode(c)
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
tx, err := h.db.BeginTx(c.Context(), nil) tx, err := h.db.BeginTx(c.Context(), nil)
if err != nil { if err != nil {
@@ -206,11 +224,14 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
shouldTrash := c.Query("trash") == "true" shouldTrash := c.Query("trash") == "true"
if shouldTrash { if shouldTrash {
deleted, err := h.vfs.SoftDeleteNode(c.Context(), tx, node) deleted, err := h.vfs.SoftDeleteNode(c.Context(), tx, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -221,8 +242,11 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
return c.JSON(fileInfoFromNode(deleted)) return c.JSON(fileInfoFromNode(deleted))
} else { } else {
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -243,13 +267,14 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
// @Param accountID path string true "Account ID" format(uuid) // @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 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) // @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 200 {array} FileInfo "Trashed files (when trash=true)"
// @Success 204 {string} string "Files deleted" // @Success 204 {string} string "Files deleted"
// @Failure 400 {object} map[string]string "All items must be files" // @Failure 400 {object} map[string]string "All items must be files"
// @Failure 401 {string} string "Not authenticated" // @Failure 401 {string} string "Not authenticated"
// @Router /accounts/{accountID}/files [delete] // @Router /accounts/{accountID}/files [delete]
func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error { func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scope, ok := scopeFromCtx(c)
if account == nil { if !ok {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
@@ -271,7 +296,7 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
} }
defer tx.Rollback() defer tx.Rollback()
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids) nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, ids, scope)
if err != nil { if err != nil {
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -281,8 +306,11 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
} }
if shouldTrash { if shouldTrash {
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes) deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -298,11 +326,14 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
return c.JSON(res) return c.JSON(res)
} else { } else {
err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes) err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrUnsupportedOperation) { if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be files", err) return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be files", err)
} }
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err) return httperr.Internal(err)
} }
@@ -315,3 +346,28 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
} }
} }
// listFileShares returns all shares that include this file
// @Summary List file shares
// @Description Get all share links that include this file
// @Tags files
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param fileID path string true "File ID"
// @Success 200 {array} sharing.Share "Array of shares"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "File not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/files/{fileID}/shares [get]
func (h *HTTPHandler) listFileShares(c *fiber.Ctx) error {
node := mustCurrentFileNode(c)
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.AccountID, sharing.ListSharesOptions{
Items: []*virtualfs.Node{node},
})
if err != nil {
return httperr.Internal(err)
}
return c.JSON(shares)
}

View File

@@ -1,14 +1,17 @@
package catalog package catalog
import ( import (
"github.com/get-drexa/drexa/internal/reqctx"
"github.com/get-drexa/drexa/internal/sharing"
"github.com/get-drexa/drexa/internal/virtualfs" "github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
type HTTPHandler struct { type HTTPHandler struct {
vfs *virtualfs.VirtualFS sharingService *sharing.Service
db *bun.DB vfs *virtualfs.VirtualFS
db *bun.DB
} }
// patchFileRequest represents a file update request // patchFileRequest represents a file update request
@@ -25,11 +28,11 @@ type patchDirectoryRequest struct {
Name string `json:"name" example:"My Documents"` Name string `json:"name" example:"My Documents"`
} }
func NewHTTPHandler(vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler { func NewHTTPHandler(sharingService *sharing.Service, vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler {
return &HTTPHandler{vfs: vfs, db: db} return &HTTPHandler{sharingService: sharingService, vfs: vfs, db: db}
} }
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) { func (h *HTTPHandler) RegisterRoutes(api *virtualfs.ScopedRouter) {
api.Delete("/files", h.deleteFiles) api.Delete("/files", h.deleteFiles)
fg := api.Group("/files/:fileID") fg := api.Group("/files/:fileID")
@@ -38,6 +41,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
fg.Get("/content", h.downloadFile) fg.Get("/content", h.downloadFile)
fg.Patch("/", h.patchFile) fg.Patch("/", h.patchFile)
fg.Delete("/", h.deleteFile) fg.Delete("/", h.deleteFile)
fg.Get("/shares", h.listFileShares)
api.Post("/directories", h.createDirectory) api.Post("/directories", h.createDirectory)
api.Delete("/directories", h.deleteDirectories) api.Delete("/directories", h.deleteDirectories)
@@ -49,6 +53,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
dg.Get("/content", h.listDirectory) dg.Get("/content", h.listDirectory)
dg.Patch("/", h.patchDirectory) dg.Patch("/", h.patchDirectory)
dg.Delete("/", h.deleteDirectory) dg.Delete("/", h.deleteDirectory)
dg.Get("/shares", h.listDirectoryShares)
} }
func fileInfoFromNode(node *virtualfs.Node) FileInfo { func fileInfoFromNode(node *virtualfs.Node) FileInfo {
@@ -82,3 +87,15 @@ func toDirectoryItem(node *virtualfs.Node) any {
return fileInfoFromNode(node) return fileInfoFromNode(node)
} }
} }
func scopeFromCtx(c *fiber.Ctx) (*virtualfs.Scope, bool) {
scopeAny := reqctx.VFSAccessScope(c)
if scopeAny == nil {
return nil, false
}
scope, ok := scopeAny.(*virtualfs.Scope)
if !ok || scope == nil {
return nil, false
}
return scope, true
}

View File

@@ -79,17 +79,51 @@ CREATE INDEX idx_vfs_nodes_pending ON vfs_nodes(created_at) WHERE status = 'pend
CREATE TABLE IF NOT EXISTS node_shares ( CREATE TABLE IF NOT EXISTS node_shares (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
node_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE, -- the account that owns the share
share_token TEXT NOT NULL UNIQUE, account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
public_id TEXT NOT NULL UNIQUE, -- opaque ID for external API (no timestamp leak)
-- parent directory of the items in this share
shared_directory_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE,
revoked_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX idx_node_shares_share_token ON node_shares(share_token); CREATE INDEX idx_node_shares_public_id ON node_shares(public_id);
CREATE INDEX idx_node_shares_node_id ON node_shares(node_id); CREATE INDEX idx_node_shares_shared_directory_id ON node_shares(shared_directory_id);
CREATE INDEX idx_node_shares_expires_at ON node_shares(expires_at) WHERE expires_at IS NOT NULL; CREATE INDEX idx_node_shares_expires_at ON node_shares(expires_at) WHERE expires_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS share_permissions (
id UUID PRIMARY KEY,
share_id UUID NOT NULL REFERENCES node_shares(id) ON DELETE CASCADE,
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE, -- NULL = anyone with the link
can_read BOOLEAN NOT NULL DEFAULT TRUE,
can_write BOOLEAN NOT NULL DEFAULT FALSE,
can_delete BOOLEAN NOT NULL DEFAULT FALSE,
can_upload BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (share_id, account_id)
);
CREATE INDEX idx_share_permissions_share_id ON share_permissions(share_id);
CREATE INDEX idx_share_permissions_account_id ON share_permissions(account_id) WHERE account_id IS NOT NULL;
CREATE INDEX idx_share_permissions_expires_at ON share_permissions(expires_at) WHERE expires_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS share_items (
id UUID PRIMARY KEY,
share_id UUID NOT NULL REFERENCES node_shares(id) ON DELETE CASCADE,
node_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (share_id, node_id)
);
CREATE INDEX idx_share_items_share_id ON share_items(share_id);
CREATE INDEX idx_share_items_node_id ON share_items(node_id);
-- ============================================================================ -- ============================================================================
-- Triggers for updated_at timestamps -- Triggers for updated_at timestamps
-- ============================================================================ -- ============================================================================
@@ -111,8 +145,14 @@ CREATE TRIGGER update_vfs_nodes_updated_at BEFORE UPDATE ON vfs_nodes
CREATE TRIGGER update_node_shares_updated_at BEFORE UPDATE ON node_shares CREATE TRIGGER update_node_shares_updated_at BEFORE UPDATE ON node_shares
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_share_permissions_updated_at BEFORE UPDATE ON share_permissions
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_share_items_updated_at BEFORE UPDATE ON share_items
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_grants_updated_at BEFORE UPDATE ON grants CREATE TRIGGER update_grants_updated_at BEFORE UPDATE ON grants
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -11,6 +11,7 @@ import (
"github.com/get-drexa/drexa/internal/catalog" "github.com/get-drexa/drexa/internal/catalog"
"github.com/get-drexa/drexa/internal/database" "github.com/get-drexa/drexa/internal/database"
"github.com/get-drexa/drexa/internal/httperr" "github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/sharing"
"github.com/get-drexa/drexa/internal/upload" "github.com/get-drexa/drexa/internal/upload"
"github.com/get-drexa/drexa/internal/user" "github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs" "github.com/get-drexa/drexa/internal/virtualfs"
@@ -93,6 +94,11 @@ func NewServer(c Config) (*Server, error) {
return nil, fmt.Errorf("failed to create virtual file system: %w", err) return nil, fmt.Errorf("failed to create virtual file system: %w", err)
} }
sharingService, err := sharing.NewService(vfs)
if err != nil {
return nil, fmt.Errorf("failed to create sharing service: %w", err)
}
userService := user.NewService() userService := user.NewService()
authService := auth.NewService(userService, auth.TokenConfig{ authService := auth.NewService(userService, auth.TokenConfig{
Issuer: c.JWT.Issuer, Issuer: c.JWT.Issuer,
@@ -113,9 +119,16 @@ func NewServer(c Config) (*Server, error) {
auth.NewHTTPHandler(authService, db, cookieConfig).RegisterRoutes(api) auth.NewHTTPHandler(authService, db, cookieConfig).RegisterRoutes(api)
user.NewHTTPHandler(userService, db, authMiddleware).RegisterRoutes(api) user.NewHTTPHandler(userService, db, authMiddleware).RegisterRoutes(api)
accountRouter := account.NewHTTPHandler(accountService, authService, db, authMiddleware, cookieConfig).RegisterRoutes(api) accountRouter := account.NewHTTPHandler(accountService, authService, vfs, db, authMiddleware, cookieConfig).RegisterRoutes(api)
upload.NewHTTPHandler(uploadService, db).RegisterRoutes(accountRouter) upload.NewHTTPHandler(uploadService, db).RegisterRoutes(accountRouter.VFSRouter())
catalog.NewHTTPHandler(vfs, db).RegisterRoutes(accountRouter)
shareHTTP := sharing.NewHTTPHandler(sharingService, accountService, vfs, db, authMiddleware)
shareRoutes := shareHTTP.RegisterShareConsumeRoutes(api)
shareHTTP.RegisterShareManagementRoutes(accountRouter)
catalogHTTP := catalog.NewHTTPHandler(sharingService, vfs, db)
catalogHTTP.RegisterRoutes(accountRouter.VFSRouter())
catalogHTTP.RegisterRoutes(shareRoutes)
s := &Server{ s := &Server{
config: c, config: c,

View File

@@ -7,6 +7,8 @@ import (
) )
const authenticatedUserKey = "authenticatedUser" const authenticatedUserKey = "authenticatedUser"
const vfsAccessScope = "vfsAccessScope"
const currentAccountKey = "currentAccount"
var ErrUnauthenticatedRequest = errors.New("unauthenticated request") var ErrUnauthenticatedRequest = errors.New("unauthenticated request")
@@ -18,6 +20,26 @@ func AuthenticatedUser(c *fiber.Ctx) any {
} }
// SetAuthenticatedUser sets the authenticated user in the fiber context. // SetAuthenticatedUser sets the authenticated user in the fiber context.
func SetAuthenticatedUser(c *fiber.Ctx, user interface{}) { func SetAuthenticatedUser(c *fiber.Ctx, user any) {
c.Locals(authenticatedUserKey, user) c.Locals(authenticatedUserKey, user)
} }
// SetCurrentAccount sets the current account in the fiber context.
func SetCurrentAccount(c *fiber.Ctx, account any) {
c.Locals(currentAccountKey, account)
}
// SetVFSAccessScope sets the VFS access scope in the fiber context.
func SetVFSAccessScope(c *fiber.Ctx, scope any) {
c.Locals(vfsAccessScope, scope)
}
// CurrentAccount returns the current account from the given fiber context.
func CurrentAccount(c *fiber.Ctx) any {
return c.Locals(currentAccountKey)
}
// VFSAccessScope returns the VFS access scope from the given fiber context.
func VFSAccessScope(c *fiber.Ctx) any {
return c.Locals(vfsAccessScope)
}

View File

@@ -0,0 +1,265 @@
package sharing
import (
"errors"
"time"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/reqctx"
"github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type HTTPHandler struct {
sharingService *Service
accountService *account.Service
vfs *virtualfs.VirtualFS
db *bun.DB
authMiddleware fiber.Handler
}
// createShareRequest represents a request to create a share link
// @Description Request to create a new share link for files or directories
type createShareRequest struct {
// Array of file/directory IDs to share
Items []string `json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55"`
// Optional expiration time for the share (ISO 8601)
ExpiresAt *time.Time `json:"expiresAt" example:"2025-01-15T00:00:00Z"`
}
func NewHTTPHandler(sharingService *Service, accountService *account.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler) *HTTPHandler {
return &HTTPHandler{
sharingService: sharingService,
accountService: accountService,
vfs: vfs,
db: db,
authMiddleware: authMiddleware,
}
}
func (h *HTTPHandler) RegisterShareConsumeRoutes(r fiber.Router) *virtualfs.ScopedRouter {
g := r.Group("/shares/:shareID", h.shareMiddleware)
return &virtualfs.ScopedRouter{Router: g}
}
func (h *HTTPHandler) RegisterShareManagementRoutes(api *account.ScopedRouter) {
g := api.Group("/shares")
g.Get("/:shareID", h.getShare)
g.Post("/", h.createShare)
g.Delete("/:shareID", h.deleteShare)
}
func (h *HTTPHandler) shareMiddleware(c *fiber.Ctx) error {
shareID := c.Params("shareID")
share, err := h.sharingService.FindShareByPublicID(c.Context(), h.db, shareID)
if err != nil {
if errors.Is(err, ErrShareNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
// a share can be public or shared to specific accounts
// if latter, the accountId query param is expected and the route should be authenticated
// then the correct account is found using the authenticated user and the accountId query param
// finally, the account scope is resolved for the share
// otherwise, consumerAccount will be nil to attempt to resolve a public scope for the share
var consumerAccount *account.Account
qAccountID := c.Query("accountId")
if qAccountID != "" {
consumerAccountID, err := uuid.Parse(qAccountID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid account ID",
})
}
u := reqctx.AuthenticatedUser(c).(*user.User)
if u != nil {
consumerAccount, err = h.accountService.AccountByID(c.Context(), h.db, u.ID, consumerAccountID)
if err != nil {
if errors.Is(err, account.ErrAccountNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
consumerAccount, err = h.accountService.AccountByID(c.Context(), h.db, u.ID, consumerAccountID)
if err != nil {
if errors.Is(err, account.ErrAccountNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
}
}
scope, err := h.sharingService.ResolveScopeForShare(c.Context(), h.db, consumerAccount, share)
if err != nil {
if errors.Is(err, ErrShareNotFound) || errors.Is(err, ErrShareExpired) || errors.Is(err, ErrShareRevoked) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
if scope == nil {
// no scope can be resolved for the share
// the user is not authorized to access the share
// return 404 to hide the existence of the share
return c.SendStatus(fiber.StatusNotFound)
}
reqctx.SetVFSAccessScope(c, scope)
return c.Next()
}
// getShare retrieves a share by its ID
// @Summary Get share
// @Description Retrieve share link details by ID
// @Tags shares
// @Accept json
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param shareID path string true "Share ID"
// @Success 200 {object} Share "Share details"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Share not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/shares/{shareID} [get]
func (h *HTTPHandler) getShare(c *fiber.Ctx) error {
shareID := c.Params("shareID")
share, err := h.sharingService.FindShareByPublicID(c.Context(), h.db, shareID)
if err != nil {
return httperr.Internal(err)
}
return c.JSON(share)
}
// createShare creates a new share link for files or directories
// @Summary Create share
// @Description Create a new share link for one or more files or directories
// @Tags shares
// @Accept json
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param request body createShareRequest true "Share details"
// @Success 200 {object} Share "Created share"
// @Failure 400 {object} map[string]string "Invalid request or no items provided"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {object} map[string]string "One or more items not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/shares [post]
func (h *HTTPHandler) createShare(c *fiber.Ctx) error {
scope, ok := scopeFromCtx(c)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
acc, ok := reqctx.CurrentAccount(c).(*account.Account)
if !ok || acc == nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
var req createShareRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid request",
})
}
if len(req.Items) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "expects at least one item to share",
})
}
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, req.Items, scope)
if err != nil {
return httperr.Internal(err)
}
if len(nodes) != len(req.Items) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "One or more items not found"})
}
opts := CreateShareOptions{
Items: nodes,
}
if req.ExpiresAt != nil {
opts.ExpiresAt = *req.ExpiresAt
}
share, err := h.sharingService.CreateShare(c.Context(), tx, acc.ID, opts)
if err != nil {
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
return c.JSON(share)
}
// deleteShare deletes a share link
// @Summary Delete share
// @Description Delete a share link, revoking access for all users
// @Tags shares
// @Param accountID path string true "Account ID" format(uuid)
// @Param shareID path string true "Share ID"
// @Success 204 {string} string "Share deleted"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Share not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/shares/{shareID} [delete]
func (h *HTTPHandler) deleteShare(c *fiber.Ctx) error {
shareID := c.Params("shareID")
tx, err := h.db.BeginTx(c.Context(), nil)
if err != nil {
return httperr.Internal(err)
}
defer tx.Rollback()
err = h.sharingService.DeleteShareByPublicID(c.Context(), tx, shareID)
if err != nil {
if errors.Is(err, ErrShareNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func scopeFromCtx(c *fiber.Ctx) (*virtualfs.Scope, bool) {
scopeAny := reqctx.VFSAccessScope(c)
if scopeAny == nil {
return nil, false
}
scope, ok := scopeAny.(*virtualfs.Scope)
if !ok || scope == nil {
return nil, false
}
return scope, true
}

View File

@@ -0,0 +1,319 @@
package sharing
import (
"context"
"crypto/rand"
"database/sql"
"encoding/binary"
"errors"
"time"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/google/uuid"
"github.com/sqids/sqids-go"
"github.com/uptrace/bun"
)
type Service struct {
sqid *sqids.Sqids
vfs *virtualfs.VirtualFS
}
type CreateShareOptions struct {
Items []*virtualfs.Node
ExpiresAt time.Time
}
// ListSharesOptions is the options for ListShares.
type ListSharesOptions struct {
// Items stores the list of nodes to filter the shares by. If nil, all shares for the account will be returned.
Items []*virtualfs.Node
// IncludesExpired includes shares that have expired in the results.
IncludesExpired bool
}
var (
ErrShareNotFound = errors.New("share not found")
ErrShareExpired = errors.New("share expired")
ErrShareRevoked = errors.New("share revoked")
ErrShareNoItems = errors.New("share has no items")
ErrNoPermissions = errors.New("no permissions found")
ErrNotSameParent = errors.New("items to be shared must be in the same parent directory")
)
func NewService(vfs *virtualfs.VirtualFS) (*Service, error) {
sqid, err := sqids.New()
if err != nil {
return nil, err
}
return &Service{vfs: vfs, sqid: sqid}, nil
}
// CreateShare creates a share record for a parent directory and its allowed items.
func (s *Service) CreateShare(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateShareOptions) (*Share, error) {
id, err := generateInternalID()
if err != nil {
return nil, err
}
pid, err := s.generatePublicID()
if err != nil {
return nil, err
}
var parentID *uuid.UUID
for _, item := range opts.Items {
if parentID == nil {
parentID = &item.ID
} else if item.ParentID != *parentID {
return nil, ErrNotSameParent
}
}
now := time.Now()
sh := &Share{
ID: id,
AccountID: accountID,
PublicID: pid,
SharedDirectoryID: opts.Items[0].ID,
CreatedAt: now,
UpdatedAt: now,
}
if !opts.ExpiresAt.IsZero() {
sh.ExpiresAt = &opts.ExpiresAt
}
shareItems := make([]ShareItem, len(opts.Items))
for i, item := range opts.Items {
si := &shareItems[i]
si.ID, err = generateInternalID()
if err != nil {
return nil, err
}
si.ShareID = sh.ID
si.NodeID = item.ID
si.CreatedAt = now
si.UpdatedAt = now
}
permID, err := generateInternalID()
if err != nil {
return nil, err
}
perm := SharePermission{
ID: permID,
ShareID: sh.ID,
// public access
AccountID: nil,
CanRead: true,
CanWrite: false,
CanDelete: false,
CanUpload: false,
CreatedAt: now,
UpdatedAt: now,
}
_, err = db.NewInsert().Model(sh).Returning("*").Exec(ctx)
if err != nil {
return nil, err
}
_, err = db.NewInsert().Model(&shareItems).On("CONFLICT DO NOTHING").Returning("*").Exec(ctx)
if err != nil {
return nil, err
}
_, err = db.NewInsert().Model(&perm).Returning("*").Exec(ctx)
if err != nil {
return nil, err
}
return sh, nil
}
// FindShareByPublicID loads a share by its public token.
func (s *Service) FindShareByPublicID(ctx context.Context, db bun.IDB, publicID string) (*Share, error) {
sh := new(Share)
err := db.NewSelect().Model(sh).
Where("public_id = ?", publicID).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrShareNotFound
}
return nil, err
}
return sh, nil
}
func (s *Service) ListShares(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts ListSharesOptions) ([]Share, error) {
var shares []Share
q := db.NewSelect().Model(&shares).
Where("account_id = ?", accountID)
if !opts.IncludesExpired {
q = q.Where("expires_at IS NULL OR expires_at > NOW()")
}
if len(opts.Items) > 0 {
ids := make([]uuid.UUID, 0, len(opts.Items))
for _, item := range opts.Items {
ids = append(ids, item.ID)
}
q = q.Where(
"EXISTS (SELECT 1 FROM share_items si WHERE si.share_id = share.id AND si.node_id IN (?))",
bun.In(ids),
)
}
err := q.Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return make([]Share, 0), nil
}
return nil, err
}
if len(shares) == 0 {
return make([]Share, 0), nil
}
return shares, nil
}
// ResolveScopeForShare validates a share and derives a VFS scope from its permissions and items.
// share_items must contain the allowed nodes (including a directory node to share its subtree).
func (s *Service) ResolveScopeForShare(ctx context.Context, db bun.IDB, consumerAccount *account.Account, share *Share) (*virtualfs.Scope, error) {
now := time.Now()
if share.ExpiresAt != nil && share.ExpiresAt.Before(now) {
return nil, ErrShareExpired
}
if share.RevokedAt != nil {
return nil, ErrShareRevoked
}
var permList []*SharePermission
err := db.NewSelect().Model(&permList).
Where("share_id = ?", share.ID).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrShareNotFound
}
return nil, err
}
if len(permList) == 0 {
return nil, ErrNoPermissions
}
var publicPerm *SharePermission
var accPerm *SharePermission
if len(permList) == 1 {
accPerm = permList[0]
if accPerm.AccountID == nil {
publicPerm = accPerm
}
} else {
for _, p := range permList {
if p.AccountID != nil && consumerAccount != nil && *p.AccountID == consumerAccount.ID {
accPerm = p
} else if p.AccountID == nil {
publicPerm = p
}
}
}
if accPerm == nil && publicPerm == nil {
return nil, ErrNoPermissions
}
var perm *SharePermission
if accPerm != nil {
perm = accPerm
} else {
perm = publicPerm
}
if perm.ExpiresAt != nil && perm.ExpiresAt.Before(now) {
return nil, ErrShareExpired
}
scope := &virtualfs.Scope{
AccountID: share.AccountID,
RootNodeID: share.SharedDirectoryID,
}
if perm.AccountID == nil {
// the share is public
scope.ActorKind = virtualfs.ScopeActorShare
scope.ActorID = share.ID
} else {
scope.ActorKind = virtualfs.ScopeActorAccount
scope.ActorID = *perm.AccountID
}
scope.AllowedOps = map[virtualfs.Operation]bool{
virtualfs.OperationRead: perm.CanRead,
virtualfs.OperationWrite: perm.CanWrite,
virtualfs.OperationDelete: perm.CanDelete,
virtualfs.OperationUpload: perm.CanUpload,
}
var items []*ShareItem
err = db.NewSelect().Model(&items).
Where("share_id = ?", share.ID).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrShareNoItems
}
return nil, err
}
if len(items) == 0 {
return nil, ErrShareNoItems
}
scope.AllowedNodes = make(map[uuid.UUID]struct{})
for _, item := range items {
scope.AllowedNodes[item.NodeID] = struct{}{}
}
return scope, nil
}
// DeleteShare deletes the given share completely. It will no longer be accessible after.
func (s *Service) DeleteShareByPublicID(ctx context.Context, db bun.IDB, publicID string) error {
r, err := db.NewDelete().Model(&Share{}).Where("public_id = ?", publicID).Exec(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrShareNotFound
}
return err
}
c, err := r.RowsAffected()
if err != nil {
return err
}
if c == 0 {
return ErrShareNotFound
}
return nil
}
func (s *Service) generatePublicID() (string, error) {
var b [8]byte
_, err := rand.Read(b[:])
if err != nil {
return "", err
}
n := binary.BigEndian.Uint64(b[:])
return s.sqid.Encode([]uint64{n})
}

View File

@@ -0,0 +1,56 @@
package sharing
import (
"time"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
// Share represents a share link for files or directories
// @Description Share link information including expiration and timestamps
type Share struct {
bun.BaseModel `bun:"node_shares"`
ID uuid.UUID `bun:",pk,type:uuid" json:"-"`
AccountID uuid.UUID `bun:"account_id,notnull,type:uuid" json:"-"`
// Unique share identifier (public ID)
PublicID string `bun:"public_id,notnull" json:"id" example:"kRp2XYTq9A55"`
SharedDirectoryID uuid.UUID `bun:"shared_directory_id,notnull,type:uuid" json:"-"`
RevokedAt *time.Time `bun:"revoked_at" json:"-"`
// When the share expires, null if it never expires (ISO 8601)
ExpiresAt *time.Time `bun:"expires_at" json:"expiresAt" example:"2025-01-15T00:00:00Z"`
// When the share was created (ISO 8601)
CreatedAt time.Time `bun:"created_at,notnull" json:"createdAt" example:"2024-12-13T15:04:05Z"`
// When the share was last updated (ISO 8601)
UpdatedAt time.Time `bun:"updated_at,notnull" json:"updatedAt" example:"2024-12-13T16:30:00Z"`
}
type SharePermission struct {
bun.BaseModel `bun:"share_permissions"`
ID uuid.UUID `bun:",pk,type:uuid"`
ShareID uuid.UUID `bun:"share_id,notnull,type:uuid"`
AccountID *uuid.UUID `bun:"account_id,type:uuid"`
CanRead bool `bun:"can_read,notnull"`
CanWrite bool `bun:"can_write,notnull"`
CanDelete bool `bun:"can_delete,notnull"`
CanUpload bool `bun:"can_upload,notnull"`
ExpiresAt *time.Time `bun:"expires_at"`
CreatedAt time.Time `bun:"created_at,notnull"`
UpdatedAt time.Time `bun:"updated_at,notnull"`
}
type ShareItem struct {
bun.BaseModel `bun:"share_items"`
ID uuid.UUID `bun:",pk,type:uuid"`
ShareID uuid.UUID `bun:"share_id,notnull,type:uuid"`
NodeID uuid.UUID `bun:"node_id,notnull,type:uuid"`
CreatedAt time.Time `bun:"created_at,notnull"`
UpdatedAt time.Time `bun:"updated_at,notnull"`
}
func generateInternalID() (uuid.UUID, error) {
return uuid.NewV7()
}

View File

@@ -7,4 +7,5 @@ var (
ErrParentNotDirectory = errors.New("parent is not a directory") ErrParentNotDirectory = errors.New("parent is not a directory")
ErrConflict = errors.New("node conflict") ErrConflict = errors.New("node conflict")
ErrContentNotUploaded = errors.New("content has not been uploaded") ErrContentNotUploaded = errors.New("content has not been uploaded")
ErrUnauthorized = errors.New("unauthorized")
) )

View File

@@ -4,8 +4,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/httperr" "github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/reqctx"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -35,7 +36,7 @@ func NewHTTPHandler(s *Service, db *bun.DB) *HTTPHandler {
return &HTTPHandler{service: s, db: db} return &HTTPHandler{service: s, db: db}
} }
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) { func (h *HTTPHandler) RegisterRoutes(api *virtualfs.ScopedRouter) {
upload := api.Group("/uploads") upload := api.Group("/uploads")
upload.Post("/", h.Create) upload.Post("/", h.Create)
@@ -59,8 +60,9 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
// @Failure 409 {object} map[string]string "File with this name already exists" // @Failure 409 {object} map[string]string "File with this name already exists"
// @Router /accounts/{accountID}/uploads [post] // @Router /accounts/{accountID}/uploads [post]
func (h *HTTPHandler) Create(c *fiber.Ctx) error { func (h *HTTPHandler) Create(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scopeAny := reqctx.VFSAccessScope(c)
if account == nil { scope, ok := scopeAny.(*virtualfs.Scope)
if !ok || scope == nil {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
@@ -69,11 +71,17 @@ func (h *HTTPHandler) Create(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
} }
upload, err := h.service.CreateUpload(c.Context(), h.db, account.ID, CreateUploadOptions{ upload, err := h.service.CreateUpload(c.Context(), h.db, CreateUploadOptions{
ParentID: req.ParentID, ParentID: req.ParentID,
Name: req.Name, Name: req.Name,
}) }, scope)
if err != nil { if err != nil {
if errors.Is(err, ErrUnauthorized) {
return c.SendStatus(fiber.StatusUnauthorized)
}
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
@@ -107,16 +115,23 @@ func (h *HTTPHandler) Create(c *fiber.Ctx) error {
// @Failure 404 {string} string "Upload session not found" // @Failure 404 {string} string "Upload session not found"
// @Router /accounts/{accountID}/uploads/{uploadID}/content [put] // @Router /accounts/{accountID}/uploads/{uploadID}/content [put]
func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error { func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scopeAny := reqctx.VFSAccessScope(c)
if account == nil { scope, ok := scopeAny.(*virtualfs.Scope)
if !ok || scope == nil {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
uploadID := c.Params("uploadID") uploadID := c.Params("uploadID")
err := h.service.ReceiveUpload(c.Context(), h.db, account.ID, uploadID, c.Context().RequestBodyStream()) err := h.service.ReceiveUpload(c.Context(), h.db, uploadID, c.Context().RequestBodyStream(), scope)
defer c.Context().Request.CloseBodyStream() defer c.Context().Request.CloseBodyStream()
if err != nil { if err != nil {
if errors.Is(err, ErrUnauthorized) {
return c.SendStatus(fiber.StatusUnauthorized)
}
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }
@@ -142,8 +157,9 @@ func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error {
// @Failure 404 {string} string "Upload session not found" // @Failure 404 {string} string "Upload session not found"
// @Router /accounts/{accountID}/uploads/{uploadID} [patch] // @Router /accounts/{accountID}/uploads/{uploadID} [patch]
func (h *HTTPHandler) Update(c *fiber.Ctx) error { func (h *HTTPHandler) Update(c *fiber.Ctx) error {
account := account.CurrentAccount(c) scopeAny := reqctx.VFSAccessScope(c)
if account == nil { scope, ok := scopeAny.(*virtualfs.Scope)
if !ok || scope == nil {
return c.SendStatus(fiber.StatusUnauthorized) return c.SendStatus(fiber.StatusUnauthorized)
} }
@@ -153,8 +169,14 @@ func (h *HTTPHandler) Update(c *fiber.Ctx) error {
} }
if req.Status == StatusCompleted { if req.Status == StatusCompleted {
upload, err := h.service.CompleteUpload(c.Context(), h.db, account.ID, c.Params("uploadID")) upload, err := h.service.CompleteUpload(c.Context(), h.db, c.Params("uploadID"), scope)
if err != nil { if err != nil {
if errors.Is(err, ErrUnauthorized) {
return c.SendStatus(fiber.StatusUnauthorized)
}
if errors.Is(err, virtualfs.ErrAccessDenied) {
return c.SendStatus(fiber.StatusNotFound)
}
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
return c.SendStatus(fiber.StatusNotFound) return c.SendStatus(fiber.StatusNotFound)
} }

View File

@@ -10,7 +10,6 @@ import (
"github.com/get-drexa/drexa/internal/blob" "github.com/get-drexa/drexa/internal/blob"
"github.com/get-drexa/drexa/internal/virtualfs" "github.com/get-drexa/drexa/internal/virtualfs"
"github.com/google/uuid"
"github.com/uptrace/bun" "github.com/uptrace/bun"
) )
@@ -35,8 +34,12 @@ type CreateUploadOptions struct {
Name string Name string
} }
func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateUploadOptions) (*Upload, error) { func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, opts CreateUploadOptions, scope *virtualfs.Scope) (*Upload, error) {
parentNode, err := s.vfs.FindNodeByPublicID(ctx, db, accountID, opts.ParentID) if scope == nil {
return nil, ErrUnauthorized
}
parentNode, err := s.vfs.FindNodeByPublicID(ctx, db, opts.ParentID, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeNotFound) { if errors.Is(err, virtualfs.ErrNodeNotFound) {
return nil, ErrNotFound return nil, ErrNotFound
@@ -48,10 +51,10 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U
return nil, ErrParentNotDirectory return nil, ErrParentNotDirectory
} }
node, err := s.vfs.CreateFile(ctx, db, accountID, virtualfs.CreateFileOptions{ node, err := s.vfs.CreateFile(ctx, db, virtualfs.CreateFileOptions{
ParentID: parentNode.ID, ParentID: parentNode.ID,
Name: opts.Name, Name: opts.Name,
}) }, scope)
if err != nil { if err != nil {
if errors.Is(err, virtualfs.ErrNodeConflict) { if errors.Is(err, virtualfs.ErrNodeConflict) {
return nil, ErrConflict return nil, ErrConflict
@@ -65,7 +68,7 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U
Duration: 1 * time.Hour, Duration: 1 * time.Hour,
}) })
if err != nil { if err != nil {
_ = s.vfs.PermanentlyDeleteNode(ctx, db, node) _ = s.vfs.PermanentlyDeleteNode(ctx, db, node, scope)
return nil, err return nil, err
} }
} else { } else {
@@ -84,7 +87,7 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U
return upload, nil return upload, nil
} }
func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid.UUID, uploadID string, reader io.Reader) error { func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, uploadID string, reader io.Reader, scope *virtualfs.Scope) error {
fmt.Printf("reader: %v\n", reader) fmt.Printf("reader: %v\n", reader)
n, ok := s.pendingUploads.Load(uploadID) n, ok := s.pendingUploads.Load(uploadID)
if !ok { if !ok {
@@ -96,11 +99,15 @@ func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid.
return ErrNotFound return ErrNotFound
} }
if upload.TargetNode.AccountID != accountID { if scope == nil {
return ErrUnauthorized
}
if upload.TargetNode.AccountID != scope.AccountID {
return ErrNotFound return ErrNotFound
} }
err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromReader(reader)) err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromReader(reader), scope)
if err != nil { if err != nil {
return err return err
} }
@@ -110,7 +117,7 @@ func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid.
return nil return nil
} }
func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid.UUID, uploadID string) (*Upload, error) { func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, uploadID string, scope *virtualfs.Scope) (*Upload, error) {
n, ok := s.pendingUploads.Load(uploadID) n, ok := s.pendingUploads.Load(uploadID)
if !ok { if !ok {
return nil, ErrNotFound return nil, ErrNotFound
@@ -121,7 +128,11 @@ func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid
return nil, ErrNotFound return nil, ErrNotFound
} }
if upload.TargetNode.AccountID != accountID { if scope == nil {
return nil, ErrUnauthorized
}
if upload.TargetNode.AccountID != scope.AccountID {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -129,7 +140,7 @@ func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid
return upload, nil return upload, nil
} }
err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromBlobKey(upload.TargetNode.BlobKey)) err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromBlobKey(upload.TargetNode.BlobKey), scope)
if err != nil { if err != nil {
if errors.Is(err, blob.ErrNotFound) { if errors.Is(err, blob.ErrNotFound) {
return nil, ErrContentNotUploaded return nil, ErrContentNotUploaded

View File

@@ -6,6 +6,7 @@ var (
ErrNodeNotFound = errors.New("node not found") ErrNodeNotFound = errors.New("node not found")
ErrNodeConflict = errors.New("node conflict") ErrNodeConflict = errors.New("node conflict")
ErrUnsupportedOperation = errors.New("unsupported operation") ErrUnsupportedOperation = errors.New("unsupported operation")
ErrAccessDenied = errors.New("access denied")
ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field") ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field")
ErrCursorMismatchedDirection = errors.New("cursor mismatched direction") ErrCursorMismatchedDirection = errors.New("cursor mismatched direction")
) )

View File

@@ -0,0 +1,59 @@
package virtualfs
import "github.com/google/uuid"
// Scope defines the bounded view of the virtual filesystem that a caller is allowed to operate on.
// It is populated by higher layers (account/share middleware) and enforced by VFS methods.
type Scope struct {
// AccountID is the owner of the storage. It stays constant even when a share actor accesses it.
AccountID uuid.UUID
// RootNodeID is the top-most node the caller is allowed to traverse; all accesses must stay under it.
// It must be set for all VFS access operations.
RootNodeID uuid.UUID
// AllowedOps lists which operations this scope may perform (read, write, delete, etc).
AllowedOps map[Operation]bool
// AllowedNodes is an optional allowlist of node IDs permitted within RootNodeID.
// When nil or empty, the full subtree is allowed; when set, only allowlisted nodes (and descendants) are allowed.
AllowedNodes map[uuid.UUID]struct{}
// ActorKind identifies who performs the action (user vs share link) for auditing.
ActorKind ScopeActorKind
// ActorID is the identifier of the actor (user ID, share ID, etc).
ActorID uuid.UUID
}
var AllAllowedOps = map[Operation]bool{
OperationRead: true,
OperationWrite: true,
OperationDelete: true,
OperationUpload: true,
OperationShare: true,
}
// Allows reports whether the scope permits the given operation.
func (s *Scope) Allows(op Operation) bool {
return s != nil && s.AllowedOps[op]
}
// Operation enumerates supported actions.
type Operation string
const (
OperationRead Operation = "read"
OperationWrite Operation = "write"
OperationDelete Operation = "delete"
OperationUpload Operation = "upload"
OperationShare Operation = "share"
)
// ScopeActorKind labels the type of actor behind the request.
type ScopeActorKind string
const (
ScopeActorAccount ScopeActorKind = "account"
ScopeActorShare ScopeActorKind = "share"
)

View File

@@ -0,0 +1,201 @@
package virtualfs
import (
"context"
"database/sql"
"errors"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
// nodeWithinRootQuery checks if a node is within a root by traversing ancestors.
const nodeWithinRootQuery = `WITH RECURSIVE path AS (
SELECT id, parent_id
FROM vfs_nodes
WHERE id = ? AND deleted_at IS NULL
UNION ALL
SELECT n.id, n.parent_id
FROM vfs_nodes n
JOIN path p ON n.id = p.parent_id
WHERE n.deleted_at IS NULL
)
SELECT 1 FROM path WHERE id = ? LIMIT 1;`
// nodeWithinRootOrAllowedQuery checks if a node is:
// 1. Within the root node (has root in its ancestry)
// 2. Either directly in the allowed list, OR has an ancestor in the allowed list
// This combines two checks into a single recursive query.
const nodeWithinRootOrAllowedQuery = `WITH RECURSIVE path AS (
SELECT id, parent_id
FROM vfs_nodes
WHERE id = ? AND deleted_at IS NULL
UNION ALL
SELECT n.id, n.parent_id
FROM vfs_nodes n
JOIN path p ON n.id = p.parent_id
WHERE n.deleted_at IS NULL
)
SELECT
EXISTS(SELECT 1 FROM path WHERE id = ?) AS within_root,
EXISTS(SELECT 1 FROM path WHERE id IN (?)) AS within_allowed;`
// filterNodesWithAllowlistQuery filters multiple nodes in a single query.
// It returns node IDs that are within the root AND have an ancestor (or self) in the allowed list.
const filterNodesWithAllowlistQuery = `WITH RECURSIVE node_paths AS (
-- Start from all input nodes
SELECT id AS original_id, id, parent_id
FROM vfs_nodes
WHERE id IN (?) AND deleted_at IS NULL
UNION ALL
-- Traverse up to ancestors
SELECT np.original_id, n.id, n.parent_id
FROM vfs_nodes n
JOIN node_paths np ON n.id = np.parent_id
WHERE n.deleted_at IS NULL
)
SELECT DISTINCT original_id
FROM node_paths
WHERE original_id IN (
-- Nodes that have root in their path
SELECT original_id FROM node_paths WHERE id = ?
)
AND original_id IN (
-- Nodes that have an allowed node in their path
SELECT original_id FROM node_paths WHERE id IN (?)
);`
// filterNodesWithinRootQuery filters multiple nodes that are within the root (no allowlist).
const filterNodesWithinRootQuery = `WITH RECURSIVE node_paths AS (
SELECT id AS original_id, id, parent_id
FROM vfs_nodes
WHERE id IN (?) AND deleted_at IS NULL
UNION ALL
SELECT np.original_id, n.id, n.parent_id
FROM vfs_nodes n
JOIN node_paths np ON n.id = np.parent_id
WHERE n.deleted_at IS NULL
)
SELECT DISTINCT original_id
FROM node_paths
WHERE id = ?;`
func isScopeSet(scope *Scope) bool {
return scope != nil && scope.AccountID != uuid.Nil && scope.RootNodeID != uuid.Nil
}
// canAccessNode checks if the scope permits the operation and allows access to the node.
func (vfs *VirtualFS) canAccessNode(ctx context.Context, db bun.IDB, scope *Scope, op Operation, nodeID uuid.UUID) (bool, error) {
if !scope.Allows(op) {
return false, nil
}
return vfs.isNodeAllowedByScope(ctx, db, scope, nodeID)
}
func (vfs *VirtualFS) isNodeWithinRoot(ctx context.Context, db bun.IDB, nodeID, rootID uuid.UUID) (bool, error) {
var exists int
err := db.NewRaw(nodeWithinRootQuery, nodeID, rootID).Scan(ctx, &exists)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return true, nil
}
// isNodeAllowedByScope checks if a node is accessible under the given scope.
// It verifies the node is within the root and (if an allowlist exists) within an allowed subtree.
// Optimized to use a single query when an allowlist is present.
func (vfs *VirtualFS) isNodeAllowedByScope(ctx context.Context, db bun.IDB, scope *Scope, nodeID uuid.UUID) (bool, error) {
if !isScopeSet(scope) {
return false, nil
}
// Fast path: no allowlist means full subtree access under root
if len(scope.AllowedNodes) == 0 {
return vfs.isNodeWithinRoot(ctx, db, nodeID, scope.RootNodeID)
}
// Quick check: if nodeID is directly in the allowlist, just check root containment
if _, ok := scope.AllowedNodes[nodeID]; ok {
return vfs.isNodeWithinRoot(ctx, db, nodeID, scope.RootNodeID)
}
// Single query: build ancestry path and check both root and allowlist membership
allowedIDs := scopeAllowedNodesList(scope)
var result struct {
WithinRoot bool `bun:"within_root"`
WithinAllowed bool `bun:"within_allowed"`
}
err := db.NewRaw(nodeWithinRootOrAllowedQuery, nodeID, scope.RootNodeID, bun.In(allowedIDs)).Scan(ctx, &result)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return result.WithinRoot && result.WithinAllowed, nil
}
// filterNodesByScope filters a list of nodes to only those accessible under the scope.
// Optimized to use a single batch query instead of per-node queries.
func (vfs *VirtualFS) filterNodesByScope(ctx context.Context, db bun.IDB, scope *Scope, nodes []*Node) ([]*Node, error) {
if len(nodes) == 0 {
return nodes, nil
}
if !isScopeSet(scope) {
return nil, nil
}
nodeIDs := make([]uuid.UUID, len(nodes))
nodeMap := make(map[uuid.UUID]*Node, len(nodes))
for i, node := range nodes {
nodeIDs[i] = node.ID
nodeMap[node.ID] = node
}
var allowedIDs []uuid.UUID
var err error
if len(scope.AllowedNodes) == 0 {
// No allowlist: just check nodes are within root
err = db.NewRaw(filterNodesWithinRootQuery, bun.In(nodeIDs), scope.RootNodeID).Scan(ctx, &allowedIDs)
} else {
// With allowlist: check nodes are within root AND within an allowed subtree
allowedNodesList := scopeAllowedNodesList(scope)
err = db.NewRaw(filterNodesWithAllowlistQuery, bun.In(nodeIDs), scope.RootNodeID, bun.In(allowedNodesList)).Scan(ctx, &allowedIDs)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return make([]*Node, 0), nil
}
return nil, err
}
allowed := make([]*Node, 0, len(allowedIDs))
for _, id := range allowedIDs {
if node, ok := nodeMap[id]; ok {
allowed = append(allowed, node)
}
}
return allowed, nil
}
// scopeAllowedNodesList converts the AllowedNodes map to a slice for use in queries.
func scopeAllowedNodesList(scope *Scope) []uuid.UUID {
if scope == nil || len(scope.AllowedNodes) == 0 {
return nil
}
ids := make([]uuid.UUID, 0, len(scope.AllowedNodes))
for id := range scope.AllowedNodes {
ids = append(ids, id)
}
return ids
}

View File

@@ -0,0 +1,13 @@
package virtualfs
import "github.com/gofiber/fiber/v2"
// ScopedRouter is a router that guarantees reqctx.VFSAccessScope(c)
// returns a valid *Scope for all registered routes.
//
// This is the base type for routers that provide VFS access scope.
// More specific router types (like account.ScopedRouter) may embed this
// to provide additional guarantees.
type ScopedRouter struct {
fiber.Router
}

View File

@@ -44,12 +44,6 @@ type VirtualFS struct {
sqid *sqids.Sqids sqid *sqids.Sqids
} }
type CreateNodeOptions struct {
ParentID uuid.UUID
Kind NodeKind
Name string
}
type CreateFileOptions struct { type CreateFileOptions struct {
ParentID uuid.UUID ParentID uuid.UUID
Name string Name string
@@ -93,10 +87,14 @@ func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error)
}, nil }, nil
} }
func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, accountID, fileID string) (*Node, error) { func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, fileID string, scope *Scope) (*Node, error) {
if !isScopeSet(scope) {
return nil, ErrAccessDenied
}
var node Node var node Node
err := db.NewSelect().Model(&node). err := db.NewSelect().Model(&node).
Where("account_id = ?", accountID). Where("account_id = ?", scope.AccountID).
Where("id = ?", fileID). Where("id = ?", fileID).
Where("status = ?", NodeStatusReady). Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL"). Where("deleted_at IS NULL").
@@ -107,11 +105,17 @@ func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, accountID, fileI
} }
return nil, err return nil, err
} }
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
return nil, err
} else if !ok {
return nil, ErrAccessDenied
}
return &node, nil return &node, nil
} }
func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accountID uuid.UUID, publicID string) (*Node, error) { func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, publicID string, scope *Scope) (*Node, error) {
nodes, err := vfs.FindNodesByPublicID(ctx, db, accountID, []string{publicID}) nodes, err := vfs.FindNodesByPublicID(ctx, db, []string{publicID}, scope)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -121,14 +125,17 @@ func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accoun
return nodes[0], nil return nodes[0], nil
} }
func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accountID uuid.UUID, publicIDs []string) ([]*Node, error) { func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, publicIDs []string, scope *Scope) ([]*Node, error) {
if len(publicIDs) == 0 { if len(publicIDs) == 0 {
return nil, nil return nil, nil
} }
if !isScopeSet(scope) {
return nil, ErrAccessDenied
}
var nodes []*Node var nodes []*Node
err := db.NewSelect().Model(&nodes). err := db.NewSelect().Model(&nodes).
Where("account_id = ?", accountID). Where("account_id = ?", scope.AccountID).
Where("public_id IN (?)", bun.In(publicIDs)). Where("public_id IN (?)", bun.In(publicIDs)).
Where("status = ?", NodeStatusReady). Where("status = ?", NodeStatusReady).
Scan(ctx) Scan(ctx)
@@ -136,7 +143,7 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accou
return nil, err return nil, err
} }
return nodes, nil return vfs.filterNodesByScope(ctx, db, scope, nodes)
} }
func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) { func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
@@ -159,11 +166,49 @@ func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, account
return root, nil return root, nil
} }
// CreateRootDirectory creates the account root directory node.
func (vfs *VirtualFS) CreateRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
pid, err := vfs.generatePublicID()
if err != nil {
return nil, err
}
id, err := newNodeID()
if err != nil {
return nil, err
}
node := &Node{
ID: id,
PublicID: pid,
AccountID: accountID,
ParentID: uuid.Nil,
Kind: NodeKindDirectory,
Status: NodeStatusReady,
Name: RootDirectoryName,
}
_, err = db.NewInsert().Model(node).Exec(ctx)
if err != nil {
if database.IsUniqueViolation(err) {
return nil, ErrNodeConflict
}
return nil, err
}
return node, nil
}
// ListChildren returns the children of a directory node with optional sorting and cursor-based pagination. // ListChildren returns the children of a directory node with optional sorting and cursor-based pagination.
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions) ([]*Node, *ListChildrenCursor, error) { func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions, scope *Scope) ([]*Node, *ListChildrenCursor, error) {
if !node.IsAccessible() { if !node.IsAccessible() {
return nil, nil, ErrNodeNotFound return nil, nil, ErrNodeNotFound
} }
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
return nil, nil, err
} else if !ok {
return nil, nil, ErrAccessDenied
}
var nodes []*Node var nodes []*Node
q := db.NewSelect().Model(&nodes). q := db.NewSelect().Model(&nodes).
@@ -172,16 +217,9 @@ func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node,
Where("status = ?", NodeStatusReady). Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL") Where("deleted_at IS NULL")
var dir string dir := "ASC"
if opts.OrderBy != "" { if opts.OrderDirection == ListChildrenDirectionDesc {
switch opts.OrderDirection { dir = "DESC"
default:
dir = "ASC"
case ListChildrenDirectionAsc:
dir = "ASC"
case ListChildrenDirectionDesc:
dir = "DESC"
}
} }
// Apply sorting with directories always first, then ID as tiebreaker. // Apply sorting with directories always first, then ID as tiebreaker.
@@ -267,7 +305,16 @@ func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node,
return nodes, c, nil return nodes, c, nil
} }
func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateFileOptions) (*Node, error) { func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, opts CreateFileOptions, scope *Scope) (*Node, error) {
if !isScopeSet(scope) {
return nil, ErrAccessDenied
}
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationUpload, opts.ParentID); err != nil {
return nil, err
} else if !ok {
return nil, ErrAccessDenied
}
pid, err := vfs.generatePublicID() pid, err := vfs.generatePublicID()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -281,7 +328,7 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
node := Node{ node := Node{
ID: id, ID: id,
PublicID: pid, PublicID: pid,
AccountID: accountID, AccountID: scope.AccountID,
ParentID: opts.ParentID, ParentID: opts.ParentID,
Kind: NodeKindFile, Kind: NodeKindFile,
Status: NodeStatusPending, Status: NodeStatusPending,
@@ -306,7 +353,12 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
return &node, nil return &node, nil
} }
func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, content FileContent) error { func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, content FileContent, scope *Scope) error {
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationUpload, node.ID); err != nil {
return err
} else if !ok {
return ErrAccessDenied
}
if content.Reader == nil && content.BlobKey.IsNil() { if content.Reader == nil && content.BlobKey.IsNil() {
return blob.ErrInvalidFileContent return blob.ErrInvalidFileContent
} }
@@ -376,18 +428,22 @@ func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, con
setCols = append(setCols, "mime_type", "blob_key", "size", "status") setCols = append(setCols, "mime_type", "blob_key", "size", "status")
} }
_, err := db.NewUpdate().Model(node). if _, err := db.NewUpdate().Model(node).
Column(setCols...). Column(setCols...).
WherePK(). WherePK().
Exec(ctx) Exec(ctx); err != nil {
if err != nil {
return err return err
} }
return nil return nil
} }
func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node) (FileContent, error) { func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (FileContent, error) {
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
return EmptyFileContent(), err
} else if !ok {
return EmptyFileContent(), ErrAccessDenied
}
if node.Kind != NodeKindFile { if node.Kind != NodeKindFile {
return EmptyFileContent(), ErrUnsupportedOperation return EmptyFileContent(), ErrUnsupportedOperation
} }
@@ -415,7 +471,16 @@ func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node) (Fil
return FileContentFromReaderWithSize(reader, node.Size), nil 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) { func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, parentID uuid.UUID, name string, scope *Scope) (*Node, error) {
if !isScopeSet(scope) {
return nil, ErrAccessDenied
}
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, parentID); err != nil {
return nil, err
} else if !ok {
return nil, ErrAccessDenied
}
pid, err := vfs.generatePublicID() pid, err := vfs.generatePublicID()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -429,7 +494,7 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID
node := &Node{ node := &Node{
ID: id, ID: id,
PublicID: pid, PublicID: pid,
AccountID: accountID, AccountID: scope.AccountID,
ParentID: parentID, ParentID: parentID,
Kind: NodeKindDirectory, Kind: NodeKindDirectory,
Status: NodeStatusReady, Status: NodeStatusReady,
@@ -447,8 +512,8 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID
return node, nil return node, nil
} }
func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node) (*Node, error) { func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (*Node, error) {
deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node}) deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node}, scope)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -458,21 +523,32 @@ func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node
return deleted[0], nil return deleted[0], nil
} }
func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node) ([]*Node, error) { func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node, scope *Scope) ([]*Node, error) {
if !scope.Allows(OperationDelete) {
return nil, ErrAccessDenied
}
if len(nodes) == 0 { if len(nodes) == 0 {
return nil, nil return nil, nil
} }
deletableNodes := make([]*Node, 0, len(nodes)) allowed, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
nodeIDs := make([]uuid.UUID, 0, len(nodes)) if err != nil {
for _, node := range nodes { return nil, err
}
if len(allowed) == 0 {
return nil, ErrNodeNotFound
}
deletableNodes := make([]*Node, 0, len(allowed))
nodeIDs := make([]uuid.UUID, 0, len(allowed))
for _, node := range allowed {
if node.IsAccessible() { if node.IsAccessible() {
nodeIDs = append(nodeIDs, node.ID) nodeIDs = append(nodeIDs, node.ID)
deletableNodes = append(deletableNodes, node) deletableNodes = append(deletableNodes, node)
} }
} }
_, err := db.NewUpdate().Model(&deletableNodes). _, err = db.NewUpdate().Model(&deletableNodes).
Where("id IN (?)", bun.In(nodeIDs)). Where("id IN (?)", bun.In(nodeIDs)).
Where("status = ?", NodeStatusReady). Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL"). Where("deleted_at IS NULL").
@@ -486,7 +562,12 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*
return deletableNodes, nil return deletableNodes, nil
} }
func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) error { func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) error {
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationDelete, node.ID); err != nil {
return err
} else if !ok {
return ErrAccessDenied
}
if node.Status != NodeStatusReady { if node.Status != NodeStatusReady {
return ErrNodeNotFound return ErrNodeNotFound
} }
@@ -507,10 +588,15 @@ func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) e
return nil return nil
} }
func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, name string) error { func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, name string, scope *Scope) error {
if !node.IsAccessible() { if !node.IsAccessible() {
return ErrNodeNotFound return ErrNodeNotFound
} }
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, node.ID); err != nil {
return err
} else if !ok {
return ErrAccessDenied
}
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node) oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil { if err != nil {
@@ -557,11 +643,25 @@ func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, na
return nil return nil
} }
func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, parentID uuid.UUID) error { func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, parentID uuid.UUID, scope *Scope) error {
if !node.IsAccessible() { if !node.IsAccessible() {
return ErrNodeNotFound return ErrNodeNotFound
} }
// check if the node is accessible
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, node.ID); err != nil {
return err
} else if !ok {
return ErrAccessDenied
}
// check if the new parent is accessible
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, parentID); err != nil {
return err
} else if !ok {
return ErrAccessDenied
}
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node) oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
if err != nil { if err != nil {
return err return err
@@ -612,15 +712,28 @@ 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 // 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) // optimization where parent paths are computed only once (2 recursive queries total)
// rather than computing full paths for each node individually (N queries). // 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) (*MoveFilesResult, error) { func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID, scope *Scope) (*MoveFilesResult, error) {
if len(nodes) == 0 { if len(nodes) == 0 {
return nil, nil return nil, nil
} }
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, newParentID); err != nil {
return nil, err
} else if !ok {
return nil, ErrAccessDenied
}
allowedNodes, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
if err != nil {
return nil, err
}
if len(allowedNodes) == 0 {
return nil, ErrNodeNotFound
}
// Validate all nodes are accessible // Validate all nodes are accessible
nodeIDs := make([]uuid.UUID, len(nodes)) nodeIDs := make([]uuid.UUID, len(allowedNodes))
nodeNames := make([]string, len(nodes)) nodeNames := make([]string, len(allowedNodes))
for i, node := range nodes { for i, node := range allowedNodes {
if !node.IsAccessible() { if !node.IsAccessible() {
return nil, ErrNodeNotFound return nil, ErrNodeNotFound
} }
@@ -629,8 +742,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
} }
var conflicts []*Node var conflicts []*Node
err := db.NewSelect().Model(&conflicts). err = db.NewSelect().Model(&conflicts).
Where("account_id = ?", nodes[0].AccountID). Where("account_id = ?", allowedNodes[0].AccountID).
Where("parent_id = ?", newParentID). Where("parent_id = ?", newParentID).
Where("name IN (?)", bun.In(nodeNames)). Where("name IN (?)", bun.In(nodeNames)).
Scan(ctx) Scan(ctx)
@@ -643,8 +756,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
conflictID[c.ID] = struct{}{} conflictID[c.ID] = struct{}{}
} }
movableNodes := make([]*Node, 0, len(nodes)-len(conflicts)) movableNodes := make([]*Node, 0, len(allowedNodes)-len(conflicts))
for _, node := range nodes { for _, node := range allowedNodes {
if _, ok := conflictID[node.ID]; !ok { if _, ok := conflictID[node.ID]; !ok {
movableNodes = append(movableNodes, node) movableNodes = append(movableNodes, node)
} }
@@ -690,7 +803,7 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
} }
} }
for _, node := range nodes { for _, node := range allowedNodes {
node.ParentID = newParentID node.ParentID = newParentID
} }
@@ -701,26 +814,42 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
}, nil }, nil
} }
func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node) (Path, error) { func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (Path, error) {
if !node.IsAccessible() { if !node.IsAccessible() {
return nil, ErrNodeNotFound return nil, ErrNodeNotFound
} }
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
return nil, err
} else if !ok {
return nil, ErrAccessDenied
}
return buildNoteAbsolutePath(ctx, db, node) return buildNoteAbsolutePath(ctx, db, node)
} }
func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node) error { func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node, scope *Scope) error {
if !scope.Allows(OperationDelete) {
return ErrAccessDenied
}
if len(nodes) == 0 { if len(nodes) == 0 {
return nil return nil
} }
for _, n := range nodes { allowed, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
if err != nil {
return err
}
if len(allowed) == 0 {
return ErrNodeNotFound
}
for _, n := range allowed {
if n.Kind != NodeKindFile { if n.Kind != NodeKindFile {
return ErrUnsupportedOperation return ErrUnsupportedOperation
} }
} }
deletedIDs := make([]uuid.UUID, 0, len(nodes)) deletedIDs := make([]uuid.UUID, 0, len(allowed))
for _, n := range nodes { for _, n := range allowed {
err := vfs.permanentlyDeleteFileNode(ctx, db, n) err := vfs.permanentlyDeleteFileNode(ctx, db, n)
if err != nil { if err != nil {
if errors.Is(err, blob.ErrNotFound) { if errors.Is(err, blob.ErrNotFound) {
@@ -737,7 +866,7 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no
return nil return nil
} }
_, err := db.NewDelete().Model((*Node)(nil)). _, err = db.NewDelete().Model((*Node)(nil)).
Where("id IN (?)", bun.In(deletedIDs)). Where("id IN (?)", bun.In(deletedIDs)).
Exec(ctx) Exec(ctx)
if err != nil { if err != nil {
@@ -747,7 +876,13 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no
return nil return nil
} }
func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node) error { func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) error {
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationDelete, node.ID); err != nil {
return err
} else if !ok {
return ErrAccessDenied
}
switch node.Kind { switch node.Kind {
case NodeKindFile: case NodeKindFile:
return vfs.permanentlyDeleteFileNode(ctx, db, node) return vfs.permanentlyDeleteFileNode(ctx, db, node)