diff --git a/apps/backend/cmd/docs/openapi.json b/apps/backend/cmd/docs/openapi.json index 68e63f2..e68cc39 100644 --- a/apps/backend/cmd/docs/openapi.json +++ b/apps/backend/cmd/docs/openapi.json @@ -298,6 +298,19 @@ } ], "responses": { + "200": { + "description": "Trashed directories (when trash=true)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/internal_catalog.DirectoryInfo" + } + } + } + } + }, "204": { "description": "Directories deleted", "content": { @@ -453,6 +466,16 @@ } ], "responses": { + "200": { + "description": "Trashed directory info (when trash=true)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/internal_catalog.DirectoryInfo" + } + } + } + }, "204": { "description": "Directory deleted", "content": { @@ -582,7 +605,7 @@ "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": [ "directories" ], @@ -599,23 +622,76 @@ } }, { - "description": "Directory ID", + "description": "Directory ID (use 'root' for the root directory)", "name": "directoryID", "in": "path", "required": true, "schema": { "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": { "200": { - "description": "Array of FileInfo and DirectoryInfo objects", + "description": "Paginated list of FileInfo and DirectoryInfo objects", "content": { "application/json": { "schema": { - "type": "array", - "items": {} + "$ref": "#/components/schemas/internal_catalog.listDirectoryResponse" + } + } + } + }, + "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": { "delete": { "security": [ @@ -778,6 +924,19 @@ } ], "responses": { + "200": { + "description": "Trashed files (when trash=true)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/internal_catalog.FileInfo" + } + } + } + } + }, "204": { "description": "Files deleted", "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": { "post": { "security": [ @@ -1567,6 +2013,32 @@ } }, "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": { "description": "User account information", "type": "object", @@ -1802,6 +2274,11 @@ "type": "string", "example": "My Documents" }, + "parentId": { + "description": "ParentID is the public ID of the directory this directory is in", + "type": "string", + "example": "kRp2XYTq9A55" + }, "path": { "description": "Full path from root (included when ?include=path)", "type": "array", @@ -1850,6 +2327,11 @@ "type": "string", "example": "document.pdf" }, + "parentId": { + "description": "ParentID is the public ID of the directory this file is in", + "type": "string", + "example": "kRp2XYTq9A55" + }, "size": { "description": "File size in bytes", "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": { "description": "Error details for a failed item move", "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": { "description": "Upload status enumeration", "type": "string", @@ -2089,4 +2634,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/backend/docs/swagger.json b/apps/backend/docs/swagger.json index b4e1871..93b0fb5 100644 --- a/apps/backend/docs/swagger.json +++ b/apps/backend/docs/swagger.json @@ -256,6 +256,15 @@ } ], "responses": { + "200": { + "description": "Trashed directories (when trash=true)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_catalog.DirectoryInfo" + } + } + }, "204": { "description": "Directories deleted", "schema": { @@ -378,6 +387,12 @@ } ], "responses": { + "200": { + "description": "Trashed directory info (when trash=true)", + "schema": { + "$ref": "#/definitions/internal_catalog.DirectoryInfo" + } + }, "204": { "description": "Directory deleted", "schema": { @@ -479,7 +494,7 @@ "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": [ "application/json" ], @@ -498,18 +513,59 @@ }, { "type": "string", - "description": "Directory ID", + "description": "Directory ID (use 'root' for the root directory)", "name": "directoryID", "in": "path", "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": { "200": { - "description": "Array of FileInfo and DirectoryInfo objects", + "description": "Paginated list of FileInfo and DirectoryInfo objects", "schema": { - "type": "array", - "items": {} + "$ref": "#/definitions/internal_catalog.listDirectoryResponse" + } + }, + "400": { + "description": "Invalid limit or cursor", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "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": { "delete": { "security": [ @@ -640,6 +753,15 @@ } ], "responses": { + "200": { + "description": "Trashed files (when trash=true)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_catalog.FileInfo" + } + } + }, "204": { "description": "Files deleted", "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": { "post": { "security": [ @@ -1271,6 +1627,32 @@ } }, "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": { "description": "User account information", "type": "object", @@ -1506,6 +1888,11 @@ "type": "string", "example": "My Documents" }, + "parentId": { + "description": "ParentID is the public ID of the directory this directory is in", + "type": "string", + "example": "kRp2XYTq9A55" + }, "path": { "description": "Full path from root (included when ?include=path)", "type": "array", @@ -1554,6 +1941,11 @@ "type": "string", "example": "document.pdf" }, + "parentId": { + "description": "ParentID is the public ID of the directory this file is in", + "type": "string", + "example": "kRp2XYTq9A55" + }, "size": { "description": "File size in bytes", "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": { "description": "Error details for a failed item move", "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": { "description": "Upload status enumeration", "type": "string", @@ -1784,4 +2239,4 @@ "in": "header" } } -} \ No newline at end of file +} diff --git a/apps/backend/docs/swagger.yaml b/apps/backend/docs/swagger.yaml index e3cac32..a0c8c0f 100644 --- a/apps/backend/docs/swagger.yaml +++ b/apps/backend/docs/swagger.yaml @@ -1,1154 +1,1602 @@ -basePath: /api -definitions: - github_com_get-drexa_drexa_internal_user.User: - description: User account information - properties: - displayName: - description: User's display name - example: John Doe - type: string - email: - description: User's email address - example: john@example.com - type: string - id: - description: Unique user identifier - example: 550e8400-e29b-41d4-a716-446655440000 - type: string - type: object - github_com_get-drexa_drexa_internal_virtualfs.PathSegment: - properties: - id: - type: string - name: - type: string - type: object - internal_account.Account: - description: Storage account with usage and quota details - properties: - createdAt: - description: When the account was created (ISO 8601) - example: "2024-12-13T15:04:05Z" - type: string - id: - description: Unique account identifier - example: 550e8400-e29b-41d4-a716-446655440000 - type: string - storageQuotaBytes: - description: Maximum storage quota in bytes - example: 10737418240 - type: integer - storageUsageBytes: - description: Current storage usage in bytes - example: 1073741824 - type: integer - updatedAt: - description: When the account was last updated (ISO 8601) - example: "2024-12-13T16:30:00Z" - type: string - userId: - description: ID of the user who owns this account - example: 550e8400-e29b-41d4-a716-446655440001 - type: string - type: object - internal_account.registerAccountRequest: - description: Request to create a new account and user - properties: - displayName: - description: Display name for the user - example: Jane Doe - type: string - email: - description: Email address for the new account - example: newuser@example.com - type: string - password: - description: Password for the new account (min 8 characters) - example: securepassword123 - type: string - type: object - internal_account.registerAccountResponse: - description: Response after successful account registration - properties: - accessToken: - description: JWT access token for immediate authentication - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature - type: string - account: - allOf: - - $ref: '#/definitions/internal_account.Account' - description: The created account - refreshToken: - description: Base64 URL encoded refresh token - example: dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi - type: string - user: - allOf: - - $ref: '#/definitions/github_com_get-drexa_drexa_internal_user.User' - description: The created user - type: object - internal_auth.loginRequest: - description: Login request with email, password, and token delivery preference - properties: - email: - description: User's email address - example: user@example.com - type: string - password: - description: User's password - example: secretpassword123 - type: string - tokenDelivery: - description: 'How to deliver tokens: "cookie" (set HTTP-only cookies) or "body" - (include in response)' - enum: - - cookie - - body - example: body - type: string - type: object - internal_auth.loginResponse: - description: Login response containing user info and optionally tokens - properties: - accessToken: - description: JWT access token (only included when tokenDelivery is "body") - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature - type: string - refreshToken: - description: Base64 URL encoded refresh token (only included when tokenDelivery - is "body") - example: dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi - type: string - user: - allOf: - - $ref: '#/definitions/github_com_get-drexa_drexa_internal_user.User' - description: Authenticated user information - type: object - internal_auth.refreshAccessTokenRequest: - description: Request to exchange a refresh token for new tokens - properties: - refreshToken: - description: Base64 URL encoded refresh token - example: dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi - type: string - type: object - internal_auth.tokenResponse: - description: Response containing new access token and refresh token - properties: - accessToken: - description: New JWT access token - example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature - type: string - refreshToken: - description: New base64 URL encoded refresh token - example: xK9mPqRsTuVwXyZ0AbCdEfGhIjKlMnOpQrStUvWxYz1234567890abcdefgh - type: string - type: object - internal_catalog.DirectoryInfo: - description: Directory information including path and timestamps - properties: - createdAt: - description: When the directory was created (ISO 8601) - example: "2024-12-13T15:04:05Z" - type: string - deletedAt: - description: When the directory was trashed, null if not trashed (ISO 8601) - example: "2024-12-14T10:00:00Z" - type: string - id: - description: Unique directory identifier - example: kRp2XYTq9A55 - type: string - kind: - description: Item type, always "directory" - example: directory - type: string - name: - description: Directory name - example: My Documents - type: string - path: - description: Full path from root (included when ?include=path) - items: - $ref: '#/definitions/github_com_get-drexa_drexa_internal_virtualfs.PathSegment' - type: array - updatedAt: - description: When the directory was last updated (ISO 8601) - example: "2024-12-13T16:30:00Z" - type: string - type: object - internal_catalog.FileInfo: - description: File information including name, size, and timestamps - properties: - createdAt: - description: When the file was created (ISO 8601) - example: "2024-12-13T15:04:05Z" - type: string - deletedAt: - description: When the file was trashed, null if not trashed (ISO 8601) - example: "2024-12-14T10:00:00Z" - type: string - id: - description: Unique file identifier - example: mElnUNCm8F22 - type: string - kind: - description: Item type, always "file" - example: file - type: string - mimeType: - description: MIME type of the file - example: application/pdf - type: string - name: - description: File name - example: document.pdf - type: string - size: - description: File size in bytes - example: 1048576 - type: integer - updatedAt: - description: When the file was last updated (ISO 8601) - example: "2024-12-13T16:30:00Z" - type: string - type: object - internal_catalog.createDirectoryRequest: - description: Request to create a new directory - properties: - name: - description: Name for the new directory - example: New Folder - type: string - parentID: - description: ID of the parent directory - example: kRp2XYTq9A55 - type: string - type: object - internal_catalog.moveItemError: - description: Error details for a failed item move - properties: - error: - description: Error message describing what went wrong - example: permission denied - type: string - id: - description: ID of the item that failed to move - example: mElnUNCm8F22 - type: string - type: object - internal_catalog.moveItemsToDirectoryResponse: - description: Response from moving items to a directory with status for each item - properties: - conflicts: - description: Array of IDs of items that conflicted with existing items in - the target directory - example: - - xYz123AbC456 - items: - type: string - type: array - errors: - description: Array of errors that occurred during the move operation - items: - $ref: '#/definitions/internal_catalog.moveItemError' - type: array - items: - description: Array of items included in the request (files and directories) - items: - $ref: '#/definitions/internal_catalog.moveResponseItem' - type: array - moved: - description: Array of IDs of successfully moved items - example: - - mElnUNCm8F22 - - kRp2XYTq9A55 - items: - type: string - type: array - type: object - internal_catalog.moveResponseItem: - description: 'Item included in the move operation. Check "kind" field to determine - type: "file" (has size, mimeType) or "directory"' - properties: - createdAt: - description: When the item was created (ISO 8601) - example: "2024-12-13T15:04:05Z" - type: string - deletedAt: - description: When the item was trashed, null if not trashed (ISO 8601) - example: "2024-12-14T10:00:00Z" - type: string - id: - description: Unique item identifier - example: mElnUNCm8F22 - type: string - kind: - description: 'Item type: "file" or "directory"' - example: file - type: string - mimeType: - description: MIME type (only for files) - example: application/pdf - type: string - name: - description: Item name - example: document.pdf - type: string - size: - description: File size in bytes (only for files) - example: 1048576 - type: integer - updatedAt: - description: When the item was last updated (ISO 8601) - example: "2024-12-13T16:30:00Z" - type: string - type: object - internal_catalog.patchDirectoryRequest: - description: Request to update directory properties - properties: - name: - description: New name for the directory - example: My Documents - type: string - type: object - internal_catalog.patchFileRequest: - description: Request to update file properties - properties: - name: - description: New name for the file - example: renamed-document.pdf - type: string - type: object - internal_catalog.postDirectoryContentRequest: - description: Request to move items into this directory - properties: - items: - description: Array of file/directory IDs to move - example: - - mElnUNCm8F22 - - kRp2XYTq9A55 - items: - type: string - type: array - type: object - internal_upload.Status: - description: Upload status enumeration - enum: - - pending - - completed - - failed - type: string - x-enum-varnames: - - StatusPending - - StatusCompleted - - StatusFailed - internal_upload.Upload: - description: File upload session with status and upload URL - properties: - id: - description: Unique upload session identifier - example: xNq5RVBt3K88 - type: string - status: - allOf: - - $ref: '#/definitions/internal_upload.Status' - description: Current upload status - enum: - - pending - - completed - - failed - example: pending - uploadUrl: - description: URL to upload file content to - example: https://api.example.com/api/accounts/550e8400-e29b-41d4-a716-446655440000/uploads/xNq5RVBt3K88/content - type: string - type: object - internal_upload.createUploadRequest: - description: Request to initiate a file upload - properties: - name: - description: Name of the file being uploaded - example: document.pdf - type: string - parentId: - description: ID of the parent directory to upload into - example: kRp2XYTq9A55 - type: string - type: object - internal_upload.updateUploadRequest: - description: Request to update upload status (e.g., mark as completed) - properties: - status: - allOf: - - $ref: '#/definitions/internal_upload.Status' - description: New status for the upload - enum: - - completed - example: completed - type: object - internal_user.User: - description: User account information - properties: - displayName: - description: User's display name - example: John Doe - type: string - email: - description: User's email address - example: john@example.com - type: string - id: - description: Unique user identifier - example: 550e8400-e29b-41d4-a716-446655440000 - type: string - type: object -host: localhost:8080 +swagger: "2.0" info: + description: "Drexa is a file storage and management API. It provides endpoints for authentication, user management, file uploads, and virtual filesystem operations." + title: "Drexa API" contact: - name: Drexa Support - url: https://github.com/get-drexa/drexa - description: Drexa is a file storage and management API. It provides endpoints for - authentication, user management, file uploads, and virtual filesystem operations. + name: "Drexa Support" + url: "https://github.com/get-drexa/drexa" license: - name: MIT - url: https://opensource.org/licenses/MIT - title: Drexa API + name: "MIT" + url: "https://opensource.org/licenses/MIT" version: "1.0" +host: "localhost:8080" +basePath: "/api" paths: /accounts: - post: - consumes: - - application/json - description: Create a new user account with email and password. Returns the - account, user, and authentication tokens. - parameters: - - description: Registration details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_account.registerAccountRequest' + get: + security: + - + BearerAuth: [] + description: "Retrieve all accounts for the authenticated user" produces: - - application/json - responses: - "200": - description: Account created successfully - schema: - $ref: '#/definitions/internal_account.registerAccountResponse' - "400": - description: Invalid request body - schema: - type: string - "409": - description: Email already registered - schema: - type: string - summary: Register new account + - "application/json" tags: - - accounts + - "accounts" + summary: "List accounts" + responses: + 200: + description: "List of accounts for the authenticated user" + schema: + type: "array" + items: + $ref: "#/definitions/internal_account.Account" + 401: + description: "Not authenticated" + schema: + type: "string" + post: + description: "Create a new user account with email and password. Returns the account, user, and authentication tokens. Tokens can be delivered via HTTP-only cookies or in the response body based on the tokenDelivery field." + consumes: + - "application/json" + produces: + - "application/json" + tags: + - "accounts" + summary: "Register new account" + parameters: + - + description: "Registration details" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_account.registerAccountRequest" + responses: + 200: + description: "Account created successfully" + schema: + $ref: "#/definitions/internal_account.registerAccountResponse" + 400: + description: "Invalid request body or token delivery method" + schema: + type: "string" + 409: + description: "Email already registered" + schema: + type: "string" /accounts/{accountID}: get: - description: Retrieve account details including storage usage and quota - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Account details - schema: - $ref: '#/definitions/internal_account.Account' - "401": - description: Not authenticated - schema: - type: string - "404": - description: Account not found - schema: - type: string security: - - BearerAuth: [] - summary: Get account + - + BearerAuth: [] + description: "Retrieve account details including storage usage and quota" + produces: + - "application/json" tags: - - accounts + - "accounts" + summary: "Get account" + parameters: + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + responses: + 200: + description: "Account details" + schema: + $ref: "#/definitions/internal_account.Account" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Account not found" + schema: + type: "string" /accounts/{accountID}/directories: post: - consumes: - - application/json - description: Create a new directory within a parent directory - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Directory details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_catalog.createDirectoryRequest' - - description: Include additional fields - enum: - - path - in: query - name: include - type: string - produces: - - application/json - responses: - "200": - description: Created directory - schema: - $ref: '#/definitions/internal_catalog.DirectoryInfo' - "400": - description: Parent not found or not a directory - schema: - additionalProperties: - type: string - type: object - "401": - description: Not authenticated - schema: - type: string - "409": - description: Directory already exists - schema: - additionalProperties: - type: string - type: object security: - - BearerAuth: [] - summary: Create directory + - + BearerAuth: [] + description: "Create a new directory within a parent directory" + consumes: + - "application/json" + produces: + - "application/json" tags: - - directories - /accounts/{accountID}/directories/{directoryID}: + - "directories" + summary: "Create directory" + parameters: + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + description: "Directory details" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_catalog.createDirectoryRequest" + - + enum: + - "path" + type: "string" + description: "Include additional fields" + name: "include" + in: "query" + responses: + 200: + description: "Created directory" + schema: + $ref: "#/definitions/internal_catalog.DirectoryInfo" + 400: + description: "Parent not found or not a directory" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 409: + description: "Directory already exists" + schema: + type: "object" + additionalProperties: + type: "string" delete: - description: Delete a directory permanently or move it to trash. Deleting a - directory also affects all its contents. - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Directory ID - in: path - name: directoryID - required: true - type: string - - default: false - description: Move to trash instead of permanent delete - in: query - name: trash - type: boolean - responses: - "204": - description: Directory deleted - schema: - type: string - "401": - description: Not authenticated - schema: - type: string - "404": - description: Directory not found - schema: - type: string security: - - BearerAuth: [] - summary: Delete directory + - + BearerAuth: [] + description: "Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories." tags: - - directories - get: - description: Retrieve metadata for a specific directory + - "directories" + summary: "Bulk delete directories" parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Directory ID - in: path - name: directoryID - required: true - type: string - - description: Include additional fields - enum: - - path - in: query - name: include - type: string - produces: - - application/json + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + type: "string" + description: "Comma-separated list of directory IDs to delete" + name: "id" + in: "query" + required: true + - + type: "boolean" + default: false + description: "Move to trash instead of permanent delete" + name: "trash" + in: "query" responses: - "200": - description: Directory metadata + 200: + description: "Trashed directories (when trash=true)" schema: - $ref: '#/definitions/internal_catalog.DirectoryInfo' - "401": - description: Not authenticated + type: "array" + items: + $ref: "#/definitions/internal_catalog.DirectoryInfo" + 204: + description: "Directories deleted" schema: - type: string - "404": - description: Directory not found - schema: - type: string - security: - - BearerAuth: [] - summary: Get directory info - tags: - - directories - patch: - consumes: - - application/json - description: Update directory properties such as name (rename) - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Directory ID - in: path - name: directoryID - required: true - type: string - - description: Directory update - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_catalog.patchDirectoryRequest' - produces: - - application/json - responses: - "200": - description: Updated directory metadata - schema: - $ref: '#/definitions/internal_catalog.DirectoryInfo' - "400": - description: Invalid request + type: "string" + 400: + description: "All items must be directories" schema: + type: "object" additionalProperties: - type: string - type: object - "401": - description: Not authenticated + type: "string" + 401: + description: "Not authenticated" schema: - type: string - "404": - description: Directory not found - schema: - type: string + type: "string" + /accounts/{accountID}/directories/{directoryID}: + get: security: - - BearerAuth: [] - summary: Update directory + - + BearerAuth: [] + description: "Retrieve metadata for a specific directory" + produces: + - "application/json" tags: - - directories + - "directories" + summary: "Get directory info" + 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 + - + enum: + - "path" + type: "string" + description: "Include additional fields" + name: "include" + in: "query" + responses: + 200: + description: "Directory metadata" + schema: + $ref: "#/definitions/internal_catalog.DirectoryInfo" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Directory not found" + schema: + type: "string" + delete: + security: + - + BearerAuth: [] + description: "Delete a directory permanently or move it to trash. Deleting a directory also affects all its contents." + tags: + - "directories" + summary: "Delete directory" + 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 + - + type: "boolean" + default: false + description: "Move to trash instead of permanent delete" + name: "trash" + in: "query" + responses: + 200: + description: "Trashed directory info (when trash=true)" + schema: + $ref: "#/definitions/internal_catalog.DirectoryInfo" + 204: + description: "Directory deleted" + schema: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Directory not found" + schema: + type: "string" + patch: + security: + - + BearerAuth: [] + description: "Update directory properties such as name (rename)" + consumes: + - "application/json" + produces: + - "application/json" + tags: + - "directories" + summary: "Update directory" + 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 + - + description: "Directory update" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_catalog.patchDirectoryRequest" + responses: + 200: + description: "Updated directory metadata" + schema: + $ref: "#/definitions/internal_catalog.DirectoryInfo" + 400: + description: "Invalid request" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Directory not found" + schema: + type: "string" /accounts/{accountID}/directories/{directoryID}/content: get: - description: Get all files and subdirectories within a directory - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Directory ID - in: path - name: directoryID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Array of FileInfo and DirectoryInfo objects - schema: - items: {} - type: array - "401": - description: Not authenticated - schema: - type: string - "404": - description: Directory not found - schema: - type: string security: - - BearerAuth: [] - summary: List directory contents + - + BearerAuth: [] + description: "Get all files and subdirectories within a directory with optional pagination and sorting" + produces: + - "application/json" tags: - - directories + - "directories" + summary: "List directory contents" + parameters: + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + type: "string" + description: "Directory ID (use 'root' for the root directory)" + name: "directoryID" + in: "path" + 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: + 200: + description: "Paginated list of FileInfo and DirectoryInfo objects" + schema: + $ref: "#/definitions/internal_catalog.listDirectoryResponse" + 400: + description: "Invalid limit or cursor" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Directory not found" + schema: + type: "string" post: + security: + - + BearerAuth: [] + description: "Move one or more files or directories into this directory. Returns detailed status for each item including which were successfully moved, which had conflicts, and which encountered errors." consumes: - - application/json - description: Move one or more files or directories into this directory. Returns - detailed status for each item including which were successfully moved, which - had conflicts, and which encountered errors. - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Target directory ID - in: path - name: directoryID - required: true - type: string - - description: Items to move - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_catalog.postDirectoryContentRequest' + - "application/json" produces: - - application/json - responses: - "200": - description: Move operation results with moved, conflict, and error states - schema: - $ref: '#/definitions/internal_catalog.moveItemsToDirectoryResponse' - "400": - description: Invalid request or items not in same directory - schema: - additionalProperties: - type: string - type: object - "401": - description: Not authenticated - schema: - type: string - "404": - description: One or more items not found - schema: - additionalProperties: - type: string - type: object - security: - - BearerAuth: [] - summary: Move items to directory + - "application/json" tags: - - directories - /accounts/{accountID}/files/{fileID}: - delete: - description: Delete a file permanently or move it to trash + - "directories" + summary: "Move items to directory" parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: File ID - in: path - name: fileID - required: true - type: string - - default: false - description: Move to trash instead of permanent delete - in: query - name: trash - type: boolean - produces: - - application/json + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + type: "string" + description: "Target directory ID" + name: "directoryID" + in: "path" + required: true + - + description: "Items to move" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_catalog.postDirectoryContentRequest" responses: - "200": - description: Trashed file info (when trash=true) + 200: + description: "Move operation results with moved, conflict, and error states" schema: - $ref: '#/definitions/internal_catalog.FileInfo' - "204": - description: Permanently deleted (when trash=false) + $ref: "#/definitions/internal_catalog.moveItemsToDirectoryResponse" + 400: + description: "Invalid request or items not in same directory" schema: - type: string - "401": - description: Not authenticated + type: "object" + additionalProperties: + type: "string" + 401: + description: "Not authenticated" schema: - type: string - "404": - description: File not found + type: "string" + 404: + description: "One or more items not found" schema: - type: string - security: - - BearerAuth: [] - summary: Delete file - tags: - - files + type: "object" + additionalProperties: + type: "string" + /accounts/{accountID}/directories/{directoryID}/shares: get: - description: Retrieve metadata for a specific file - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: File ID - in: path - name: fileID - required: true - type: string - produces: - - application/json - responses: - "200": - description: File metadata - schema: - $ref: '#/definitions/internal_catalog.FileInfo' - "401": - description: Not authenticated - schema: - type: string - "404": - description: File not found - schema: - type: string security: - - BearerAuth: [] - summary: Get file info - tags: - - files - patch: - consumes: - - application/json - description: Update file properties such as name (rename) - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: File ID - in: path - name: fileID - required: true - type: string - - description: File update - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_catalog.patchFileRequest' + - + BearerAuth: [] + description: "Get all share links that include this directory" produces: - - application/json + - "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: Updated file metadata + 200: + description: "Array of shares" schema: - $ref: '#/definitions/internal_catalog.FileInfo' - "400": - description: Invalid request + 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: + delete: + security: + - + BearerAuth: [] + description: "Delete multiple files permanently or move them to trash. All items must be files." + tags: + - "files" + summary: "Bulk delete files" + parameters: + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + type: "string" + description: "Comma-separated list of file IDs to delete" + name: "id" + in: "query" + required: true + - + type: "boolean" + default: false + description: "Move to trash instead of permanent delete" + name: "trash" + in: "query" + responses: + 200: + description: "Trashed files (when trash=true)" + schema: + type: "array" + items: + $ref: "#/definitions/internal_catalog.FileInfo" + 204: + description: "Files deleted" + schema: + type: "string" + 400: + description: "All items must be files" + schema: + type: "object" additionalProperties: - type: string - type: object - "401": - description: Not authenticated + type: "string" + 401: + description: "Not authenticated" schema: - type: string - "404": - description: File not found - schema: - type: string + type: "string" + /accounts/{accountID}/files/{fileID}: + get: security: - - BearerAuth: [] - summary: Update file + - + BearerAuth: [] + description: "Retrieve metadata for a specific file" + produces: + - "application/json" tags: - - files + - "files" + summary: "Get file info" + 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: "File metadata" + schema: + $ref: "#/definitions/internal_catalog.FileInfo" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "File not found" + schema: + type: "string" + delete: + security: + - + BearerAuth: [] + description: "Delete a file permanently or move it to trash" + produces: + - "application/json" + tags: + - "files" + summary: "Delete file" + 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 + - + type: "boolean" + default: false + description: "Move to trash instead of permanent delete" + name: "trash" + in: "query" + responses: + 200: + description: "Trashed file info (when trash=true)" + schema: + $ref: "#/definitions/internal_catalog.FileInfo" + 204: + description: "Permanently deleted (when trash=false)" + schema: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "File not found" + schema: + type: "string" + patch: + security: + - + BearerAuth: [] + description: "Update file properties such as name (rename)" + consumes: + - "application/json" + produces: + - "application/json" + tags: + - "files" + summary: "Update file" + 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 + - + description: "File update" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_catalog.patchFileRequest" + responses: + 200: + description: "Updated file metadata" + schema: + $ref: "#/definitions/internal_catalog.FileInfo" + 400: + description: "Invalid request" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "File not found" + schema: + type: "string" /accounts/{accountID}/files/{fileID}/content: get: - description: Download the file content. May redirect to a signed URL for external - storage. - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: File ID - in: path - name: fileID - required: true - type: string - produces: - - application/octet-stream - responses: - "200": - description: File content stream - schema: - type: file - "307": - description: Redirect to download URL - schema: - type: string - "401": - description: Not authenticated - schema: - type: string - "404": - description: File not found - schema: - type: string security: - - BearerAuth: [] - summary: Download file + - + BearerAuth: [] + description: "Download the file content. May redirect to a signed URL for external storage." + produces: + - "application/octet-stream" tags: - - files + - "files" + summary: "Download file" + 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: "File content stream" + schema: + type: "file" + 307: + description: "Redirect to download URL" + schema: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "File not found" + schema: + type: "string" + /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: post: - consumes: - - application/json - description: Start a new file upload session. Returns an upload URL to PUT file - content to. - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Upload details - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_upload.createUploadRequest' - produces: - - application/json - responses: - "200": - description: Upload session created - schema: - $ref: '#/definitions/internal_upload.Upload' - "400": - description: Parent is not a directory - schema: - additionalProperties: - type: string - type: object - "401": - description: Not authenticated - schema: - type: string - "404": - description: Parent directory not found - schema: - type: string - "409": - description: File with this name already exists - schema: - additionalProperties: - type: string - type: object security: - - BearerAuth: [] - summary: Create upload session + - + BearerAuth: [] + description: "Start a new file upload session. Returns an upload URL to PUT file content to." + consumes: + - "application/json" + produces: + - "application/json" tags: - - uploads + - "uploads" + summary: "Create upload session" + parameters: + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + description: "Upload details" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_upload.createUploadRequest" + responses: + 200: + description: "Upload session created" + schema: + $ref: "#/definitions/internal_upload.Upload" + 400: + description: "Parent is not a directory" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Parent directory not found" + schema: + type: "string" + 409: + description: "File with this name already exists" + schema: + type: "object" + additionalProperties: + type: "string" /accounts/{accountID}/uploads/{uploadID}: patch: - consumes: - - application/json - description: Mark an upload as completed after content has been uploaded. This - finalizes the file in the filesystem. - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Upload session ID - in: path - name: uploadID - required: true - type: string - - description: Status update - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_upload.updateUploadRequest' - produces: - - application/json - responses: - "200": - description: Upload completed - schema: - $ref: '#/definitions/internal_upload.Upload' - "400": - description: Content not uploaded yet or invalid status - schema: - additionalProperties: - type: string - type: object - "401": - description: Not authenticated - schema: - type: string - "404": - description: Upload session not found - schema: - type: string security: - - BearerAuth: [] - summary: Complete upload + - + BearerAuth: [] + description: "Mark an upload as completed after content has been uploaded. This finalizes the file in the filesystem." + consumes: + - "application/json" + produces: + - "application/json" tags: - - uploads + - "uploads" + summary: "Complete upload" + parameters: + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + type: "string" + description: "Upload session ID" + name: "uploadID" + in: "path" + required: true + - + description: "Status update" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_upload.updateUploadRequest" + responses: + 200: + description: "Upload completed" + schema: + $ref: "#/definitions/internal_upload.Upload" + 400: + description: "Content not uploaded yet or invalid status" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Upload session not found" + schema: + type: "string" /accounts/{accountID}/uploads/{uploadID}/content: put: - consumes: - - application/octet-stream - description: Stream file content to complete an upload. Send raw binary data - in the request body. - parameters: - - description: Account ID - format: uuid - in: path - name: accountID - required: true - type: string - - description: Upload session ID - in: path - name: uploadID - required: true - type: string - - description: File content (binary) - in: body - name: file - required: true - schema: - items: - type: integer - type: array - responses: - "204": - description: Content received successfully - schema: - type: string - "401": - description: Not authenticated - schema: - type: string - "404": - description: Upload session not found - schema: - type: string security: - - BearerAuth: [] - summary: Upload file content + - + BearerAuth: [] + description: "Stream file content to complete an upload. Send raw binary data in the request body." + consumes: + - "application/octet-stream" tags: - - uploads + - "uploads" + summary: "Upload file content" + parameters: + - + type: "string" + format: "uuid" + description: "Account ID" + name: "accountID" + in: "path" + required: true + - + type: "string" + description: "Upload session ID" + name: "uploadID" + in: "path" + required: true + - + description: "File content (binary)" + name: "file" + in: "body" + required: true + schema: + type: "array" + items: + type: "integer" + responses: + 204: + description: "Content received successfully" + schema: + type: "string" + 401: + description: "Not authenticated" + schema: + type: "string" + 404: + description: "Upload session not found" + schema: + type: "string" /auth/login: post: + description: "Authenticate with email and password to receive JWT tokens. Tokens can be delivered via HTTP-only cookies or in the response body based on the tokenDelivery field." consumes: - - application/json - description: Authenticate with email and password to receive JWT tokens. Tokens - can be delivered via HTTP-only cookies or in the response body based on the - tokenDelivery field. - parameters: - - description: Login credentials - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_auth.loginRequest' + - "application/json" produces: - - application/json - responses: - "200": - description: Successful authentication - schema: - $ref: '#/definitions/internal_auth.loginResponse' - "400": - description: Invalid request body or token delivery method - schema: - additionalProperties: - type: string - type: object - "401": - description: Invalid email or password - schema: - additionalProperties: - type: string - type: object - summary: User login + - "application/json" tags: - - auth + - "auth" + summary: "User login" + parameters: + - + description: "Login credentials" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_auth.loginRequest" + responses: + 200: + description: "Successful authentication" + schema: + $ref: "#/definitions/internal_auth.loginResponse" + 400: + description: "Invalid request body or token delivery method" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Invalid email or password" + schema: + type: "object" + additionalProperties: + type: "string" /auth/tokens: post: + description: "Exchange a valid refresh token for a new pair of access and refresh tokens. The old refresh token is invalidated (rotation)." consumes: - - application/json - description: Exchange a valid refresh token for a new pair of access and refresh - tokens. The old refresh token is invalidated (rotation). - parameters: - - description: Refresh token - in: body - name: request - required: true - schema: - $ref: '#/definitions/internal_auth.refreshAccessTokenRequest' + - "application/json" produces: - - application/json - responses: - "200": - description: New tokens - schema: - $ref: '#/definitions/internal_auth.tokenResponse' - "400": - description: Invalid request body - schema: - additionalProperties: - type: string - type: object - "401": - description: Invalid, expired, or reused refresh token - schema: - additionalProperties: - type: string - type: object - summary: Refresh access token + - "application/json" tags: - - auth + - "auth" + summary: "Refresh access token" + parameters: + - + description: "Refresh token" + name: "request" + in: "body" + required: true + schema: + $ref: "#/definitions/internal_auth.refreshAccessTokenRequest" + responses: + 200: + description: "New tokens" + schema: + $ref: "#/definitions/internal_auth.tokenResponse" + 400: + description: "Invalid request body" + schema: + type: "object" + additionalProperties: + type: "string" + 401: + description: "Invalid, expired, or reused refresh token" + schema: + type: "object" + additionalProperties: + type: "string" /users/me: get: - description: Retrieve the authenticated user's profile information - produces: - - application/json - responses: - "200": - description: User profile - schema: - $ref: '#/definitions/internal_user.User' - "401": - description: Not authenticated - schema: - type: string security: - - BearerAuth: [] - summary: Get current user + - + BearerAuth: [] + description: "Retrieve the authenticated user's profile information" + produces: + - "application/json" tags: - - users + - "users" + summary: "Get current user" + responses: + 200: + description: "User profile" + schema: + $ref: "#/definitions/internal_user.User" + 401: + description: "Not authenticated" + schema: + type: "string" +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: + description: "User account information" + type: "object" + properties: + displayName: + description: "User's display name" + type: "string" + example: "John Doe" + email: + description: "User's email address" + type: "string" + example: "john@example.com" + id: + description: "Unique user identifier" + type: "string" + example: "550e8400-e29b-41d4-a716-446655440000" + github_com_get-drexa_drexa_internal_virtualfs.PathSegment: + type: "object" + properties: + id: + type: "string" + name: + type: "string" + internal_account.Account: + description: "Storage account with usage and quota details" + type: "object" + properties: + createdAt: + description: "When the account was created (ISO 8601)" + type: "string" + example: "2024-12-13T15:04:05Z" + id: + description: "Unique account identifier" + type: "string" + example: "550e8400-e29b-41d4-a716-446655440000" + storageQuotaBytes: + description: "Maximum storage quota in bytes" + type: "integer" + example: 10737418240 + storageUsageBytes: + description: "Current storage usage in bytes" + type: "integer" + example: 1073741824 + updatedAt: + description: "When the account was last updated (ISO 8601)" + type: "string" + example: "2024-12-13T16:30:00Z" + userId: + description: "ID of the user who owns this account" + type: "string" + example: "550e8400-e29b-41d4-a716-446655440001" + internal_account.registerAccountRequest: + description: "Request to create a new account and user" + type: "object" + properties: + displayName: + description: "Display name for the user" + type: "string" + example: "Jane Doe" + email: + description: "Email address for the new account" + type: "string" + example: "newuser@example.com" + password: + description: "Password for the new account (min 8 characters)" + type: "string" + example: "securepassword123" + tokenDelivery: + description: "How to deliver tokens: \"cookie\" (set HTTP-only cookies) or \"body\" (include in response)" + type: "string" + enum: + - "cookie" + - "body" + example: "body" + internal_account.registerAccountResponse: + description: "Response after successful account registration" + type: "object" + properties: + accessToken: + description: "JWT access token for immediate authentication (only included when tokenDelivery is \"body\")" + type: "string" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" + account: + description: "The created account" + allOf: + - + $ref: "#/definitions/internal_account.Account" + refreshToken: + description: "Base64 URL encoded refresh token (only included when tokenDelivery is \"body\")" + type: "string" + example: "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" + user: + description: "The created user" + allOf: + - + $ref: "#/definitions/github_com_get-drexa_drexa_internal_user.User" + internal_auth.loginRequest: + description: "Login request with email, password, and token delivery preference" + type: "object" + properties: + email: + description: "User's email address" + type: "string" + example: "user@example.com" + password: + description: "User's password" + type: "string" + example: "secretpassword123" + tokenDelivery: + description: "How to deliver tokens: \"cookie\" (set HTTP-only cookies) or \"body\" (include in response)" + type: "string" + enum: + - "cookie" + - "body" + example: "body" + internal_auth.loginResponse: + description: "Login response containing user info and optionally tokens" + type: "object" + properties: + accessToken: + description: "JWT access token (only included when tokenDelivery is \"body\")" + type: "string" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" + refreshToken: + description: "Base64 URL encoded refresh token (only included when tokenDelivery is \"body\")" + type: "string" + example: "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" + user: + description: "Authenticated user information" + allOf: + - + $ref: "#/definitions/github_com_get-drexa_drexa_internal_user.User" + internal_auth.refreshAccessTokenRequest: + description: "Request to exchange a refresh token for new tokens" + type: "object" + properties: + refreshToken: + description: "Base64 URL encoded refresh token" + type: "string" + example: "dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi" + internal_auth.tokenResponse: + description: "Response containing new access token and refresh token" + type: "object" + properties: + accessToken: + description: "New JWT access token" + type: "string" + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature" + refreshToken: + description: "New base64 URL encoded refresh token" + type: "string" + example: "xK9mPqRsTuVwXyZ0AbCdEfGhIjKlMnOpQrStUvWxYz1234567890abcdefgh" + internal_catalog.DirectoryInfo: + description: "Directory information including path and timestamps" + type: "object" + properties: + createdAt: + description: "When the directory was created (ISO 8601)" + type: "string" + example: "2024-12-13T15:04:05Z" + deletedAt: + description: "When the directory was trashed, null if not trashed (ISO 8601)" + type: "string" + example: "2024-12-14T10:00:00Z" + id: + description: "Unique directory identifier" + type: "string" + example: "kRp2XYTq9A55" + kind: + description: "Item type, always \"directory\"" + type: "string" + example: "directory" + name: + description: "Directory name" + type: "string" + example: "My Documents" + parentId: + description: "ParentID is the public ID of the directory this directory is in" + type: "string" + example: "kRp2XYTq9A55" + path: + description: "Full path from root (included when ?include=path)" + type: "array" + items: + $ref: "#/definitions/github_com_get-drexa_drexa_internal_virtualfs.PathSegment" + updatedAt: + description: "When the directory was last updated (ISO 8601)" + type: "string" + example: "2024-12-13T16:30:00Z" + internal_catalog.FileInfo: + description: "File information including name, size, and timestamps" + type: "object" + properties: + createdAt: + description: "When the file was created (ISO 8601)" + type: "string" + example: "2024-12-13T15:04:05Z" + deletedAt: + description: "When the file was trashed, null if not trashed (ISO 8601)" + type: "string" + example: "2024-12-14T10:00:00Z" + id: + description: "Unique file identifier" + type: "string" + example: "mElnUNCm8F22" + kind: + description: "Item type, always \"file\"" + type: "string" + example: "file" + mimeType: + description: "MIME type of the file" + type: "string" + example: "application/pdf" + name: + description: "File name" + type: "string" + example: "document.pdf" + parentId: + description: "ParentID is the public ID of the directory this file is in" + type: "string" + example: "kRp2XYTq9A55" + size: + description: "File size in bytes" + type: "integer" + example: 1048576 + updatedAt: + description: "When the file was last updated (ISO 8601)" + type: "string" + example: "2024-12-13T16:30:00Z" + internal_catalog.createDirectoryRequest: + description: "Request to create a new directory" + type: "object" + properties: + name: + description: "Name for the new directory" + type: "string" + example: "New Folder" + parentID: + description: "ID of the parent directory" + type: "string" + example: "kRp2XYTq9A55" + 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: + description: "Error details for a failed item move" + type: "object" + properties: + error: + description: "Error message describing what went wrong" + type: "string" + example: "permission denied" + id: + description: "ID of the item that failed to move" + type: "string" + example: "mElnUNCm8F22" + internal_catalog.moveItemsToDirectoryResponse: + description: "Response from moving items to a directory with status for each item" + type: "object" + properties: + conflicts: + description: "Array of IDs of items that conflicted with existing items in the target directory" + type: "array" + items: + type: "string" + example: + - "xYz123AbC456" + errors: + description: "Array of errors that occurred during the move operation" + type: "array" + items: + $ref: "#/definitions/internal_catalog.moveItemError" + items: + description: "Array of items included in the request (FileInfo or DirectoryInfo objects)" + type: "array" + items: {} + moved: + description: "Array of IDs of successfully moved items" + type: "array" + items: + type: "string" + example: + - "mElnUNCm8F22" + - "kRp2XYTq9A55" + internal_catalog.patchDirectoryRequest: + description: "Request to update directory properties" + type: "object" + properties: + name: + description: "New name for the directory" + type: "string" + example: "My Documents" + internal_catalog.patchFileRequest: + description: "Request to update file properties" + type: "object" + properties: + name: + description: "New name for the file" + type: "string" + example: "renamed-document.pdf" + internal_catalog.postDirectoryContentRequest: + description: "Request to move items into this directory" + type: "object" + properties: + items: + description: "Array of file/directory IDs to move" + type: "array" + items: + type: "string" + example: + - "mElnUNCm8F22" + - "kRp2XYTq9A55" + 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: + description: "Upload status enumeration" + type: "string" + enum: + - "pending" + - "completed" + - "failed" + x-enum-varnames: + - "StatusPending" + - "StatusCompleted" + - "StatusFailed" + internal_upload.Upload: + description: "File upload session with status and upload URL" + type: "object" + properties: + id: + description: "Unique upload session identifier" + type: "string" + example: "xNq5RVBt3K88" + status: + description: "Current upload status" + enum: + - "pending" + - "completed" + - "failed" + allOf: + - + $ref: "#/definitions/internal_upload.Status" + example: "pending" + uploadUrl: + description: "URL to upload file content to" + type: "string" + example: "https://api.example.com/api/accounts/550e8400-e29b-41d4-a716-446655440000/uploads/xNq5RVBt3K88/content" + internal_upload.createUploadRequest: + description: "Request to initiate a file upload" + type: "object" + properties: + name: + description: "Name of the file being uploaded" + type: "string" + example: "document.pdf" + parentId: + description: "ID of the parent directory to upload into" + type: "string" + example: "kRp2XYTq9A55" + internal_upload.updateUploadRequest: + description: "Request to update upload status (e.g., mark as completed)" + type: "object" + properties: + status: + description: "New status for the upload" + enum: + - "completed" + allOf: + - + $ref: "#/definitions/internal_upload.Status" + example: "completed" + internal_user.User: + description: "User account information" + type: "object" + properties: + displayName: + description: "User's display name" + type: "string" + example: "John Doe" + email: + description: "User's email address" + type: "string" + example: "john@example.com" + id: + description: "Unique user identifier" + type: "string" + example: "550e8400-e29b-41d4-a716-446655440000" securityDefinitions: BearerAuth: - description: 'JWT access token. Format: "Bearer {token}"' - in: header - name: Authorization - type: apiKey -swagger: "2.0" + description: "JWT access token. Format: \"Bearer {token}\"" + type: "apiKey" + name: "Authorization" + in: "header" diff --git a/apps/backend/internal/account/http.go b/apps/backend/internal/account/http.go index d4d62d4..96001a4 100644 --- a/apps/backend/internal/account/http.go +++ b/apps/backend/internal/account/http.go @@ -7,6 +7,7 @@ import ( "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" @@ -15,6 +16,7 @@ import ( type HTTPHandler struct { accountService *Service authService *auth.Service + vfs *virtualfs.VirtualFS db *bun.DB authMiddleware fiber.Handler cookieConfig auth.CookieConfig @@ -46,17 +48,11 @@ type registerAccountResponse struct { RefreshToken string `json:"refreshToken,omitempty" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"` } -const currentAccountKey = "currentAccount" - -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 { +func NewHTTPHandler(accountService *Service, authService *auth.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler, cookieConfig auth.CookieConfig) *HTTPHandler { 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.Post("/accounts", h.registerAccount) @@ -66,7 +62,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) fiber.Router { account.Get("/", h.getAccount) - return account + return &ScopedRouter{virtualfs.ScopedRouter{account}} } func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error { @@ -85,7 +81,22 @@ func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error { 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() } @@ -120,8 +131,8 @@ func (h *HTTPHandler) listAccounts(c *fiber.Ctx) error { // @Failure 404 {string} string "Account not found" // @Router /accounts/{accountID} [get] func (h *HTTPHandler) getAccount(c *fiber.Ctx) error { - account := CurrentAccount(c) - if account == nil { + account, ok := reqctx.CurrentAccount(c).(*Account) + if !ok || account == nil { return c.SendStatus(fiber.StatusNotFound) } return c.JSON(account) diff --git a/apps/backend/internal/account/scoped_router.go b/apps/backend/internal/account/scoped_router.go new file mode 100644 index 0000000..217bc57 --- /dev/null +++ b/apps/backend/internal/account/scoped_router.go @@ -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 +} diff --git a/apps/backend/internal/account/service.go b/apps/backend/internal/account/service.go index 11ab633..ee04b16 100644 --- a/apps/backend/internal/account/service.go +++ b/apps/backend/internal/account/service.go @@ -59,7 +59,7 @@ func (s *Service) Register(ctx context.Context, db bun.IDB, opts RegisterOptions 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 { return nil, nil, err } diff --git a/apps/backend/internal/catalog/directory.go b/apps/backend/internal/catalog/directory.go index 9dbdbd7..1d1a308 100644 --- a/apps/backend/internal/catalog/directory.go +++ b/apps/backend/internal/catalog/directory.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/get-drexa/drexa/internal/account" "github.com/get-drexa/drexa/internal/httperr" + "github.com/get-drexa/drexa/internal/sharing" "github.com/get-drexa/drexa/internal/virtualfs" "github.com/gofiber/fiber/v2" ) @@ -99,8 +99,8 @@ type decodedListChildrenCursor struct { } func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scope, ok := scopeFromCtx(c) + if !ok { return c.SendStatus(fiber.StatusUnauthorized) } @@ -108,13 +108,16 @@ func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error { var node *virtualfs.Node 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 errors.Is(err, virtualfs.ErrNodeNotFound) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } node = n } 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 errors.Is(err, virtualfs.ErrNodeNotFound) { return c.SendStatus(fiber.StatusNotFound) @@ -153,8 +156,8 @@ func includeParam(c *fiber.Ctx) []string { // @Failure 409 {object} map[string]string "Directory already exists" // @Router /accounts/{accountID}/directories [post] func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scope, ok := scopeFromCtx(c) + if !ok { return c.SendStatus(fiber.StatusUnauthorized) } @@ -169,7 +172,7 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error { } 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 errors.Is(err, virtualfs.ErrNodeNotFound) { 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"}) } - 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 errors.Is(err, virtualfs.ErrNodeConflict) { 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) } @@ -200,8 +206,11 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error { include := includeParam(c) 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 errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } i.Path = p @@ -230,6 +239,10 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error { // @Router /accounts/{accountID}/directories/{directoryID} [get] func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error { node := mustCurrentDirectoryNode(c) + scope, ok := scopeFromCtx(c) + if !ok { + return c.SendStatus(fiber.StatusUnauthorized) + } i := DirectoryInfo{ Kind: DirItemKindDirectory, @@ -242,8 +255,11 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error { include := includeParam(c) 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 errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } i.Path = p @@ -254,7 +270,7 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error { // listDirectory returns 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 // @Produce json // @Security BearerAuth @@ -271,6 +287,10 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error { // @Router /accounts/{accountID}/directories/{directoryID}/content [get] func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error { node := mustCurrentDirectoryNode(c) + scope, ok := scopeFromCtx(c) + if !ok { + return c.SendStatus(fiber.StatusUnauthorized) + } 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"}) } - 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 errors.Is(err, virtualfs.ErrNodeNotFound) { 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 errors.Is(err, virtualfs.ErrNodeNotFound) { return c.SendStatus(fiber.StatusNotFound) } + if errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } @@ -392,6 +415,10 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error { // @Router /accounts/{accountID}/directories/{directoryID} [patch] func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error { node := mustCurrentDirectoryNode(c) + scope, ok := scopeFromCtx(c) + if !ok { + return c.SendStatus(fiber.StatusUnauthorized) + } patch := new(patchDirectoryRequest) if err := c.BodyParser(patch); err != nil { @@ -405,11 +432,14 @@ func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error { defer tx.Rollback() 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 errors.Is(err, virtualfs.ErrNodeNotFound) { return c.SendStatus(fiber.StatusNotFound) } + if errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } 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 directoryID path string true "Directory ID" // @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" // @Failure 401 {string} string "Not authenticated" // @Failure 404 {string} string "Directory not found" // @Router /accounts/{accountID}/directories/{directoryID} [delete] func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error { node := mustCurrentDirectoryNode(c) + scope, ok := scopeFromCtx(c) + if !ok { + return c.SendStatus(fiber.StatusUnauthorized) + } tx, err := h.db.BeginTx(c.Context(), nil) if err != nil { @@ -452,8 +487,11 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error { shouldTrash := c.Query("trash") == "true" if shouldTrash { - _, err := h.vfs.SoftDeleteNode(c.Context(), tx, node) + _, err := h.vfs.SoftDeleteNode(c.Context(), tx, node, scope) if err != nil { + if errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } @@ -464,8 +502,11 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error { return c.JSON(directoryInfoFromNode(node)) } else { - err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) + err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope) if err != nil { + if errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } 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 id query string true "Comma-separated list of directory IDs to delete" example:"kRp2XYTq9A55,xYz123AbC456" // @Param trash query bool false "Move to trash instead of permanent delete" default(false) +// @Success 200 {array} DirectoryInfo "Trashed directories (when trash=true)" // @Success 204 {string} string "Directories deleted" // @Failure 400 {object} map[string]string "All items must be directories" // @Failure 401 {string} string "Not authenticated" // @Router /accounts/{accountID}/directories [delete] func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scope, ok := scopeFromCtx(c) + if !ok { return c.SendStatus(fiber.StatusUnauthorized) } @@ -514,7 +556,7 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error { } 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 { return httperr.Internal(err) } @@ -530,8 +572,11 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error { } 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 errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } @@ -545,11 +590,14 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error { res = append(res, directoryInfoFromNode(node)) } - return c.JSON(deleted) + return c.JSON(res) } else { 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 errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } 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" // @Router /accounts/{accountID}/directories/{directoryID}/content [post] func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error { - acc := account.CurrentAccount(c) - if acc == nil { + scope, ok := scopeFromCtx(c) + if !ok { return c.SendStatus(fiber.StatusUnauthorized) } @@ -602,7 +650,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error { } 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 { return httperr.Internal(err) } @@ -611,7 +659,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error { } // 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 errors.Is(err, virtualfs.ErrUnsupportedOperation) { 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) { 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) } @@ -712,3 +763,28 @@ func decodeListChildrenCursor(s string) (*decodedListChildrenCursor, error) { 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) +} diff --git a/apps/backend/internal/catalog/file.go b/apps/backend/internal/catalog/file.go index 9314eee..b32d99d 100644 --- a/apps/backend/internal/catalog/file.go +++ b/apps/backend/internal/catalog/file.go @@ -5,8 +5,8 @@ import ( "strings" "time" - "github.com/get-drexa/drexa/internal/account" "github.com/get-drexa/drexa/internal/httperr" + "github.com/get-drexa/drexa/internal/sharing" "github.com/get-drexa/drexa/internal/virtualfs" "github.com/gofiber/fiber/v2" ) @@ -39,13 +39,13 @@ func mustCurrentFileNode(c *fiber.Ctx) *virtualfs.Node { } func (h *HTTPHandler) currentFileMiddleware(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scope, ok := scopeFromCtx(c) + if !ok { return c.SendStatus(fiber.StatusUnauthorized) } 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 errors.Is(err, virtualfs.ErrNodeNotFound) { return c.SendStatus(fiber.StatusNotFound) @@ -100,9 +100,16 @@ func (h *HTTPHandler) fetchFile(c *fiber.Ctx) error { // @Router /accounts/{accountID}/files/{fileID}/content [get] func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error { 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 errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } if errors.Is(err, virtualfs.ErrUnsupportedOperation) { return c.SendStatus(fiber.StatusNotFound) } @@ -143,6 +150,10 @@ func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error { // @Router /accounts/{accountID}/files/{fileID} [patch] func (h *HTTPHandler) patchFile(c *fiber.Ctx) error { node := mustCurrentFileNode(c) + scope, ok := scopeFromCtx(c) + if !ok { + return c.SendStatus(fiber.StatusUnauthorized) + } patch := new(patchFileRequest) if err := c.BodyParser(patch); err != nil { @@ -156,11 +167,14 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error { defer tx.Rollback() 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 errors.Is(err, virtualfs.ErrNodeNotFound) { return c.SendStatus(fiber.StatusNotFound) } + if errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } } @@ -197,6 +211,10 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error { // @Router /accounts/{accountID}/files/{fileID} [delete] func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error { node := mustCurrentFileNode(c) + scope, ok := scopeFromCtx(c) + if !ok { + return c.SendStatus(fiber.StatusUnauthorized) + } tx, err := h.db.BeginTx(c.Context(), nil) if err != nil { @@ -206,11 +224,14 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error { shouldTrash := c.Query("trash") == "true" 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 errors.Is(err, virtualfs.ErrNodeNotFound) { return c.SendStatus(fiber.StatusNotFound) } + if errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } @@ -221,8 +242,11 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error { return c.JSON(fileInfoFromNode(deleted)) } else { - err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node) + err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope) if err != nil { + if errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } 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 id query string true "Comma-separated list of file IDs to delete" example:"mElnUNCm8F22,kRp2XYTq9A55" // @Param trash query bool false "Move to trash instead of permanent delete" default(false) +// @Success 200 {array} FileInfo "Trashed files (when trash=true)" // @Success 204 {string} string "Files deleted" // @Failure 400 {object} map[string]string "All items must be files" // @Failure 401 {string} string "Not authenticated" // @Router /accounts/{accountID}/files [delete] func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scope, ok := scopeFromCtx(c) + if !ok { return c.SendStatus(fiber.StatusUnauthorized) } @@ -271,7 +296,7 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error { } 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 { return httperr.Internal(err) } @@ -281,8 +306,11 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error { } 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 errors.Is(err, virtualfs.ErrAccessDenied) { + return c.SendStatus(fiber.StatusNotFound) + } return httperr.Internal(err) } @@ -298,11 +326,14 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error { return c.JSON(res) } else { - err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes) + err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes, scope) if err != nil { if errors.Is(err, virtualfs.ErrUnsupportedOperation) { 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) } @@ -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) +} diff --git a/apps/backend/internal/catalog/http.go b/apps/backend/internal/catalog/http.go index b54c778..a13e8ec 100644 --- a/apps/backend/internal/catalog/http.go +++ b/apps/backend/internal/catalog/http.go @@ -1,14 +1,17 @@ package catalog import ( + "github.com/get-drexa/drexa/internal/reqctx" + "github.com/get-drexa/drexa/internal/sharing" "github.com/get-drexa/drexa/internal/virtualfs" "github.com/gofiber/fiber/v2" "github.com/uptrace/bun" ) type HTTPHandler struct { - vfs *virtualfs.VirtualFS - db *bun.DB + sharingService *sharing.Service + vfs *virtualfs.VirtualFS + db *bun.DB } // patchFileRequest represents a file update request @@ -25,11 +28,11 @@ type patchDirectoryRequest struct { Name string `json:"name" example:"My Documents"` } -func NewHTTPHandler(vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler { - return &HTTPHandler{vfs: vfs, db: db} +func NewHTTPHandler(sharingService *sharing.Service, vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler { + 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) fg := api.Group("/files/:fileID") @@ -38,6 +41,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) { fg.Get("/content", h.downloadFile) fg.Patch("/", h.patchFile) fg.Delete("/", h.deleteFile) + fg.Get("/shares", h.listFileShares) api.Post("/directories", h.createDirectory) api.Delete("/directories", h.deleteDirectories) @@ -49,6 +53,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) { dg.Get("/content", h.listDirectory) dg.Patch("/", h.patchDirectory) dg.Delete("/", h.deleteDirectory) + dg.Get("/shares", h.listDirectoryShares) } func fileInfoFromNode(node *virtualfs.Node) FileInfo { @@ -82,3 +87,15 @@ func toDirectoryItem(node *virtualfs.Node) any { 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 +} diff --git a/apps/backend/internal/database/migrations/001_initial.up.sql b/apps/backend/internal/database/migrations/001_initial.up.sql index c4d9b0e..262c1cd 100644 --- a/apps/backend/internal/database/migrations/001_initial.up.sql +++ b/apps/backend/internal/database/migrations/001_initial.up.sql @@ -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 ( id UUID PRIMARY KEY, - node_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE, - share_token TEXT NOT NULL UNIQUE, + -- the account that owns the share + 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, created_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_node_id ON node_shares(node_id); +CREATE INDEX idx_node_shares_public_id ON node_shares(public_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 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 -- ============================================================================ @@ -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 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 FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); CREATE TRIGGER update_grants_updated_at BEFORE UPDATE ON grants - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/apps/backend/internal/drexa/server.go b/apps/backend/internal/drexa/server.go index af5b8bb..dc30c64 100644 --- a/apps/backend/internal/drexa/server.go +++ b/apps/backend/internal/drexa/server.go @@ -11,6 +11,7 @@ import ( "github.com/get-drexa/drexa/internal/catalog" "github.com/get-drexa/drexa/internal/database" "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/user" "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) } + sharingService, err := sharing.NewService(vfs) + if err != nil { + return nil, fmt.Errorf("failed to create sharing service: %w", err) + } + userService := user.NewService() authService := auth.NewService(userService, auth.TokenConfig{ Issuer: c.JWT.Issuer, @@ -113,9 +119,16 @@ func NewServer(c Config) (*Server, error) { auth.NewHTTPHandler(authService, db, cookieConfig).RegisterRoutes(api) user.NewHTTPHandler(userService, db, authMiddleware).RegisterRoutes(api) - accountRouter := account.NewHTTPHandler(accountService, authService, db, authMiddleware, cookieConfig).RegisterRoutes(api) - upload.NewHTTPHandler(uploadService, db).RegisterRoutes(accountRouter) - catalog.NewHTTPHandler(vfs, db).RegisterRoutes(accountRouter) + accountRouter := account.NewHTTPHandler(accountService, authService, vfs, db, authMiddleware, cookieConfig).RegisterRoutes(api) + upload.NewHTTPHandler(uploadService, db).RegisterRoutes(accountRouter.VFSRouter()) + + 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{ config: c, diff --git a/apps/backend/internal/reqctx/reqctx.go b/apps/backend/internal/reqctx/reqctx.go index c29cc7b..e386a8b 100644 --- a/apps/backend/internal/reqctx/reqctx.go +++ b/apps/backend/internal/reqctx/reqctx.go @@ -7,6 +7,8 @@ import ( ) const authenticatedUserKey = "authenticatedUser" +const vfsAccessScope = "vfsAccessScope" +const currentAccountKey = "currentAccount" 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. -func SetAuthenticatedUser(c *fiber.Ctx, user interface{}) { +func SetAuthenticatedUser(c *fiber.Ctx, user any) { 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) +} diff --git a/apps/backend/internal/sharing/http.go b/apps/backend/internal/sharing/http.go new file mode 100644 index 0000000..08cbafc --- /dev/null +++ b/apps/backend/internal/sharing/http.go @@ -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 +} diff --git a/apps/backend/internal/sharing/service.go b/apps/backend/internal/sharing/service.go new file mode 100644 index 0000000..919e873 --- /dev/null +++ b/apps/backend/internal/sharing/service.go @@ -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}) +} diff --git a/apps/backend/internal/sharing/share.go b/apps/backend/internal/sharing/share.go new file mode 100644 index 0000000..a74cac7 --- /dev/null +++ b/apps/backend/internal/sharing/share.go @@ -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() +} diff --git a/apps/backend/internal/upload/err.go b/apps/backend/internal/upload/err.go index 1d97ff1..5e811c7 100644 --- a/apps/backend/internal/upload/err.go +++ b/apps/backend/internal/upload/err.go @@ -7,4 +7,5 @@ var ( ErrParentNotDirectory = errors.New("parent is not a directory") ErrConflict = errors.New("node conflict") ErrContentNotUploaded = errors.New("content has not been uploaded") + ErrUnauthorized = errors.New("unauthorized") ) diff --git a/apps/backend/internal/upload/http.go b/apps/backend/internal/upload/http.go index 1618083..4d22a26 100644 --- a/apps/backend/internal/upload/http.go +++ b/apps/backend/internal/upload/http.go @@ -4,8 +4,9 @@ import ( "errors" "fmt" - "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/virtualfs" "github.com/gofiber/fiber/v2" "github.com/uptrace/bun" ) @@ -35,7 +36,7 @@ func NewHTTPHandler(s *Service, db *bun.DB) *HTTPHandler { 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.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" // @Router /accounts/{accountID}/uploads [post] func (h *HTTPHandler) Create(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scopeAny := reqctx.VFSAccessScope(c) + scope, ok := scopeAny.(*virtualfs.Scope) + if !ok || scope == nil { 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"}) } - 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, Name: req.Name, - }) + }, scope) 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) { 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" // @Router /accounts/{accountID}/uploads/{uploadID}/content [put] func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scopeAny := reqctx.VFSAccessScope(c) + scope, ok := scopeAny.(*virtualfs.Scope) + if !ok || scope == nil { return c.SendStatus(fiber.StatusUnauthorized) } 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() 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) { 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" // @Router /accounts/{accountID}/uploads/{uploadID} [patch] func (h *HTTPHandler) Update(c *fiber.Ctx) error { - account := account.CurrentAccount(c) - if account == nil { + scopeAny := reqctx.VFSAccessScope(c) + scope, ok := scopeAny.(*virtualfs.Scope) + if !ok || scope == nil { return c.SendStatus(fiber.StatusUnauthorized) } @@ -153,8 +169,14 @@ func (h *HTTPHandler) Update(c *fiber.Ctx) error { } 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 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) { return c.SendStatus(fiber.StatusNotFound) } diff --git a/apps/backend/internal/upload/service.go b/apps/backend/internal/upload/service.go index aace9c3..17ced5f 100644 --- a/apps/backend/internal/upload/service.go +++ b/apps/backend/internal/upload/service.go @@ -10,7 +10,6 @@ import ( "github.com/get-drexa/drexa/internal/blob" "github.com/get-drexa/drexa/internal/virtualfs" - "github.com/google/uuid" "github.com/uptrace/bun" ) @@ -35,8 +34,12 @@ type CreateUploadOptions struct { Name string } -func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateUploadOptions) (*Upload, error) { - parentNode, err := s.vfs.FindNodeByPublicID(ctx, db, accountID, opts.ParentID) +func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, opts CreateUploadOptions, scope *virtualfs.Scope) (*Upload, error) { + if scope == nil { + return nil, ErrUnauthorized + } + + parentNode, err := s.vfs.FindNodeByPublicID(ctx, db, opts.ParentID, scope) if err != nil { if errors.Is(err, virtualfs.ErrNodeNotFound) { return nil, ErrNotFound @@ -48,10 +51,10 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U 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, Name: opts.Name, - }) + }, scope) if err != nil { if errors.Is(err, virtualfs.ErrNodeConflict) { return nil, ErrConflict @@ -65,7 +68,7 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U Duration: 1 * time.Hour, }) if err != nil { - _ = s.vfs.PermanentlyDeleteNode(ctx, db, node) + _ = s.vfs.PermanentlyDeleteNode(ctx, db, node, scope) return nil, err } } else { @@ -84,7 +87,7 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U 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) n, ok := s.pendingUploads.Load(uploadID) if !ok { @@ -96,11 +99,15 @@ func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid. return ErrNotFound } - if upload.TargetNode.AccountID != accountID { + if scope == nil { + return ErrUnauthorized + } + + if upload.TargetNode.AccountID != scope.AccountID { 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 { return err } @@ -110,7 +117,7 @@ func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid. 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) if !ok { return nil, ErrNotFound @@ -121,7 +128,11 @@ func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid return nil, ErrNotFound } - if upload.TargetNode.AccountID != accountID { + if scope == nil { + return nil, ErrUnauthorized + } + + if upload.TargetNode.AccountID != scope.AccountID { return nil, ErrNotFound } @@ -129,7 +140,7 @@ func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid 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 errors.Is(err, blob.ErrNotFound) { return nil, ErrContentNotUploaded diff --git a/apps/backend/internal/virtualfs/err.go b/apps/backend/internal/virtualfs/err.go index 5f4b6e0..4e5194f 100644 --- a/apps/backend/internal/virtualfs/err.go +++ b/apps/backend/internal/virtualfs/err.go @@ -6,6 +6,7 @@ var ( ErrNodeNotFound = errors.New("node not found") ErrNodeConflict = errors.New("node conflict") ErrUnsupportedOperation = errors.New("unsupported operation") + ErrAccessDenied = errors.New("access denied") ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field") ErrCursorMismatchedDirection = errors.New("cursor mismatched direction") ) diff --git a/apps/backend/internal/virtualfs/scope.go b/apps/backend/internal/virtualfs/scope.go new file mode 100644 index 0000000..3d0711d --- /dev/null +++ b/apps/backend/internal/virtualfs/scope.go @@ -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" +) diff --git a/apps/backend/internal/virtualfs/scope_access.go b/apps/backend/internal/virtualfs/scope_access.go new file mode 100644 index 0000000..34292f1 --- /dev/null +++ b/apps/backend/internal/virtualfs/scope_access.go @@ -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 +} diff --git a/apps/backend/internal/virtualfs/scoped_router.go b/apps/backend/internal/virtualfs/scoped_router.go new file mode 100644 index 0000000..42e1c1c --- /dev/null +++ b/apps/backend/internal/virtualfs/scoped_router.go @@ -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 +} diff --git a/apps/backend/internal/virtualfs/vfs.go b/apps/backend/internal/virtualfs/vfs.go index 7aa13d2..d1deaf2 100644 --- a/apps/backend/internal/virtualfs/vfs.go +++ b/apps/backend/internal/virtualfs/vfs.go @@ -44,12 +44,6 @@ type VirtualFS struct { sqid *sqids.Sqids } -type CreateNodeOptions struct { - ParentID uuid.UUID - Kind NodeKind - Name string -} - type CreateFileOptions struct { ParentID uuid.UUID Name string @@ -93,10 +87,14 @@ func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error) }, 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 err := db.NewSelect().Model(&node). - Where("account_id = ?", accountID). + Where("account_id = ?", scope.AccountID). Where("id = ?", fileID). Where("status = ?", NodeStatusReady). Where("deleted_at IS NULL"). @@ -107,11 +105,17 @@ func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, accountID, fileI } 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 } -func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accountID uuid.UUID, publicID string) (*Node, error) { - nodes, err := vfs.FindNodesByPublicID(ctx, db, accountID, []string{publicID}) +func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, publicID string, scope *Scope) (*Node, error) { + nodes, err := vfs.FindNodesByPublicID(ctx, db, []string{publicID}, scope) if err != nil { return nil, err } @@ -121,14 +125,17 @@ func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accoun 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 { return nil, nil } + if !isScopeSet(scope) { + return nil, ErrAccessDenied + } var nodes []*Node err := db.NewSelect().Model(&nodes). - Where("account_id = ?", accountID). + Where("account_id = ?", scope.AccountID). Where("public_id IN (?)", bun.In(publicIDs)). Where("status = ?", NodeStatusReady). Scan(ctx) @@ -136,7 +143,7 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accou 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) { @@ -159,11 +166,49 @@ func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, account 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. -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() { 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 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("deleted_at IS NULL") - var dir string - if opts.OrderBy != "" { - switch opts.OrderDirection { - default: - dir = "ASC" - case ListChildrenDirectionAsc: - dir = "ASC" - case ListChildrenDirectionDesc: - dir = "DESC" - } + dir := "ASC" + if opts.OrderDirection == ListChildrenDirectionDesc { + dir = "DESC" } // 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 } -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() if err != nil { return nil, err @@ -281,7 +328,7 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid node := Node{ ID: id, PublicID: pid, - AccountID: accountID, + AccountID: scope.AccountID, ParentID: opts.ParentID, Kind: NodeKindFile, Status: NodeStatusPending, @@ -306,7 +353,12 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid 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() { 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") } - _, err := db.NewUpdate().Model(node). + if _, err := db.NewUpdate().Model(node). Column(setCols...). WherePK(). - Exec(ctx) - if err != nil { + Exec(ctx); err != nil { return err } 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 { 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 } -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() if err != nil { return nil, err @@ -429,7 +494,7 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID node := &Node{ ID: id, PublicID: pid, - AccountID: accountID, + AccountID: scope.AccountID, ParentID: parentID, Kind: NodeKindDirectory, Status: NodeStatusReady, @@ -447,8 +512,8 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID return node, nil } -func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node) (*Node, error) { - deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node}) +func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (*Node, error) { + deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node}, scope) if err != nil { return nil, err } @@ -458,21 +523,32 @@ func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node 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 { return nil, nil } - deletableNodes := make([]*Node, 0, len(nodes)) - nodeIDs := make([]uuid.UUID, 0, len(nodes)) - for _, node := range nodes { + allowed, err := vfs.filterNodesByScope(ctx, db, scope, nodes) + if err != nil { + 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() { nodeIDs = append(nodeIDs, node.ID) deletableNodes = append(deletableNodes, node) } } - _, err := db.NewUpdate().Model(&deletableNodes). + _, err = db.NewUpdate().Model(&deletableNodes). Where("id IN (?)", bun.In(nodeIDs)). Where("status = ?", NodeStatusReady). Where("deleted_at IS NULL"). @@ -486,7 +562,12 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []* 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 { return ErrNodeNotFound } @@ -507,10 +588,15 @@ func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) e 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() { 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) if err != nil { @@ -557,11 +643,25 @@ func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, na 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() { 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) if err != nil { 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 // optimization where parent paths are computed only once (2 recursive queries total) // rather than computing full paths for each node individually (N queries). -func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID) (*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 { 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 - nodeIDs := make([]uuid.UUID, len(nodes)) - nodeNames := make([]string, len(nodes)) - for i, node := range nodes { + nodeIDs := make([]uuid.UUID, len(allowedNodes)) + nodeNames := make([]string, len(allowedNodes)) + for i, node := range allowedNodes { if !node.IsAccessible() { return nil, ErrNodeNotFound } @@ -629,8 +742,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, } var conflicts []*Node - err := db.NewSelect().Model(&conflicts). - Where("account_id = ?", nodes[0].AccountID). + err = db.NewSelect().Model(&conflicts). + Where("account_id = ?", allowedNodes[0].AccountID). Where("parent_id = ?", newParentID). Where("name IN (?)", bun.In(nodeNames)). Scan(ctx) @@ -643,8 +756,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, conflictID[c.ID] = struct{}{} } - movableNodes := make([]*Node, 0, len(nodes)-len(conflicts)) - for _, node := range nodes { + movableNodes := make([]*Node, 0, len(allowedNodes)-len(conflicts)) + for _, node := range allowedNodes { if _, ok := conflictID[node.ID]; !ok { 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 } @@ -701,26 +814,42 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, }, 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() { 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) } -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 { 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 { return ErrUnsupportedOperation } } - deletedIDs := make([]uuid.UUID, 0, len(nodes)) - for _, n := range nodes { + deletedIDs := make([]uuid.UUID, 0, len(allowed)) + for _, n := range allowed { err := vfs.permanentlyDeleteFileNode(ctx, db, n) if err != nil { if errors.Is(err, blob.ErrNotFound) { @@ -737,7 +866,7 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no return nil } - _, err := db.NewDelete().Model((*Node)(nil)). + _, err = db.NewDelete().Model((*Node)(nil)). Where("id IN (?)", bun.In(deletedIDs)). Exec(ctx) if err != nil { @@ -747,7 +876,13 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no 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 { case NodeKindFile: return vfs.permanentlyDeleteFileNode(ctx, db, node)