mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 12:01:17 +00:00
feat: initial sharing impl
This commit is contained in:
@@ -298,6 +298,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Trashed directories (when trash=true)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/internal_catalog.DirectoryInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"204": {
|
"204": {
|
||||||
"description": "Directories deleted",
|
"description": "Directories deleted",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -453,6 +466,16 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Trashed directory info (when trash=true)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/internal_catalog.DirectoryInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"204": {
|
"204": {
|
||||||
"description": "Directory deleted",
|
"description": "Directory deleted",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -582,7 +605,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Get all files and subdirectories within a directory",
|
"description": "Get all files and subdirectories within a directory with optional pagination and sorting",
|
||||||
"tags": [
|
"tags": [
|
||||||
"directories"
|
"directories"
|
||||||
],
|
],
|
||||||
@@ -599,23 +622,76 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Directory ID",
|
"description": "Directory ID (use 'root' for the root directory)",
|
||||||
"name": "directoryID",
|
"name": "directoryID",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Sort field: name, createdAt, or updatedAt",
|
||||||
|
"name": "orderBy",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"name",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Sort direction: asc or desc",
|
||||||
|
"name": "dir",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"asc",
|
||||||
|
"desc"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Maximum number of items to return (default: 100, min: 1)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Cursor for pagination (base64-encoded cursor from previous response)",
|
||||||
|
"name": "cursor",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Array of FileInfo and DirectoryInfo objects",
|
"description": "Paginated list of FileInfo and DirectoryInfo objects",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"$ref": "#/components/schemas/internal_catalog.listDirectoryResponse"
|
||||||
"items": {}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid limit or cursor",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +811,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/accounts/{accountID}/directories/{directoryID}/shares": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Get all share links that include this directory",
|
||||||
|
"tags": [
|
||||||
|
"directories"
|
||||||
|
],
|
||||||
|
"summary": "List directory shares",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Directory ID",
|
||||||
|
"name": "directoryID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Array of shares",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/github_com_get-drexa_drexa_internal_sharing.Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Directory not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/accounts/{accountID}/files": {
|
"/accounts/{accountID}/files": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -778,6 +924,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Trashed files (when trash=true)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/internal_catalog.FileInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"204": {
|
"204": {
|
||||||
"description": "Files deleted",
|
"description": "Files deleted",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -1132,6 +1291,293 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/accounts/{accountID}/files/{fileID}/shares": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Get all share links that include this file",
|
||||||
|
"tags": [
|
||||||
|
"files"
|
||||||
|
],
|
||||||
|
"summary": "List file shares",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "File ID",
|
||||||
|
"name": "fileID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Array of shares",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/github_com_get-drexa_drexa_internal_sharing.Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "File not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/accounts/{accountID}/shares": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Create a new share link for one or more files or directories",
|
||||||
|
"tags": [
|
||||||
|
"shares"
|
||||||
|
],
|
||||||
|
"summary": "Create share",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/internal_sharing.createShareRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Share details",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Created share",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/internal_sharing.Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request or no items provided",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "One or more items not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/accounts/{accountID}/shares/{shareID}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Retrieve share link details by ID",
|
||||||
|
"tags": [
|
||||||
|
"shares"
|
||||||
|
],
|
||||||
|
"summary": "Get share",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Share ID",
|
||||||
|
"name": "shareID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Share details",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/internal_sharing.Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Share not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Delete a share link, revoking access for all users",
|
||||||
|
"tags": [
|
||||||
|
"shares"
|
||||||
|
],
|
||||||
|
"summary": "Delete share",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Share ID",
|
||||||
|
"name": "shareID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Share deleted",
|
||||||
|
"content": {
|
||||||
|
"*/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"content": {
|
||||||
|
"*/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Share not found",
|
||||||
|
"content": {
|
||||||
|
"*/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/accounts/{accountID}/uploads": {
|
"/accounts/{accountID}/uploads": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1567,6 +2013,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"github_com_get-drexa_drexa_internal_sharing.Share": {
|
||||||
|
"description": "Share link information including expiration and timestamps",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"description": "When the share was created (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T15:04:05Z"
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"description": "When the share expires, null if it never expires (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2025-01-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Unique share identifier (public ID)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"description": "When the share was last updated (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T16:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"github_com_get-drexa_drexa_internal_user.User": {
|
"github_com_get-drexa_drexa_internal_user.User": {
|
||||||
"description": "User account information",
|
"description": "User account information",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1802,6 +2274,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "My Documents"
|
"example": "My Documents"
|
||||||
},
|
},
|
||||||
|
"parentId": {
|
||||||
|
"description": "ParentID is the public ID of the directory this directory is in",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"description": "Full path from root (included when ?include=path)",
|
"description": "Full path from root (included when ?include=path)",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -1850,6 +2327,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "document.pdf"
|
"example": "document.pdf"
|
||||||
},
|
},
|
||||||
|
"parentId": {
|
||||||
|
"description": "ParentID is the public ID of the directory this file is in",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
"size": {
|
"size": {
|
||||||
"description": "File size in bytes",
|
"description": "File size in bytes",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1878,6 +2360,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_catalog.listDirectoryResponse": {
|
||||||
|
"description": "Response to a request to list the contents of a directory",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"description": "Items is the list of items in the directory, limited to the limit specified in the request",
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
},
|
||||||
|
"nextCursor": {
|
||||||
|
"description": "NextCursor is the cursor to use to get the next page of results",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_catalog.moveItemError": {
|
"internal_catalog.moveItemError": {
|
||||||
"description": "Error details for a failed item move",
|
"description": "Error details for a failed item move",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1988,6 +2485,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_sharing.Share": {
|
||||||
|
"description": "Share link information including expiration and timestamps",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"description": "When the share was created (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T15:04:05Z"
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"description": "When the share expires, null if it never expires (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2025-01-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Unique share identifier (public ID)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"description": "When the share was last updated (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T16:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"internal_sharing.createShareRequest": {
|
||||||
|
"description": "Request to create a new share link for files or directories",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expiresAt": {
|
||||||
|
"description": "Optional expiration time for the share (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2025-01-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"description": "Array of file/directory IDs to share",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
"mElnUNCm8F22",
|
||||||
|
"kRp2XYTq9A55"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_upload.Status": {
|
"internal_upload.Status": {
|
||||||
"description": "Upload status enumeration",
|
"description": "Upload status enumeration",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -2089,4 +2634,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Trashed directories (when trash=true)",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/internal_catalog.DirectoryInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"204": {
|
"204": {
|
||||||
"description": "Directories deleted",
|
"description": "Directories deleted",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -378,6 +387,12 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Trashed directory info (when trash=true)",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_catalog.DirectoryInfo"
|
||||||
|
}
|
||||||
|
},
|
||||||
"204": {
|
"204": {
|
||||||
"description": "Directory deleted",
|
"description": "Directory deleted",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -479,7 +494,7 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Get all files and subdirectories within a directory",
|
"description": "Get all files and subdirectories within a directory with optional pagination and sorting",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -498,18 +513,59 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Directory ID",
|
"description": "Directory ID (use 'root' for the root directory)",
|
||||||
"name": "directoryID",
|
"name": "directoryID",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"name",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Sort field: name, createdAt, or updatedAt",
|
||||||
|
"name": "orderBy",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"asc",
|
||||||
|
"desc"
|
||||||
|
],
|
||||||
|
"type": "string",
|
||||||
|
"description": "Sort direction: asc or desc",
|
||||||
|
"name": "dir",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of items to return (default: 100, min: 1)",
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Cursor for pagination (base64-encoded cursor from previous response)",
|
||||||
|
"name": "cursor",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Array of FileInfo and DirectoryInfo objects",
|
"description": "Paginated list of FileInfo and DirectoryInfo objects",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "array",
|
"$ref": "#/definitions/internal_catalog.listDirectoryResponse"
|
||||||
"items": {}
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid limit or cursor",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
@@ -603,6 +659,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/accounts/{accountID}/directories/{directoryID}/shares": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Get all share links that include this directory",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"directories"
|
||||||
|
],
|
||||||
|
"summary": "List directory shares",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory ID",
|
||||||
|
"name": "directoryID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Array of shares",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/github_com_get-drexa_drexa_internal_sharing.Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Directory not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/accounts/{accountID}/files": {
|
"/accounts/{accountID}/files": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -640,6 +753,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Trashed files (when trash=true)",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/internal_catalog.FileInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"204": {
|
"204": {
|
||||||
"description": "Files deleted",
|
"description": "Files deleted",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -916,6 +1038,240 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/accounts/{accountID}/files/{fileID}/shares": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Get all share links that include this file",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"files"
|
||||||
|
],
|
||||||
|
"summary": "List file shares",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "File ID",
|
||||||
|
"name": "fileID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Array of shares",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/github_com_get-drexa_drexa_internal_sharing.Share"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "File not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/accounts/{accountID}/shares": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Create a new share link for one or more files or directories",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"shares"
|
||||||
|
],
|
||||||
|
"summary": "Create share",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Share details",
|
||||||
|
"name": "request",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_sharing.createShareRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Created share",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_sharing.Share"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid request or no items provided",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "One or more items not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/accounts/{accountID}/shares/{shareID}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Retrieve share link details by ID",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"shares"
|
||||||
|
],
|
||||||
|
"summary": "Get share",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Share ID",
|
||||||
|
"name": "shareID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Share details",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/internal_sharing.Share"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Share not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"BearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Delete a share link, revoking access for all users",
|
||||||
|
"tags": [
|
||||||
|
"shares"
|
||||||
|
],
|
||||||
|
"summary": "Delete share",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Account ID",
|
||||||
|
"name": "accountID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Share ID",
|
||||||
|
"name": "shareID",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Share deleted",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Not authenticated",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Share not found",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/accounts/{accountID}/uploads": {
|
"/accounts/{accountID}/uploads": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1271,6 +1627,32 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"definitions": {
|
"definitions": {
|
||||||
|
"github_com_get-drexa_drexa_internal_sharing.Share": {
|
||||||
|
"description": "Share link information including expiration and timestamps",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"description": "When the share was created (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T15:04:05Z"
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"description": "When the share expires, null if it never expires (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2025-01-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Unique share identifier (public ID)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"description": "When the share was last updated (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T16:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"github_com_get-drexa_drexa_internal_user.User": {
|
"github_com_get-drexa_drexa_internal_user.User": {
|
||||||
"description": "User account information",
|
"description": "User account information",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1506,6 +1888,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "My Documents"
|
"example": "My Documents"
|
||||||
},
|
},
|
||||||
|
"parentId": {
|
||||||
|
"description": "ParentID is the public ID of the directory this directory is in",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"description": "Full path from root (included when ?include=path)",
|
"description": "Full path from root (included when ?include=path)",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
@@ -1554,6 +1941,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "document.pdf"
|
"example": "document.pdf"
|
||||||
},
|
},
|
||||||
|
"parentId": {
|
||||||
|
"description": "ParentID is the public ID of the directory this file is in",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
"size": {
|
"size": {
|
||||||
"description": "File size in bytes",
|
"description": "File size in bytes",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1582,6 +1974,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_catalog.listDirectoryResponse": {
|
||||||
|
"description": "Response to a request to list the contents of a directory",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"description": "Items is the list of items in the directory, limited to the limit specified in the request",
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
},
|
||||||
|
"nextCursor": {
|
||||||
|
"description": "NextCursor is the cursor to use to get the next page of results",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_catalog.moveItemError": {
|
"internal_catalog.moveItemError": {
|
||||||
"description": "Error details for a failed item move",
|
"description": "Error details for a failed item move",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1676,6 +2083,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"internal_sharing.Share": {
|
||||||
|
"description": "Share link information including expiration and timestamps",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"description": "When the share was created (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T15:04:05Z"
|
||||||
|
},
|
||||||
|
"expiresAt": {
|
||||||
|
"description": "When the share expires, null if it never expires (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2025-01-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Unique share identifier (public ID)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "kRp2XYTq9A55"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"description": "When the share was last updated (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2024-12-13T16:30:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"internal_sharing.createShareRequest": {
|
||||||
|
"description": "Request to create a new share link for files or directories",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"expiresAt": {
|
||||||
|
"description": "Optional expiration time for the share (ISO 8601)",
|
||||||
|
"type": "string",
|
||||||
|
"example": "2025-01-15T00:00:00Z"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"description": "Array of file/directory IDs to share",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": [
|
||||||
|
"mElnUNCm8F22",
|
||||||
|
"kRp2XYTq9A55"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"internal_upload.Status": {
|
"internal_upload.Status": {
|
||||||
"description": "Upload status enumeration",
|
"description": "Upload status enumeration",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -1784,4 +2239,4 @@
|
|||||||
"in": "header"
|
"in": "header"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/get-drexa/drexa/internal/httperr"
|
"github.com/get-drexa/drexa/internal/httperr"
|
||||||
"github.com/get-drexa/drexa/internal/reqctx"
|
"github.com/get-drexa/drexa/internal/reqctx"
|
||||||
"github.com/get-drexa/drexa/internal/user"
|
"github.com/get-drexa/drexa/internal/user"
|
||||||
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
type HTTPHandler struct {
|
type HTTPHandler struct {
|
||||||
accountService *Service
|
accountService *Service
|
||||||
authService *auth.Service
|
authService *auth.Service
|
||||||
|
vfs *virtualfs.VirtualFS
|
||||||
db *bun.DB
|
db *bun.DB
|
||||||
authMiddleware fiber.Handler
|
authMiddleware fiber.Handler
|
||||||
cookieConfig auth.CookieConfig
|
cookieConfig auth.CookieConfig
|
||||||
@@ -46,17 +48,11 @@ type registerAccountResponse struct {
|
|||||||
RefreshToken string `json:"refreshToken,omitempty" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"`
|
RefreshToken string `json:"refreshToken,omitempty" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentAccountKey = "currentAccount"
|
func NewHTTPHandler(accountService *Service, authService *auth.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler, cookieConfig auth.CookieConfig) *HTTPHandler {
|
||||||
|
|
||||||
func CurrentAccount(c *fiber.Ctx) *Account {
|
|
||||||
return c.Locals(currentAccountKey).(*Account)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHTTPHandler(accountService *Service, authService *auth.Service, db *bun.DB, authMiddleware fiber.Handler, cookieConfig auth.CookieConfig) *HTTPHandler {
|
|
||||||
return &HTTPHandler{accountService: accountService, authService: authService, db: db, authMiddleware: authMiddleware, cookieConfig: cookieConfig}
|
return &HTTPHandler{accountService: accountService, authService: authService, db: db, authMiddleware: authMiddleware, cookieConfig: cookieConfig}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) fiber.Router {
|
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) *ScopedRouter {
|
||||||
api.Get("/accounts", h.authMiddleware, h.listAccounts)
|
api.Get("/accounts", h.authMiddleware, h.listAccounts)
|
||||||
api.Post("/accounts", h.registerAccount)
|
api.Post("/accounts", h.registerAccount)
|
||||||
|
|
||||||
@@ -66,7 +62,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) fiber.Router {
|
|||||||
|
|
||||||
account.Get("/", h.getAccount)
|
account.Get("/", h.getAccount)
|
||||||
|
|
||||||
return account
|
return &ScopedRouter{virtualfs.ScopedRouter{account}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error {
|
func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error {
|
||||||
@@ -85,7 +81,22 @@ func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error {
|
|||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals(currentAccountKey, account)
|
root, err := h.vfs.FindRootDirectory(c.Context(), h.db, account.ID)
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := &virtualfs.Scope{
|
||||||
|
AccountID: account.ID,
|
||||||
|
RootNodeID: root.ID,
|
||||||
|
AllowedOps: virtualfs.AllAllowedOps,
|
||||||
|
AllowedNodes: nil,
|
||||||
|
ActorKind: virtualfs.ScopeActorAccount,
|
||||||
|
ActorID: u.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqctx.SetVFSAccessScope(c, scope)
|
||||||
|
reqctx.SetCurrentAccount(c, account)
|
||||||
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
@@ -120,8 +131,8 @@ func (h *HTTPHandler) listAccounts(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {string} string "Account not found"
|
// @Failure 404 {string} string "Account not found"
|
||||||
// @Router /accounts/{accountID} [get]
|
// @Router /accounts/{accountID} [get]
|
||||||
func (h *HTTPHandler) getAccount(c *fiber.Ctx) error {
|
func (h *HTTPHandler) getAccount(c *fiber.Ctx) error {
|
||||||
account := CurrentAccount(c)
|
account, ok := reqctx.CurrentAccount(c).(*Account)
|
||||||
if account == nil {
|
if !ok || account == nil {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
return c.JSON(account)
|
return c.JSON(account)
|
||||||
|
|||||||
21
apps/backend/internal/account/scoped_router.go
Normal file
21
apps/backend/internal/account/scoped_router.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import "github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
|
|
||||||
|
// ScopedRouter is a router with auth + account middleware applied.
|
||||||
|
// Routes registered on this router have access to:
|
||||||
|
// - The authenticated user via reqctx.AuthenticatedUser()
|
||||||
|
// - The current account via reqctx.CurrentAccount()
|
||||||
|
// - The VFS scope via reqctx.VFSAccessScope()
|
||||||
|
//
|
||||||
|
// This embeds virtualfs.ScopedRouter, so it can be passed to functions
|
||||||
|
// that only require VFS scope by calling VFSRouter().
|
||||||
|
type ScopedRouter struct {
|
||||||
|
virtualfs.ScopedRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// VFSRouter returns the embedded virtualfs.ScopedRouter for use with
|
||||||
|
// functions that only require VFS scope access.
|
||||||
|
func (r *ScopedRouter) VFSRouter() *virtualfs.ScopedRouter {
|
||||||
|
return &r.ScopedRouter
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ func (s *Service) Register(ctx context.Context, db bun.IDB, opts RegisterOptions
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.vfs.CreateDirectory(ctx, db, acc.ID, uuid.Nil, virtualfs.RootDirectoryName)
|
_, err = s.vfs.CreateRootDirectory(ctx, db, acc.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/get-drexa/drexa/internal/account"
|
|
||||||
"github.com/get-drexa/drexa/internal/httperr"
|
"github.com/get-drexa/drexa/internal/httperr"
|
||||||
|
"github.com/get-drexa/drexa/internal/sharing"
|
||||||
"github.com/get-drexa/drexa/internal/virtualfs"
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -99,8 +99,8 @@ type decodedListChildrenCursor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
|
func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scope, ok := scopeFromCtx(c)
|
||||||
if account == nil {
|
if !ok {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,13 +108,16 @@ func (h *HTTPHandler) currentDirectoryMiddleware(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var node *virtualfs.Node
|
var node *virtualfs.Node
|
||||||
if directoryID == "root" {
|
if directoryID == "root" {
|
||||||
n, err := h.vfs.FindRootDirectory(c.Context(), h.db, account.ID)
|
n, err := h.vfs.FindNode(c.Context(), h.db, scope.RootNodeID.String(), scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
node = n
|
node = n
|
||||||
} else {
|
} else {
|
||||||
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, directoryID)
|
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, directoryID, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
@@ -153,8 +156,8 @@ func includeParam(c *fiber.Ctx) []string {
|
|||||||
// @Failure 409 {object} map[string]string "Directory already exists"
|
// @Failure 409 {object} map[string]string "Directory already exists"
|
||||||
// @Router /accounts/{accountID}/directories [post]
|
// @Router /accounts/{accountID}/directories [post]
|
||||||
func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scope, ok := scopeFromCtx(c)
|
||||||
if account == nil {
|
if !ok {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +172,7 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
parent, err := h.vfs.FindNodeByPublicID(c.Context(), tx, account.ID, req.ParentID)
|
parent, err := h.vfs.FindNodeByPublicID(c.Context(), tx, req.ParentID, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent not found"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent not found"})
|
||||||
@@ -181,11 +184,14 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent is not a directory"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Parent is not a directory"})
|
||||||
}
|
}
|
||||||
|
|
||||||
node, err := h.vfs.CreateDirectory(c.Context(), tx, account.ID, parent.ID, req.Name)
|
node, err := h.vfs.CreateDirectory(c.Context(), tx, parent.ID, req.Name, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeConflict) {
|
if errors.Is(err, virtualfs.ErrNodeConflict) {
|
||||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Directory already exists"})
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Directory already exists"})
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +206,11 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
include := includeParam(c)
|
include := includeParam(c)
|
||||||
if slices.Contains(include, "path") {
|
if slices.Contains(include, "path") {
|
||||||
p, err := h.vfs.RealPath(c.Context(), tx, node)
|
p, err := h.vfs.RealPath(c.Context(), tx, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
i.Path = p
|
i.Path = p
|
||||||
@@ -230,6 +239,10 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
|
|||||||
// @Router /accounts/{accountID}/directories/{directoryID} [get]
|
// @Router /accounts/{accountID}/directories/{directoryID} [get]
|
||||||
func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
|
||||||
node := mustCurrentDirectoryNode(c)
|
node := mustCurrentDirectoryNode(c)
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
i := DirectoryInfo{
|
i := DirectoryInfo{
|
||||||
Kind: DirItemKindDirectory,
|
Kind: DirItemKindDirectory,
|
||||||
@@ -242,8 +255,11 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
include := includeParam(c)
|
include := includeParam(c)
|
||||||
if slices.Contains(include, "path") {
|
if slices.Contains(include, "path") {
|
||||||
p, err := h.vfs.RealPath(c.Context(), h.db, node)
|
p, err := h.vfs.RealPath(c.Context(), h.db, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
i.Path = p
|
i.Path = p
|
||||||
@@ -254,7 +270,7 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// listDirectory returns directory contents
|
// listDirectory returns directory contents
|
||||||
// @Summary List directory contents
|
// @Summary List directory contents
|
||||||
// @Description Get all files and subdirectories within a directory with optional pagination, sorting, and filtering
|
// @Description Get all files and subdirectories within a directory with optional pagination and sorting
|
||||||
// @Tags directories
|
// @Tags directories
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -271,6 +287,10 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
|
|||||||
// @Router /accounts/{accountID}/directories/{directoryID}/content [get]
|
// @Router /accounts/{accountID}/directories/{directoryID}/content [get]
|
||||||
func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
|
||||||
node := mustCurrentDirectoryNode(c)
|
node := mustCurrentDirectoryNode(c)
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
opts := virtualfs.ListChildrenOptions{}
|
opts := virtualfs.ListChildrenOptions{}
|
||||||
|
|
||||||
@@ -312,7 +332,7 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, node.AccountID, dc.nodeID)
|
n, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, dc.nodeID, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"})
|
||||||
@@ -327,11 +347,14 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
children, cursor, err := h.vfs.ListChildren(c.Context(), h.db, node, opts)
|
children, cursor, err := h.vfs.ListChildren(c.Context(), h.db, node, opts, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +415,10 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
|
|||||||
// @Router /accounts/{accountID}/directories/{directoryID} [patch]
|
// @Router /accounts/{accountID}/directories/{directoryID} [patch]
|
||||||
func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
|
||||||
node := mustCurrentDirectoryNode(c)
|
node := mustCurrentDirectoryNode(c)
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
patch := new(patchDirectoryRequest)
|
patch := new(patchDirectoryRequest)
|
||||||
if err := c.BodyParser(patch); err != nil {
|
if err := c.BodyParser(patch); err != nil {
|
||||||
@@ -405,11 +432,14 @@ func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if patch.Name != "" {
|
if patch.Name != "" {
|
||||||
err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name)
|
err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,12 +467,17 @@ func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
|
|||||||
// @Param accountID path string true "Account ID" format(uuid)
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
// @Param directoryID path string true "Directory ID"
|
// @Param directoryID path string true "Directory ID"
|
||||||
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
|
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
|
||||||
|
// @Success 200 {object} DirectoryInfo "Trashed directory info (when trash=true)"
|
||||||
// @Success 204 {string} string "Directory deleted"
|
// @Success 204 {string} string "Directory deleted"
|
||||||
// @Failure 401 {string} string "Not authenticated"
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
// @Failure 404 {string} string "Directory not found"
|
// @Failure 404 {string} string "Directory not found"
|
||||||
// @Router /accounts/{accountID}/directories/{directoryID} [delete]
|
// @Router /accounts/{accountID}/directories/{directoryID} [delete]
|
||||||
func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
|
||||||
node := mustCurrentDirectoryNode(c)
|
node := mustCurrentDirectoryNode(c)
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := h.db.BeginTx(c.Context(), nil)
|
tx, err := h.db.BeginTx(c.Context(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -452,8 +487,11 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
shouldTrash := c.Query("trash") == "true"
|
shouldTrash := c.Query("trash") == "true"
|
||||||
if shouldTrash {
|
if shouldTrash {
|
||||||
_, err := h.vfs.SoftDeleteNode(c.Context(), tx, node)
|
_, err := h.vfs.SoftDeleteNode(c.Context(), tx, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,8 +502,11 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(directoryInfoFromNode(node))
|
return c.JSON(directoryInfoFromNode(node))
|
||||||
} else {
|
} else {
|
||||||
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node)
|
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,13 +527,14 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
|
|||||||
// @Param accountID path string true "Account ID" format(uuid)
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
// @Param id query string true "Comma-separated list of directory IDs to delete" example:"kRp2XYTq9A55,xYz123AbC456"
|
// @Param id query string true "Comma-separated list of directory IDs to delete" example:"kRp2XYTq9A55,xYz123AbC456"
|
||||||
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
|
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
|
||||||
|
// @Success 200 {array} DirectoryInfo "Trashed directories (when trash=true)"
|
||||||
// @Success 204 {string} string "Directories deleted"
|
// @Success 204 {string} string "Directories deleted"
|
||||||
// @Failure 400 {object} map[string]string "All items must be directories"
|
// @Failure 400 {object} map[string]string "All items must be directories"
|
||||||
// @Failure 401 {string} string "Not authenticated"
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
// @Router /accounts/{accountID}/directories [delete]
|
// @Router /accounts/{accountID}/directories [delete]
|
||||||
func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
|
func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scope, ok := scopeFromCtx(c)
|
||||||
if account == nil {
|
if !ok {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +556,7 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids)
|
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, ids, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
@@ -530,8 +572,11 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if shouldTrash {
|
if shouldTrash {
|
||||||
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes)
|
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,11 +590,14 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
|
|||||||
res = append(res, directoryInfoFromNode(node))
|
res = append(res, directoryInfoFromNode(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(deleted)
|
return c.JSON(res)
|
||||||
} else {
|
} else {
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node)
|
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,8 +628,8 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {object} map[string]string "One or more items not found"
|
// @Failure 404 {object} map[string]string "One or more items not found"
|
||||||
// @Router /accounts/{accountID}/directories/{directoryID}/content [post]
|
// @Router /accounts/{accountID}/directories/{directoryID}/content [post]
|
||||||
func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
||||||
acc := account.CurrentAccount(c)
|
scope, ok := scopeFromCtx(c)
|
||||||
if acc == nil {
|
if !ok {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,7 +650,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, acc.ID, req.Items)
|
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, req.Items, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
@@ -611,7 +659,7 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move all nodes to the target directory
|
// Move all nodes to the target directory
|
||||||
result, err := h.vfs.MoveNodesInSameDirectory(c.Context(), tx, nodes, targetDir.ID)
|
result, err := h.vfs.MoveNodesInSameDirectory(c.Context(), tx, nodes, targetDir.ID, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "All items must be in the same directory"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "All items must be in the same directory"})
|
||||||
@@ -619,6 +667,9 @@ func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
|
|||||||
if errors.Is(err, virtualfs.ErrNodeConflict) {
|
if errors.Is(err, virtualfs.ErrNodeConflict) {
|
||||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Name conflict in target directory"})
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Name conflict in target directory"})
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,3 +763,28 @@ func decodeListChildrenCursor(s string) (*decodedListChildrenCursor, error) {
|
|||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listDirectoryShares returns all shares that include this directory
|
||||||
|
// @Summary List directory shares
|
||||||
|
// @Description Get all share links that include this directory
|
||||||
|
// @Tags directories
|
||||||
|
// @Produce json
|
||||||
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
|
// @Param directoryID path string true "Directory ID"
|
||||||
|
// @Success 200 {array} sharing.Share "Array of shares"
|
||||||
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
|
// @Failure 404 {string} string "Directory not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /accounts/{accountID}/directories/{directoryID}/shares [get]
|
||||||
|
func (h *HTTPHandler) listDirectoryShares(c *fiber.Ctx) error {
|
||||||
|
node := mustCurrentDirectoryNode(c)
|
||||||
|
|
||||||
|
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.AccountID, sharing.ListSharesOptions{
|
||||||
|
Items: []*virtualfs.Node{node},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(shares)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/get-drexa/drexa/internal/account"
|
|
||||||
"github.com/get-drexa/drexa/internal/httperr"
|
"github.com/get-drexa/drexa/internal/httperr"
|
||||||
|
"github.com/get-drexa/drexa/internal/sharing"
|
||||||
"github.com/get-drexa/drexa/internal/virtualfs"
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -39,13 +39,13 @@ func mustCurrentFileNode(c *fiber.Ctx) *virtualfs.Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) currentFileMiddleware(c *fiber.Ctx) error {
|
func (h *HTTPHandler) currentFileMiddleware(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scope, ok := scopeFromCtx(c)
|
||||||
if account == nil {
|
if !ok {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
fileID := c.Params("fileID")
|
fileID := c.Params("fileID")
|
||||||
node, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, account.ID, fileID)
|
node, err := h.vfs.FindNodeByPublicID(c.Context(), h.db, fileID, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
@@ -100,9 +100,16 @@ func (h *HTTPHandler) fetchFile(c *fiber.Ctx) error {
|
|||||||
// @Router /accounts/{accountID}/files/{fileID}/content [get]
|
// @Router /accounts/{accountID}/files/{fileID}/content [get]
|
||||||
func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error {
|
func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error {
|
||||||
node := mustCurrentFileNode(c)
|
node := mustCurrentFileNode(c)
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
content, err := h.vfs.ReadFile(c.Context(), h.db, node)
|
content, err := h.vfs.ReadFile(c.Context(), h.db, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -143,6 +150,10 @@ func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error {
|
|||||||
// @Router /accounts/{accountID}/files/{fileID} [patch]
|
// @Router /accounts/{accountID}/files/{fileID} [patch]
|
||||||
func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
|
func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
|
||||||
node := mustCurrentFileNode(c)
|
node := mustCurrentFileNode(c)
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
patch := new(patchFileRequest)
|
patch := new(patchFileRequest)
|
||||||
if err := c.BodyParser(patch); err != nil {
|
if err := c.BodyParser(patch); err != nil {
|
||||||
@@ -156,11 +167,14 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
|
|||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if patch.Name != "" {
|
if patch.Name != "" {
|
||||||
err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name)
|
err := h.vfs.RenameNode(c.Context(), tx, node, patch.Name, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +211,10 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
|
|||||||
// @Router /accounts/{accountID}/files/{fileID} [delete]
|
// @Router /accounts/{accountID}/files/{fileID} [delete]
|
||||||
func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
|
func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
|
||||||
node := mustCurrentFileNode(c)
|
node := mustCurrentFileNode(c)
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := h.db.BeginTx(c.Context(), nil)
|
tx, err := h.db.BeginTx(c.Context(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -206,11 +224,14 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
shouldTrash := c.Query("trash") == "true"
|
shouldTrash := c.Query("trash") == "true"
|
||||||
if shouldTrash {
|
if shouldTrash {
|
||||||
deleted, err := h.vfs.SoftDeleteNode(c.Context(), tx, node)
|
deleted, err := h.vfs.SoftDeleteNode(c.Context(), tx, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +242,11 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(fileInfoFromNode(deleted))
|
return c.JSON(fileInfoFromNode(deleted))
|
||||||
} else {
|
} else {
|
||||||
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node)
|
err = h.vfs.PermanentlyDeleteNode(c.Context(), tx, node, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,13 +267,14 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
|
|||||||
// @Param accountID path string true "Account ID" format(uuid)
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
// @Param id query string true "Comma-separated list of file IDs to delete" example:"mElnUNCm8F22,kRp2XYTq9A55"
|
// @Param id query string true "Comma-separated list of file IDs to delete" example:"mElnUNCm8F22,kRp2XYTq9A55"
|
||||||
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
|
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
|
||||||
|
// @Success 200 {array} FileInfo "Trashed files (when trash=true)"
|
||||||
// @Success 204 {string} string "Files deleted"
|
// @Success 204 {string} string "Files deleted"
|
||||||
// @Failure 400 {object} map[string]string "All items must be files"
|
// @Failure 400 {object} map[string]string "All items must be files"
|
||||||
// @Failure 401 {string} string "Not authenticated"
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
// @Router /accounts/{accountID}/files [delete]
|
// @Router /accounts/{accountID}/files [delete]
|
||||||
func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
|
func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scope, ok := scopeFromCtx(c)
|
||||||
if account == nil {
|
if !ok {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +296,7 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, account.ID, ids)
|
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, ids, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
@@ -281,8 +306,11 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if shouldTrash {
|
if shouldTrash {
|
||||||
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes)
|
deleted, err := h.vfs.SoftDeleteNodes(c.Context(), tx, nodes, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,11 +326,14 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.JSON(res)
|
return c.JSON(res)
|
||||||
} else {
|
} else {
|
||||||
err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes)
|
err = h.vfs.PermanentlyDeleteFiles(c.Context(), tx, nodes, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
if errors.Is(err, virtualfs.ErrUnsupportedOperation) {
|
||||||
return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be files", err)
|
return httperr.NewHTTPError(fiber.StatusBadRequest, "all items must be files", err)
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
return httperr.Internal(err)
|
return httperr.Internal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,3 +346,28 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listFileShares returns all shares that include this file
|
||||||
|
// @Summary List file shares
|
||||||
|
// @Description Get all share links that include this file
|
||||||
|
// @Tags files
|
||||||
|
// @Produce json
|
||||||
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
|
// @Param fileID path string true "File ID"
|
||||||
|
// @Success 200 {array} sharing.Share "Array of shares"
|
||||||
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
|
// @Failure 404 {string} string "File not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /accounts/{accountID}/files/{fileID}/shares [get]
|
||||||
|
func (h *HTTPHandler) listFileShares(c *fiber.Ctx) error {
|
||||||
|
node := mustCurrentFileNode(c)
|
||||||
|
|
||||||
|
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.AccountID, sharing.ListSharesOptions{
|
||||||
|
Items: []*virtualfs.Node{node},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(shares)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package catalog
|
package catalog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/get-drexa/drexa/internal/reqctx"
|
||||||
|
"github.com/get-drexa/drexa/internal/sharing"
|
||||||
"github.com/get-drexa/drexa/internal/virtualfs"
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTTPHandler struct {
|
type HTTPHandler struct {
|
||||||
vfs *virtualfs.VirtualFS
|
sharingService *sharing.Service
|
||||||
db *bun.DB
|
vfs *virtualfs.VirtualFS
|
||||||
|
db *bun.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// patchFileRequest represents a file update request
|
// patchFileRequest represents a file update request
|
||||||
@@ -25,11 +28,11 @@ type patchDirectoryRequest struct {
|
|||||||
Name string `json:"name" example:"My Documents"`
|
Name string `json:"name" example:"My Documents"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPHandler(vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler {
|
func NewHTTPHandler(sharingService *sharing.Service, vfs *virtualfs.VirtualFS, db *bun.DB) *HTTPHandler {
|
||||||
return &HTTPHandler{vfs: vfs, db: db}
|
return &HTTPHandler{sharingService: sharingService, vfs: vfs, db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
|
func (h *HTTPHandler) RegisterRoutes(api *virtualfs.ScopedRouter) {
|
||||||
api.Delete("/files", h.deleteFiles)
|
api.Delete("/files", h.deleteFiles)
|
||||||
|
|
||||||
fg := api.Group("/files/:fileID")
|
fg := api.Group("/files/:fileID")
|
||||||
@@ -38,6 +41,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
|
|||||||
fg.Get("/content", h.downloadFile)
|
fg.Get("/content", h.downloadFile)
|
||||||
fg.Patch("/", h.patchFile)
|
fg.Patch("/", h.patchFile)
|
||||||
fg.Delete("/", h.deleteFile)
|
fg.Delete("/", h.deleteFile)
|
||||||
|
fg.Get("/shares", h.listFileShares)
|
||||||
|
|
||||||
api.Post("/directories", h.createDirectory)
|
api.Post("/directories", h.createDirectory)
|
||||||
api.Delete("/directories", h.deleteDirectories)
|
api.Delete("/directories", h.deleteDirectories)
|
||||||
@@ -49,6 +53,7 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
|
|||||||
dg.Get("/content", h.listDirectory)
|
dg.Get("/content", h.listDirectory)
|
||||||
dg.Patch("/", h.patchDirectory)
|
dg.Patch("/", h.patchDirectory)
|
||||||
dg.Delete("/", h.deleteDirectory)
|
dg.Delete("/", h.deleteDirectory)
|
||||||
|
dg.Get("/shares", h.listDirectoryShares)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileInfoFromNode(node *virtualfs.Node) FileInfo {
|
func fileInfoFromNode(node *virtualfs.Node) FileInfo {
|
||||||
@@ -82,3 +87,15 @@ func toDirectoryItem(node *virtualfs.Node) any {
|
|||||||
return fileInfoFromNode(node)
|
return fileInfoFromNode(node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scopeFromCtx(c *fiber.Ctx) (*virtualfs.Scope, bool) {
|
||||||
|
scopeAny := reqctx.VFSAccessScope(c)
|
||||||
|
if scopeAny == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
scope, ok := scopeAny.(*virtualfs.Scope)
|
||||||
|
if !ok || scope == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return scope, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,17 +79,51 @@ CREATE INDEX idx_vfs_nodes_pending ON vfs_nodes(created_at) WHERE status = 'pend
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS node_shares (
|
CREATE TABLE IF NOT EXISTS node_shares (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
node_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE,
|
-- the account that owns the share
|
||||||
share_token TEXT NOT NULL UNIQUE,
|
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||||
|
public_id TEXT NOT NULL UNIQUE, -- opaque ID for external API (no timestamp leak)
|
||||||
|
-- parent directory of the items in this share
|
||||||
|
shared_directory_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
expires_at TIMESTAMPTZ,
|
expires_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_node_shares_share_token ON node_shares(share_token);
|
CREATE INDEX idx_node_shares_public_id ON node_shares(public_id);
|
||||||
CREATE INDEX idx_node_shares_node_id ON node_shares(node_id);
|
CREATE INDEX idx_node_shares_shared_directory_id ON node_shares(shared_directory_id);
|
||||||
CREATE INDEX idx_node_shares_expires_at ON node_shares(expires_at) WHERE expires_at IS NOT NULL;
|
CREATE INDEX idx_node_shares_expires_at ON node_shares(expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS share_permissions (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
share_id UUID NOT NULL REFERENCES node_shares(id) ON DELETE CASCADE,
|
||||||
|
account_id UUID REFERENCES accounts(id) ON DELETE CASCADE, -- NULL = anyone with the link
|
||||||
|
can_read BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
can_write BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
can_delete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
can_upload BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (share_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_share_permissions_share_id ON share_permissions(share_id);
|
||||||
|
CREATE INDEX idx_share_permissions_account_id ON share_permissions(account_id) WHERE account_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_share_permissions_expires_at ON share_permissions(expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS share_items (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
share_id UUID NOT NULL REFERENCES node_shares(id) ON DELETE CASCADE,
|
||||||
|
node_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (share_id, node_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_share_items_share_id ON share_items(share_id);
|
||||||
|
CREATE INDEX idx_share_items_node_id ON share_items(node_id);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Triggers for updated_at timestamps
|
-- Triggers for updated_at timestamps
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -111,8 +145,14 @@ CREATE TRIGGER update_vfs_nodes_updated_at BEFORE UPDATE ON vfs_nodes
|
|||||||
CREATE TRIGGER update_node_shares_updated_at BEFORE UPDATE ON node_shares
|
CREATE TRIGGER update_node_shares_updated_at BEFORE UPDATE ON node_shares
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_share_permissions_updated_at BEFORE UPDATE ON share_permissions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_share_items_updated_at BEFORE UPDATE ON share_items
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
|
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
CREATE TRIGGER update_grants_updated_at BEFORE UPDATE ON grants
|
CREATE TRIGGER update_grants_updated_at BEFORE UPDATE ON grants
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/get-drexa/drexa/internal/catalog"
|
"github.com/get-drexa/drexa/internal/catalog"
|
||||||
"github.com/get-drexa/drexa/internal/database"
|
"github.com/get-drexa/drexa/internal/database"
|
||||||
"github.com/get-drexa/drexa/internal/httperr"
|
"github.com/get-drexa/drexa/internal/httperr"
|
||||||
|
"github.com/get-drexa/drexa/internal/sharing"
|
||||||
"github.com/get-drexa/drexa/internal/upload"
|
"github.com/get-drexa/drexa/internal/upload"
|
||||||
"github.com/get-drexa/drexa/internal/user"
|
"github.com/get-drexa/drexa/internal/user"
|
||||||
"github.com/get-drexa/drexa/internal/virtualfs"
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
@@ -93,6 +94,11 @@ func NewServer(c Config) (*Server, error) {
|
|||||||
return nil, fmt.Errorf("failed to create virtual file system: %w", err)
|
return nil, fmt.Errorf("failed to create virtual file system: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sharingService, err := sharing.NewService(vfs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create sharing service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
userService := user.NewService()
|
userService := user.NewService()
|
||||||
authService := auth.NewService(userService, auth.TokenConfig{
|
authService := auth.NewService(userService, auth.TokenConfig{
|
||||||
Issuer: c.JWT.Issuer,
|
Issuer: c.JWT.Issuer,
|
||||||
@@ -113,9 +119,16 @@ func NewServer(c Config) (*Server, error) {
|
|||||||
auth.NewHTTPHandler(authService, db, cookieConfig).RegisterRoutes(api)
|
auth.NewHTTPHandler(authService, db, cookieConfig).RegisterRoutes(api)
|
||||||
user.NewHTTPHandler(userService, db, authMiddleware).RegisterRoutes(api)
|
user.NewHTTPHandler(userService, db, authMiddleware).RegisterRoutes(api)
|
||||||
|
|
||||||
accountRouter := account.NewHTTPHandler(accountService, authService, db, authMiddleware, cookieConfig).RegisterRoutes(api)
|
accountRouter := account.NewHTTPHandler(accountService, authService, vfs, db, authMiddleware, cookieConfig).RegisterRoutes(api)
|
||||||
upload.NewHTTPHandler(uploadService, db).RegisterRoutes(accountRouter)
|
upload.NewHTTPHandler(uploadService, db).RegisterRoutes(accountRouter.VFSRouter())
|
||||||
catalog.NewHTTPHandler(vfs, db).RegisterRoutes(accountRouter)
|
|
||||||
|
shareHTTP := sharing.NewHTTPHandler(sharingService, accountService, vfs, db, authMiddleware)
|
||||||
|
shareRoutes := shareHTTP.RegisterShareConsumeRoutes(api)
|
||||||
|
shareHTTP.RegisterShareManagementRoutes(accountRouter)
|
||||||
|
|
||||||
|
catalogHTTP := catalog.NewHTTPHandler(sharingService, vfs, db)
|
||||||
|
catalogHTTP.RegisterRoutes(accountRouter.VFSRouter())
|
||||||
|
catalogHTTP.RegisterRoutes(shareRoutes)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
config: c,
|
config: c,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const authenticatedUserKey = "authenticatedUser"
|
const authenticatedUserKey = "authenticatedUser"
|
||||||
|
const vfsAccessScope = "vfsAccessScope"
|
||||||
|
const currentAccountKey = "currentAccount"
|
||||||
|
|
||||||
var ErrUnauthenticatedRequest = errors.New("unauthenticated request")
|
var ErrUnauthenticatedRequest = errors.New("unauthenticated request")
|
||||||
|
|
||||||
@@ -18,6 +20,26 @@ func AuthenticatedUser(c *fiber.Ctx) any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetAuthenticatedUser sets the authenticated user in the fiber context.
|
// SetAuthenticatedUser sets the authenticated user in the fiber context.
|
||||||
func SetAuthenticatedUser(c *fiber.Ctx, user interface{}) {
|
func SetAuthenticatedUser(c *fiber.Ctx, user any) {
|
||||||
c.Locals(authenticatedUserKey, user)
|
c.Locals(authenticatedUserKey, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCurrentAccount sets the current account in the fiber context.
|
||||||
|
func SetCurrentAccount(c *fiber.Ctx, account any) {
|
||||||
|
c.Locals(currentAccountKey, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVFSAccessScope sets the VFS access scope in the fiber context.
|
||||||
|
func SetVFSAccessScope(c *fiber.Ctx, scope any) {
|
||||||
|
c.Locals(vfsAccessScope, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentAccount returns the current account from the given fiber context.
|
||||||
|
func CurrentAccount(c *fiber.Ctx) any {
|
||||||
|
return c.Locals(currentAccountKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VFSAccessScope returns the VFS access scope from the given fiber context.
|
||||||
|
func VFSAccessScope(c *fiber.Ctx) any {
|
||||||
|
return c.Locals(vfsAccessScope)
|
||||||
|
}
|
||||||
|
|||||||
265
apps/backend/internal/sharing/http.go
Normal file
265
apps/backend/internal/sharing/http.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package sharing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/get-drexa/drexa/internal/account"
|
||||||
|
"github.com/get-drexa/drexa/internal/httperr"
|
||||||
|
"github.com/get-drexa/drexa/internal/reqctx"
|
||||||
|
"github.com/get-drexa/drexa/internal/user"
|
||||||
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPHandler struct {
|
||||||
|
sharingService *Service
|
||||||
|
accountService *account.Service
|
||||||
|
vfs *virtualfs.VirtualFS
|
||||||
|
db *bun.DB
|
||||||
|
authMiddleware fiber.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// createShareRequest represents a request to create a share link
|
||||||
|
// @Description Request to create a new share link for files or directories
|
||||||
|
type createShareRequest struct {
|
||||||
|
// Array of file/directory IDs to share
|
||||||
|
Items []string `json:"items" example:"mElnUNCm8F22,kRp2XYTq9A55"`
|
||||||
|
// Optional expiration time for the share (ISO 8601)
|
||||||
|
ExpiresAt *time.Time `json:"expiresAt" example:"2025-01-15T00:00:00Z"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPHandler(sharingService *Service, accountService *account.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler) *HTTPHandler {
|
||||||
|
return &HTTPHandler{
|
||||||
|
sharingService: sharingService,
|
||||||
|
accountService: accountService,
|
||||||
|
vfs: vfs,
|
||||||
|
db: db,
|
||||||
|
authMiddleware: authMiddleware,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) RegisterShareConsumeRoutes(r fiber.Router) *virtualfs.ScopedRouter {
|
||||||
|
g := r.Group("/shares/:shareID", h.shareMiddleware)
|
||||||
|
return &virtualfs.ScopedRouter{Router: g}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) RegisterShareManagementRoutes(api *account.ScopedRouter) {
|
||||||
|
g := api.Group("/shares")
|
||||||
|
g.Get("/:shareID", h.getShare)
|
||||||
|
g.Post("/", h.createShare)
|
||||||
|
g.Delete("/:shareID", h.deleteShare)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPHandler) shareMiddleware(c *fiber.Ctx) error {
|
||||||
|
shareID := c.Params("shareID")
|
||||||
|
|
||||||
|
share, err := h.sharingService.FindShareByPublicID(c.Context(), h.db, shareID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrShareNotFound) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a share can be public or shared to specific accounts
|
||||||
|
// if latter, the accountId query param is expected and the route should be authenticated
|
||||||
|
// then the correct account is found using the authenticated user and the accountId query param
|
||||||
|
// finally, the account scope is resolved for the share
|
||||||
|
// otherwise, consumerAccount will be nil to attempt to resolve a public scope for the share
|
||||||
|
|
||||||
|
var consumerAccount *account.Account
|
||||||
|
|
||||||
|
qAccountID := c.Query("accountId")
|
||||||
|
if qAccountID != "" {
|
||||||
|
consumerAccountID, err := uuid.Parse(qAccountID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"error": "invalid account ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
u := reqctx.AuthenticatedUser(c).(*user.User)
|
||||||
|
if u != nil {
|
||||||
|
consumerAccount, err = h.accountService.AccountByID(c.Context(), h.db, u.ID, consumerAccountID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, account.ErrAccountNotFound) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
consumerAccount, err = h.accountService.AccountByID(c.Context(), h.db, u.ID, consumerAccountID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, account.ErrAccountNotFound) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, err := h.sharingService.ResolveScopeForShare(c.Context(), h.db, consumerAccount, share)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrShareNotFound) || errors.Is(err, ErrShareExpired) || errors.Is(err, ErrShareRevoked) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope == nil {
|
||||||
|
// no scope can be resolved for the share
|
||||||
|
// the user is not authorized to access the share
|
||||||
|
// return 404 to hide the existence of the share
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqctx.SetVFSAccessScope(c, scope)
|
||||||
|
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getShare retrieves a share by its ID
|
||||||
|
// @Summary Get share
|
||||||
|
// @Description Retrieve share link details by ID
|
||||||
|
// @Tags shares
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
|
// @Param shareID path string true "Share ID"
|
||||||
|
// @Success 200 {object} Share "Share details"
|
||||||
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
|
// @Failure 404 {string} string "Share not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /accounts/{accountID}/shares/{shareID} [get]
|
||||||
|
func (h *HTTPHandler) getShare(c *fiber.Ctx) error {
|
||||||
|
shareID := c.Params("shareID")
|
||||||
|
share, err := h.sharingService.FindShareByPublicID(c.Context(), h.db, shareID)
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
return c.JSON(share)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createShare creates a new share link for files or directories
|
||||||
|
// @Summary Create share
|
||||||
|
// @Description Create a new share link for one or more files or directories
|
||||||
|
// @Tags shares
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
|
// @Param request body createShareRequest true "Share details"
|
||||||
|
// @Success 200 {object} Share "Created share"
|
||||||
|
// @Failure 400 {object} map[string]string "Invalid request or no items provided"
|
||||||
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
|
// @Failure 404 {object} map[string]string "One or more items not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /accounts/{accountID}/shares [post]
|
||||||
|
func (h *HTTPHandler) createShare(c *fiber.Ctx) error {
|
||||||
|
scope, ok := scopeFromCtx(c)
|
||||||
|
if !ok {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
acc, ok := reqctx.CurrentAccount(c).(*account.Account)
|
||||||
|
if !ok || acc == nil {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createShareRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"error": "invalid request",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"error": "expects at least one item to share",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := h.db.BeginTx(c.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
nodes, err := h.vfs.FindNodesByPublicID(c.Context(), tx, req.Items, scope)
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
if len(nodes) != len(req.Items) {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "One or more items not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := CreateShareOptions{
|
||||||
|
Items: nodes,
|
||||||
|
}
|
||||||
|
if req.ExpiresAt != nil {
|
||||||
|
opts.ExpiresAt = *req.ExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
share, err := h.sharingService.CreateShare(c.Context(), tx, acc.ID, opts)
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(share)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteShare deletes a share link
|
||||||
|
// @Summary Delete share
|
||||||
|
// @Description Delete a share link, revoking access for all users
|
||||||
|
// @Tags shares
|
||||||
|
// @Param accountID path string true "Account ID" format(uuid)
|
||||||
|
// @Param shareID path string true "Share ID"
|
||||||
|
// @Success 204 {string} string "Share deleted"
|
||||||
|
// @Failure 401 {string} string "Not authenticated"
|
||||||
|
// @Failure 404 {string} string "Share not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /accounts/{accountID}/shares/{shareID} [delete]
|
||||||
|
func (h *HTTPHandler) deleteShare(c *fiber.Ctx) error {
|
||||||
|
shareID := c.Params("shareID")
|
||||||
|
|
||||||
|
tx, err := h.db.BeginTx(c.Context(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
err = h.sharingService.DeleteShareByPublicID(c.Context(), tx, shareID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrShareNotFound) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return httperr.Internal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopeFromCtx(c *fiber.Ctx) (*virtualfs.Scope, bool) {
|
||||||
|
scopeAny := reqctx.VFSAccessScope(c)
|
||||||
|
if scopeAny == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
scope, ok := scopeAny.(*virtualfs.Scope)
|
||||||
|
if !ok || scope == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return scope, true
|
||||||
|
}
|
||||||
319
apps/backend/internal/sharing/service.go
Normal file
319
apps/backend/internal/sharing/service.go
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
package sharing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/get-drexa/drexa/internal/account"
|
||||||
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqids/sqids-go"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
sqid *sqids.Sqids
|
||||||
|
vfs *virtualfs.VirtualFS
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateShareOptions struct {
|
||||||
|
Items []*virtualfs.Node
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSharesOptions is the options for ListShares.
|
||||||
|
type ListSharesOptions struct {
|
||||||
|
// Items stores the list of nodes to filter the shares by. If nil, all shares for the account will be returned.
|
||||||
|
Items []*virtualfs.Node
|
||||||
|
|
||||||
|
// IncludesExpired includes shares that have expired in the results.
|
||||||
|
IncludesExpired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrShareNotFound = errors.New("share not found")
|
||||||
|
ErrShareExpired = errors.New("share expired")
|
||||||
|
ErrShareRevoked = errors.New("share revoked")
|
||||||
|
ErrShareNoItems = errors.New("share has no items")
|
||||||
|
ErrNoPermissions = errors.New("no permissions found")
|
||||||
|
ErrNotSameParent = errors.New("items to be shared must be in the same parent directory")
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewService(vfs *virtualfs.VirtualFS) (*Service, error) {
|
||||||
|
sqid, err := sqids.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Service{vfs: vfs, sqid: sqid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateShare creates a share record for a parent directory and its allowed items.
|
||||||
|
func (s *Service) CreateShare(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateShareOptions) (*Share, error) {
|
||||||
|
id, err := generateInternalID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pid, err := s.generatePublicID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentID *uuid.UUID
|
||||||
|
for _, item := range opts.Items {
|
||||||
|
if parentID == nil {
|
||||||
|
parentID = &item.ID
|
||||||
|
} else if item.ParentID != *parentID {
|
||||||
|
return nil, ErrNotSameParent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
sh := &Share{
|
||||||
|
ID: id,
|
||||||
|
AccountID: accountID,
|
||||||
|
PublicID: pid,
|
||||||
|
SharedDirectoryID: opts.Items[0].ID,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.ExpiresAt.IsZero() {
|
||||||
|
sh.ExpiresAt = &opts.ExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
shareItems := make([]ShareItem, len(opts.Items))
|
||||||
|
for i, item := range opts.Items {
|
||||||
|
si := &shareItems[i]
|
||||||
|
si.ID, err = generateInternalID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
si.ShareID = sh.ID
|
||||||
|
si.NodeID = item.ID
|
||||||
|
si.CreatedAt = now
|
||||||
|
si.UpdatedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
permID, err := generateInternalID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
perm := SharePermission{
|
||||||
|
ID: permID,
|
||||||
|
ShareID: sh.ID,
|
||||||
|
// public access
|
||||||
|
AccountID: nil,
|
||||||
|
CanRead: true,
|
||||||
|
CanWrite: false,
|
||||||
|
CanDelete: false,
|
||||||
|
CanUpload: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.NewInsert().Model(sh).Returning("*").Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.NewInsert().Model(&shareItems).On("CONFLICT DO NOTHING").Returning("*").Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.NewInsert().Model(&perm).Returning("*").Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindShareByPublicID loads a share by its public token.
|
||||||
|
func (s *Service) FindShareByPublicID(ctx context.Context, db bun.IDB, publicID string) (*Share, error) {
|
||||||
|
sh := new(Share)
|
||||||
|
|
||||||
|
err := db.NewSelect().Model(sh).
|
||||||
|
Where("public_id = ?", publicID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrShareNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sh, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListShares(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts ListSharesOptions) ([]Share, error) {
|
||||||
|
var shares []Share
|
||||||
|
|
||||||
|
q := db.NewSelect().Model(&shares).
|
||||||
|
Where("account_id = ?", accountID)
|
||||||
|
|
||||||
|
if !opts.IncludesExpired {
|
||||||
|
q = q.Where("expires_at IS NULL OR expires_at > NOW()")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opts.Items) > 0 {
|
||||||
|
ids := make([]uuid.UUID, 0, len(opts.Items))
|
||||||
|
for _, item := range opts.Items {
|
||||||
|
ids = append(ids, item.ID)
|
||||||
|
}
|
||||||
|
q = q.Where(
|
||||||
|
"EXISTS (SELECT 1 FROM share_items si WHERE si.share_id = share.id AND si.node_id IN (?))",
|
||||||
|
bun.In(ids),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := q.Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return make([]Share, 0), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(shares) == 0 {
|
||||||
|
return make([]Share, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return shares, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveScopeForShare validates a share and derives a VFS scope from its permissions and items.
|
||||||
|
// share_items must contain the allowed nodes (including a directory node to share its subtree).
|
||||||
|
func (s *Service) ResolveScopeForShare(ctx context.Context, db bun.IDB, consumerAccount *account.Account, share *Share) (*virtualfs.Scope, error) {
|
||||||
|
now := time.Now()
|
||||||
|
if share.ExpiresAt != nil && share.ExpiresAt.Before(now) {
|
||||||
|
return nil, ErrShareExpired
|
||||||
|
}
|
||||||
|
if share.RevokedAt != nil {
|
||||||
|
return nil, ErrShareRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
var permList []*SharePermission
|
||||||
|
|
||||||
|
err := db.NewSelect().Model(&permList).
|
||||||
|
Where("share_id = ?", share.ID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrShareNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(permList) == 0 {
|
||||||
|
return nil, ErrNoPermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicPerm *SharePermission
|
||||||
|
var accPerm *SharePermission
|
||||||
|
if len(permList) == 1 {
|
||||||
|
accPerm = permList[0]
|
||||||
|
if accPerm.AccountID == nil {
|
||||||
|
publicPerm = accPerm
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, p := range permList {
|
||||||
|
if p.AccountID != nil && consumerAccount != nil && *p.AccountID == consumerAccount.ID {
|
||||||
|
accPerm = p
|
||||||
|
} else if p.AccountID == nil {
|
||||||
|
publicPerm = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accPerm == nil && publicPerm == nil {
|
||||||
|
return nil, ErrNoPermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
var perm *SharePermission
|
||||||
|
if accPerm != nil {
|
||||||
|
perm = accPerm
|
||||||
|
} else {
|
||||||
|
perm = publicPerm
|
||||||
|
}
|
||||||
|
|
||||||
|
if perm.ExpiresAt != nil && perm.ExpiresAt.Before(now) {
|
||||||
|
return nil, ErrShareExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := &virtualfs.Scope{
|
||||||
|
AccountID: share.AccountID,
|
||||||
|
RootNodeID: share.SharedDirectoryID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if perm.AccountID == nil {
|
||||||
|
// the share is public
|
||||||
|
scope.ActorKind = virtualfs.ScopeActorShare
|
||||||
|
scope.ActorID = share.ID
|
||||||
|
} else {
|
||||||
|
scope.ActorKind = virtualfs.ScopeActorAccount
|
||||||
|
scope.ActorID = *perm.AccountID
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.AllowedOps = map[virtualfs.Operation]bool{
|
||||||
|
virtualfs.OperationRead: perm.CanRead,
|
||||||
|
virtualfs.OperationWrite: perm.CanWrite,
|
||||||
|
virtualfs.OperationDelete: perm.CanDelete,
|
||||||
|
virtualfs.OperationUpload: perm.CanUpload,
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []*ShareItem
|
||||||
|
err = db.NewSelect().Model(&items).
|
||||||
|
Where("share_id = ?", share.ID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrShareNoItems
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, ErrShareNoItems
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.AllowedNodes = make(map[uuid.UUID]struct{})
|
||||||
|
for _, item := range items {
|
||||||
|
scope.AllowedNodes[item.NodeID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scope, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteShare deletes the given share completely. It will no longer be accessible after.
|
||||||
|
func (s *Service) DeleteShareByPublicID(ctx context.Context, db bun.IDB, publicID string) error {
|
||||||
|
r, err := db.NewDelete().Model(&Share{}).Where("public_id = ?", publicID).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return ErrShareNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c, err := r.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c == 0 {
|
||||||
|
return ErrShareNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) generatePublicID() (string, error) {
|
||||||
|
var b [8]byte
|
||||||
|
_, err := rand.Read(b[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n := binary.BigEndian.Uint64(b[:])
|
||||||
|
return s.sqid.Encode([]uint64{n})
|
||||||
|
}
|
||||||
56
apps/backend/internal/sharing/share.go
Normal file
56
apps/backend/internal/sharing/share.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package sharing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Share represents a share link for files or directories
|
||||||
|
// @Description Share link information including expiration and timestamps
|
||||||
|
type Share struct {
|
||||||
|
bun.BaseModel `bun:"node_shares"`
|
||||||
|
|
||||||
|
ID uuid.UUID `bun:",pk,type:uuid" json:"-"`
|
||||||
|
AccountID uuid.UUID `bun:"account_id,notnull,type:uuid" json:"-"`
|
||||||
|
// Unique share identifier (public ID)
|
||||||
|
PublicID string `bun:"public_id,notnull" json:"id" example:"kRp2XYTq9A55"`
|
||||||
|
SharedDirectoryID uuid.UUID `bun:"shared_directory_id,notnull,type:uuid" json:"-"`
|
||||||
|
RevokedAt *time.Time `bun:"revoked_at" json:"-"`
|
||||||
|
// When the share expires, null if it never expires (ISO 8601)
|
||||||
|
ExpiresAt *time.Time `bun:"expires_at" json:"expiresAt" example:"2025-01-15T00:00:00Z"`
|
||||||
|
// When the share was created (ISO 8601)
|
||||||
|
CreatedAt time.Time `bun:"created_at,notnull" json:"createdAt" example:"2024-12-13T15:04:05Z"`
|
||||||
|
// When the share was last updated (ISO 8601)
|
||||||
|
UpdatedAt time.Time `bun:"updated_at,notnull" json:"updatedAt" example:"2024-12-13T16:30:00Z"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharePermission struct {
|
||||||
|
bun.BaseModel `bun:"share_permissions"`
|
||||||
|
|
||||||
|
ID uuid.UUID `bun:",pk,type:uuid"`
|
||||||
|
ShareID uuid.UUID `bun:"share_id,notnull,type:uuid"`
|
||||||
|
AccountID *uuid.UUID `bun:"account_id,type:uuid"`
|
||||||
|
CanRead bool `bun:"can_read,notnull"`
|
||||||
|
CanWrite bool `bun:"can_write,notnull"`
|
||||||
|
CanDelete bool `bun:"can_delete,notnull"`
|
||||||
|
CanUpload bool `bun:"can_upload,notnull"`
|
||||||
|
ExpiresAt *time.Time `bun:"expires_at"`
|
||||||
|
CreatedAt time.Time `bun:"created_at,notnull"`
|
||||||
|
UpdatedAt time.Time `bun:"updated_at,notnull"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShareItem struct {
|
||||||
|
bun.BaseModel `bun:"share_items"`
|
||||||
|
|
||||||
|
ID uuid.UUID `bun:",pk,type:uuid"`
|
||||||
|
ShareID uuid.UUID `bun:"share_id,notnull,type:uuid"`
|
||||||
|
NodeID uuid.UUID `bun:"node_id,notnull,type:uuid"`
|
||||||
|
CreatedAt time.Time `bun:"created_at,notnull"`
|
||||||
|
UpdatedAt time.Time `bun:"updated_at,notnull"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateInternalID() (uuid.UUID, error) {
|
||||||
|
return uuid.NewV7()
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ var (
|
|||||||
ErrParentNotDirectory = errors.New("parent is not a directory")
|
ErrParentNotDirectory = errors.New("parent is not a directory")
|
||||||
ErrConflict = errors.New("node conflict")
|
ErrConflict = errors.New("node conflict")
|
||||||
ErrContentNotUploaded = errors.New("content has not been uploaded")
|
ErrContentNotUploaded = errors.New("content has not been uploaded")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/get-drexa/drexa/internal/account"
|
|
||||||
"github.com/get-drexa/drexa/internal/httperr"
|
"github.com/get-drexa/drexa/internal/httperr"
|
||||||
|
"github.com/get-drexa/drexa/internal/reqctx"
|
||||||
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
@@ -35,7 +36,7 @@ func NewHTTPHandler(s *Service, db *bun.DB) *HTTPHandler {
|
|||||||
return &HTTPHandler{service: s, db: db}
|
return &HTTPHandler{service: s, db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
|
func (h *HTTPHandler) RegisterRoutes(api *virtualfs.ScopedRouter) {
|
||||||
upload := api.Group("/uploads")
|
upload := api.Group("/uploads")
|
||||||
|
|
||||||
upload.Post("/", h.Create)
|
upload.Post("/", h.Create)
|
||||||
@@ -59,8 +60,9 @@ func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
|
|||||||
// @Failure 409 {object} map[string]string "File with this name already exists"
|
// @Failure 409 {object} map[string]string "File with this name already exists"
|
||||||
// @Router /accounts/{accountID}/uploads [post]
|
// @Router /accounts/{accountID}/uploads [post]
|
||||||
func (h *HTTPHandler) Create(c *fiber.Ctx) error {
|
func (h *HTTPHandler) Create(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scopeAny := reqctx.VFSAccessScope(c)
|
||||||
if account == nil {
|
scope, ok := scopeAny.(*virtualfs.Scope)
|
||||||
|
if !ok || scope == nil {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +71,17 @@ func (h *HTTPHandler) Create(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request"})
|
||||||
}
|
}
|
||||||
|
|
||||||
upload, err := h.service.CreateUpload(c.Context(), h.db, account.ID, CreateUploadOptions{
|
upload, err := h.service.CreateUpload(c.Context(), h.db, CreateUploadOptions{
|
||||||
ParentID: req.ParentID,
|
ParentID: req.ParentID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
})
|
}, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrUnauthorized) {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -107,16 +115,23 @@ func (h *HTTPHandler) Create(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {string} string "Upload session not found"
|
// @Failure 404 {string} string "Upload session not found"
|
||||||
// @Router /accounts/{accountID}/uploads/{uploadID}/content [put]
|
// @Router /accounts/{accountID}/uploads/{uploadID}/content [put]
|
||||||
func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error {
|
func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scopeAny := reqctx.VFSAccessScope(c)
|
||||||
if account == nil {
|
scope, ok := scopeAny.(*virtualfs.Scope)
|
||||||
|
if !ok || scope == nil {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadID := c.Params("uploadID")
|
uploadID := c.Params("uploadID")
|
||||||
|
|
||||||
err := h.service.ReceiveUpload(c.Context(), h.db, account.ID, uploadID, c.Context().RequestBodyStream())
|
err := h.service.ReceiveUpload(c.Context(), h.db, uploadID, c.Context().RequestBodyStream(), scope)
|
||||||
defer c.Context().Request.CloseBodyStream()
|
defer c.Context().Request.CloseBodyStream()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrUnauthorized) {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -142,8 +157,9 @@ func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {string} string "Upload session not found"
|
// @Failure 404 {string} string "Upload session not found"
|
||||||
// @Router /accounts/{accountID}/uploads/{uploadID} [patch]
|
// @Router /accounts/{accountID}/uploads/{uploadID} [patch]
|
||||||
func (h *HTTPHandler) Update(c *fiber.Ctx) error {
|
func (h *HTTPHandler) Update(c *fiber.Ctx) error {
|
||||||
account := account.CurrentAccount(c)
|
scopeAny := reqctx.VFSAccessScope(c)
|
||||||
if account == nil {
|
scope, ok := scopeAny.(*virtualfs.Scope)
|
||||||
|
if !ok || scope == nil {
|
||||||
return c.SendStatus(fiber.StatusUnauthorized)
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +169,14 @@ func (h *HTTPHandler) Update(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.Status == StatusCompleted {
|
if req.Status == StatusCompleted {
|
||||||
upload, err := h.service.CompleteUpload(c.Context(), h.db, account.ID, c.Params("uploadID"))
|
upload, err := h.service.CompleteUpload(c.Context(), h.db, c.Params("uploadID"), scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrUnauthorized) {
|
||||||
|
return c.SendStatus(fiber.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
if errors.Is(err, virtualfs.ErrAccessDenied) {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
if errors.Is(err, ErrNotFound) {
|
if errors.Is(err, ErrNotFound) {
|
||||||
return c.SendStatus(fiber.StatusNotFound)
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/get-drexa/drexa/internal/blob"
|
"github.com/get-drexa/drexa/internal/blob"
|
||||||
"github.com/get-drexa/drexa/internal/virtualfs"
|
"github.com/get-drexa/drexa/internal/virtualfs"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,8 +34,12 @@ type CreateUploadOptions struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateUploadOptions) (*Upload, error) {
|
func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, opts CreateUploadOptions, scope *virtualfs.Scope) (*Upload, error) {
|
||||||
parentNode, err := s.vfs.FindNodeByPublicID(ctx, db, accountID, opts.ParentID)
|
if scope == nil {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
parentNode, err := s.vfs.FindNodeByPublicID(ctx, db, opts.ParentID, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
if errors.Is(err, virtualfs.ErrNodeNotFound) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -48,10 +51,10 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U
|
|||||||
return nil, ErrParentNotDirectory
|
return nil, ErrParentNotDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
node, err := s.vfs.CreateFile(ctx, db, accountID, virtualfs.CreateFileOptions{
|
node, err := s.vfs.CreateFile(ctx, db, virtualfs.CreateFileOptions{
|
||||||
ParentID: parentNode.ID,
|
ParentID: parentNode.ID,
|
||||||
Name: opts.Name,
|
Name: opts.Name,
|
||||||
})
|
}, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, virtualfs.ErrNodeConflict) {
|
if errors.Is(err, virtualfs.ErrNodeConflict) {
|
||||||
return nil, ErrConflict
|
return nil, ErrConflict
|
||||||
@@ -65,7 +68,7 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U
|
|||||||
Duration: 1 * time.Hour,
|
Duration: 1 * time.Hour,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = s.vfs.PermanentlyDeleteNode(ctx, db, node)
|
_ = s.vfs.PermanentlyDeleteNode(ctx, db, node, scope)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -84,7 +87,7 @@ func (s *Service) CreateUpload(ctx context.Context, db bun.IDB, accountID uuid.U
|
|||||||
return upload, nil
|
return upload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid.UUID, uploadID string, reader io.Reader) error {
|
func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, uploadID string, reader io.Reader, scope *virtualfs.Scope) error {
|
||||||
fmt.Printf("reader: %v\n", reader)
|
fmt.Printf("reader: %v\n", reader)
|
||||||
n, ok := s.pendingUploads.Load(uploadID)
|
n, ok := s.pendingUploads.Load(uploadID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -96,11 +99,15 @@ func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid.
|
|||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if upload.TargetNode.AccountID != accountID {
|
if scope == nil {
|
||||||
|
return ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
if upload.TargetNode.AccountID != scope.AccountID {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromReader(reader))
|
err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromReader(reader), scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -110,7 +117,7 @@ func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, accountID uuid.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid.UUID, uploadID string) (*Upload, error) {
|
func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, uploadID string, scope *virtualfs.Scope) (*Upload, error) {
|
||||||
n, ok := s.pendingUploads.Load(uploadID)
|
n, ok := s.pendingUploads.Load(uploadID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
@@ -121,7 +128,11 @@ func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
if upload.TargetNode.AccountID != accountID {
|
if scope == nil {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
if upload.TargetNode.AccountID != scope.AccountID {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +140,7 @@ func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, accountID uuid
|
|||||||
return upload, nil
|
return upload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromBlobKey(upload.TargetNode.BlobKey))
|
err := s.vfs.WriteFile(ctx, db, upload.TargetNode, virtualfs.FileContentFromBlobKey(upload.TargetNode.BlobKey), scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, blob.ErrNotFound) {
|
if errors.Is(err, blob.ErrNotFound) {
|
||||||
return nil, ErrContentNotUploaded
|
return nil, ErrContentNotUploaded
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ var (
|
|||||||
ErrNodeNotFound = errors.New("node not found")
|
ErrNodeNotFound = errors.New("node not found")
|
||||||
ErrNodeConflict = errors.New("node conflict")
|
ErrNodeConflict = errors.New("node conflict")
|
||||||
ErrUnsupportedOperation = errors.New("unsupported operation")
|
ErrUnsupportedOperation = errors.New("unsupported operation")
|
||||||
|
ErrAccessDenied = errors.New("access denied")
|
||||||
ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field")
|
ErrCursorMismatchedOrderField = errors.New("cursor mismatched order field")
|
||||||
ErrCursorMismatchedDirection = errors.New("cursor mismatched direction")
|
ErrCursorMismatchedDirection = errors.New("cursor mismatched direction")
|
||||||
)
|
)
|
||||||
|
|||||||
59
apps/backend/internal/virtualfs/scope.go
Normal file
59
apps/backend/internal/virtualfs/scope.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package virtualfs
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
|
// Scope defines the bounded view of the virtual filesystem that a caller is allowed to operate on.
|
||||||
|
// It is populated by higher layers (account/share middleware) and enforced by VFS methods.
|
||||||
|
type Scope struct {
|
||||||
|
// AccountID is the owner of the storage. It stays constant even when a share actor accesses it.
|
||||||
|
AccountID uuid.UUID
|
||||||
|
|
||||||
|
// RootNodeID is the top-most node the caller is allowed to traverse; all accesses must stay under it.
|
||||||
|
// It must be set for all VFS access operations.
|
||||||
|
RootNodeID uuid.UUID
|
||||||
|
|
||||||
|
// AllowedOps lists which operations this scope may perform (read, write, delete, etc).
|
||||||
|
AllowedOps map[Operation]bool
|
||||||
|
|
||||||
|
// AllowedNodes is an optional allowlist of node IDs permitted within RootNodeID.
|
||||||
|
// When nil or empty, the full subtree is allowed; when set, only allowlisted nodes (and descendants) are allowed.
|
||||||
|
AllowedNodes map[uuid.UUID]struct{}
|
||||||
|
|
||||||
|
// ActorKind identifies who performs the action (user vs share link) for auditing.
|
||||||
|
ActorKind ScopeActorKind
|
||||||
|
|
||||||
|
// ActorID is the identifier of the actor (user ID, share ID, etc).
|
||||||
|
ActorID uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
var AllAllowedOps = map[Operation]bool{
|
||||||
|
OperationRead: true,
|
||||||
|
OperationWrite: true,
|
||||||
|
OperationDelete: true,
|
||||||
|
OperationUpload: true,
|
||||||
|
OperationShare: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows reports whether the scope permits the given operation.
|
||||||
|
func (s *Scope) Allows(op Operation) bool {
|
||||||
|
return s != nil && s.AllowedOps[op]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation enumerates supported actions.
|
||||||
|
type Operation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OperationRead Operation = "read"
|
||||||
|
OperationWrite Operation = "write"
|
||||||
|
OperationDelete Operation = "delete"
|
||||||
|
OperationUpload Operation = "upload"
|
||||||
|
OperationShare Operation = "share"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScopeActorKind labels the type of actor behind the request.
|
||||||
|
type ScopeActorKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopeActorAccount ScopeActorKind = "account"
|
||||||
|
ScopeActorShare ScopeActorKind = "share"
|
||||||
|
)
|
||||||
201
apps/backend/internal/virtualfs/scope_access.go
Normal file
201
apps/backend/internal/virtualfs/scope_access.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package virtualfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nodeWithinRootQuery checks if a node is within a root by traversing ancestors.
|
||||||
|
const nodeWithinRootQuery = `WITH RECURSIVE path AS (
|
||||||
|
SELECT id, parent_id
|
||||||
|
FROM vfs_nodes
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id, n.parent_id
|
||||||
|
FROM vfs_nodes n
|
||||||
|
JOIN path p ON n.id = p.parent_id
|
||||||
|
WHERE n.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT 1 FROM path WHERE id = ? LIMIT 1;`
|
||||||
|
|
||||||
|
// nodeWithinRootOrAllowedQuery checks if a node is:
|
||||||
|
// 1. Within the root node (has root in its ancestry)
|
||||||
|
// 2. Either directly in the allowed list, OR has an ancestor in the allowed list
|
||||||
|
// This combines two checks into a single recursive query.
|
||||||
|
const nodeWithinRootOrAllowedQuery = `WITH RECURSIVE path AS (
|
||||||
|
SELECT id, parent_id
|
||||||
|
FROM vfs_nodes
|
||||||
|
WHERE id = ? AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id, n.parent_id
|
||||||
|
FROM vfs_nodes n
|
||||||
|
JOIN path p ON n.id = p.parent_id
|
||||||
|
WHERE n.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
EXISTS(SELECT 1 FROM path WHERE id = ?) AS within_root,
|
||||||
|
EXISTS(SELECT 1 FROM path WHERE id IN (?)) AS within_allowed;`
|
||||||
|
|
||||||
|
// filterNodesWithAllowlistQuery filters multiple nodes in a single query.
|
||||||
|
// It returns node IDs that are within the root AND have an ancestor (or self) in the allowed list.
|
||||||
|
const filterNodesWithAllowlistQuery = `WITH RECURSIVE node_paths AS (
|
||||||
|
-- Start from all input nodes
|
||||||
|
SELECT id AS original_id, id, parent_id
|
||||||
|
FROM vfs_nodes
|
||||||
|
WHERE id IN (?) AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
-- Traverse up to ancestors
|
||||||
|
SELECT np.original_id, n.id, n.parent_id
|
||||||
|
FROM vfs_nodes n
|
||||||
|
JOIN node_paths np ON n.id = np.parent_id
|
||||||
|
WHERE n.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT DISTINCT original_id
|
||||||
|
FROM node_paths
|
||||||
|
WHERE original_id IN (
|
||||||
|
-- Nodes that have root in their path
|
||||||
|
SELECT original_id FROM node_paths WHERE id = ?
|
||||||
|
)
|
||||||
|
AND original_id IN (
|
||||||
|
-- Nodes that have an allowed node in their path
|
||||||
|
SELECT original_id FROM node_paths WHERE id IN (?)
|
||||||
|
);`
|
||||||
|
|
||||||
|
// filterNodesWithinRootQuery filters multiple nodes that are within the root (no allowlist).
|
||||||
|
const filterNodesWithinRootQuery = `WITH RECURSIVE node_paths AS (
|
||||||
|
SELECT id AS original_id, id, parent_id
|
||||||
|
FROM vfs_nodes
|
||||||
|
WHERE id IN (?) AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT np.original_id, n.id, n.parent_id
|
||||||
|
FROM vfs_nodes n
|
||||||
|
JOIN node_paths np ON n.id = np.parent_id
|
||||||
|
WHERE n.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT DISTINCT original_id
|
||||||
|
FROM node_paths
|
||||||
|
WHERE id = ?;`
|
||||||
|
|
||||||
|
func isScopeSet(scope *Scope) bool {
|
||||||
|
return scope != nil && scope.AccountID != uuid.Nil && scope.RootNodeID != uuid.Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canAccessNode checks if the scope permits the operation and allows access to the node.
|
||||||
|
func (vfs *VirtualFS) canAccessNode(ctx context.Context, db bun.IDB, scope *Scope, op Operation, nodeID uuid.UUID) (bool, error) {
|
||||||
|
if !scope.Allows(op) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return vfs.isNodeAllowedByScope(ctx, db, scope, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (vfs *VirtualFS) isNodeWithinRoot(ctx context.Context, db bun.IDB, nodeID, rootID uuid.UUID) (bool, error) {
|
||||||
|
var exists int
|
||||||
|
err := db.NewRaw(nodeWithinRootQuery, nodeID, rootID).Scan(ctx, &exists)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNodeAllowedByScope checks if a node is accessible under the given scope.
|
||||||
|
// It verifies the node is within the root and (if an allowlist exists) within an allowed subtree.
|
||||||
|
// Optimized to use a single query when an allowlist is present.
|
||||||
|
func (vfs *VirtualFS) isNodeAllowedByScope(ctx context.Context, db bun.IDB, scope *Scope, nodeID uuid.UUID) (bool, error) {
|
||||||
|
if !isScopeSet(scope) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: no allowlist means full subtree access under root
|
||||||
|
if len(scope.AllowedNodes) == 0 {
|
||||||
|
return vfs.isNodeWithinRoot(ctx, db, nodeID, scope.RootNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick check: if nodeID is directly in the allowlist, just check root containment
|
||||||
|
if _, ok := scope.AllowedNodes[nodeID]; ok {
|
||||||
|
return vfs.isNodeWithinRoot(ctx, db, nodeID, scope.RootNodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single query: build ancestry path and check both root and allowlist membership
|
||||||
|
allowedIDs := scopeAllowedNodesList(scope)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
WithinRoot bool `bun:"within_root"`
|
||||||
|
WithinAllowed bool `bun:"within_allowed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.NewRaw(nodeWithinRootOrAllowedQuery, nodeID, scope.RootNodeID, bun.In(allowedIDs)).Scan(ctx, &result)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.WithinRoot && result.WithinAllowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterNodesByScope filters a list of nodes to only those accessible under the scope.
|
||||||
|
// Optimized to use a single batch query instead of per-node queries.
|
||||||
|
func (vfs *VirtualFS) filterNodesByScope(ctx context.Context, db bun.IDB, scope *Scope, nodes []*Node) ([]*Node, error) {
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return nodes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isScopeSet(scope) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeIDs := make([]uuid.UUID, len(nodes))
|
||||||
|
nodeMap := make(map[uuid.UUID]*Node, len(nodes))
|
||||||
|
for i, node := range nodes {
|
||||||
|
nodeIDs[i] = node.ID
|
||||||
|
nodeMap[node.ID] = node
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedIDs []uuid.UUID
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(scope.AllowedNodes) == 0 {
|
||||||
|
// No allowlist: just check nodes are within root
|
||||||
|
err = db.NewRaw(filterNodesWithinRootQuery, bun.In(nodeIDs), scope.RootNodeID).Scan(ctx, &allowedIDs)
|
||||||
|
} else {
|
||||||
|
// With allowlist: check nodes are within root AND within an allowed subtree
|
||||||
|
allowedNodesList := scopeAllowedNodesList(scope)
|
||||||
|
err = db.NewRaw(filterNodesWithAllowlistQuery, bun.In(nodeIDs), scope.RootNodeID, bun.In(allowedNodesList)).Scan(ctx, &allowedIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return make([]*Node, 0), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := make([]*Node, 0, len(allowedIDs))
|
||||||
|
for _, id := range allowedIDs {
|
||||||
|
if node, ok := nodeMap[id]; ok {
|
||||||
|
allowed = append(allowed, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeAllowedNodesList converts the AllowedNodes map to a slice for use in queries.
|
||||||
|
func scopeAllowedNodesList(scope *Scope) []uuid.UUID {
|
||||||
|
if scope == nil || len(scope.AllowedNodes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, 0, len(scope.AllowedNodes))
|
||||||
|
for id := range scope.AllowedNodes {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
13
apps/backend/internal/virtualfs/scoped_router.go
Normal file
13
apps/backend/internal/virtualfs/scoped_router.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package virtualfs
|
||||||
|
|
||||||
|
import "github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
// ScopedRouter is a router that guarantees reqctx.VFSAccessScope(c)
|
||||||
|
// returns a valid *Scope for all registered routes.
|
||||||
|
//
|
||||||
|
// This is the base type for routers that provide VFS access scope.
|
||||||
|
// More specific router types (like account.ScopedRouter) may embed this
|
||||||
|
// to provide additional guarantees.
|
||||||
|
type ScopedRouter struct {
|
||||||
|
fiber.Router
|
||||||
|
}
|
||||||
@@ -44,12 +44,6 @@ type VirtualFS struct {
|
|||||||
sqid *sqids.Sqids
|
sqid *sqids.Sqids
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateNodeOptions struct {
|
|
||||||
ParentID uuid.UUID
|
|
||||||
Kind NodeKind
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateFileOptions struct {
|
type CreateFileOptions struct {
|
||||||
ParentID uuid.UUID
|
ParentID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
@@ -93,10 +87,14 @@ func New(blobStore blob.Store, keyResolver BlobKeyResolver) (*VirtualFS, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, accountID, fileID string) (*Node, error) {
|
func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, fileID string, scope *Scope) (*Node, error) {
|
||||||
|
if !isScopeSet(scope) {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
var node Node
|
var node Node
|
||||||
err := db.NewSelect().Model(&node).
|
err := db.NewSelect().Model(&node).
|
||||||
Where("account_id = ?", accountID).
|
Where("account_id = ?", scope.AccountID).
|
||||||
Where("id = ?", fileID).
|
Where("id = ?", fileID).
|
||||||
Where("status = ?", NodeStatusReady).
|
Where("status = ?", NodeStatusReady).
|
||||||
Where("deleted_at IS NULL").
|
Where("deleted_at IS NULL").
|
||||||
@@ -107,11 +105,17 @@ func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, accountID, fileI
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
return &node, nil
|
return &node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accountID uuid.UUID, publicID string) (*Node, error) {
|
func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, publicID string, scope *Scope) (*Node, error) {
|
||||||
nodes, err := vfs.FindNodesByPublicID(ctx, db, accountID, []string{publicID})
|
nodes, err := vfs.FindNodesByPublicID(ctx, db, []string{publicID}, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -121,14 +125,17 @@ func (vfs *VirtualFS) FindNodeByPublicID(ctx context.Context, db bun.IDB, accoun
|
|||||||
return nodes[0], nil
|
return nodes[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accountID uuid.UUID, publicIDs []string) ([]*Node, error) {
|
func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, publicIDs []string, scope *Scope) ([]*Node, error) {
|
||||||
if len(publicIDs) == 0 {
|
if len(publicIDs) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if !isScopeSet(scope) {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
var nodes []*Node
|
var nodes []*Node
|
||||||
err := db.NewSelect().Model(&nodes).
|
err := db.NewSelect().Model(&nodes).
|
||||||
Where("account_id = ?", accountID).
|
Where("account_id = ?", scope.AccountID).
|
||||||
Where("public_id IN (?)", bun.In(publicIDs)).
|
Where("public_id IN (?)", bun.In(publicIDs)).
|
||||||
Where("status = ?", NodeStatusReady).
|
Where("status = ?", NodeStatusReady).
|
||||||
Scan(ctx)
|
Scan(ctx)
|
||||||
@@ -136,7 +143,7 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, accou
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes, nil
|
return vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
|
func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
|
||||||
@@ -159,11 +166,49 @@ func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, account
|
|||||||
return root, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRootDirectory creates the account root directory node.
|
||||||
|
func (vfs *VirtualFS) CreateRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
|
||||||
|
pid, err := vfs.generatePublicID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := newNodeID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
node := &Node{
|
||||||
|
ID: id,
|
||||||
|
PublicID: pid,
|
||||||
|
AccountID: accountID,
|
||||||
|
ParentID: uuid.Nil,
|
||||||
|
Kind: NodeKindDirectory,
|
||||||
|
Status: NodeStatusReady,
|
||||||
|
Name: RootDirectoryName,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.NewInsert().Model(node).Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if database.IsUniqueViolation(err) {
|
||||||
|
return nil, ErrNodeConflict
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListChildren returns the children of a directory node with optional sorting and cursor-based pagination.
|
// ListChildren returns the children of a directory node with optional sorting and cursor-based pagination.
|
||||||
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions) ([]*Node, *ListChildrenCursor, error) {
|
func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node, opts ListChildrenOptions, scope *Scope) ([]*Node, *ListChildrenCursor, error) {
|
||||||
if !node.IsAccessible() {
|
if !node.IsAccessible() {
|
||||||
return nil, nil, ErrNodeNotFound
|
return nil, nil, ErrNodeNotFound
|
||||||
}
|
}
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
var nodes []*Node
|
var nodes []*Node
|
||||||
q := db.NewSelect().Model(&nodes).
|
q := db.NewSelect().Model(&nodes).
|
||||||
@@ -172,16 +217,9 @@ func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node,
|
|||||||
Where("status = ?", NodeStatusReady).
|
Where("status = ?", NodeStatusReady).
|
||||||
Where("deleted_at IS NULL")
|
Where("deleted_at IS NULL")
|
||||||
|
|
||||||
var dir string
|
dir := "ASC"
|
||||||
if opts.OrderBy != "" {
|
if opts.OrderDirection == ListChildrenDirectionDesc {
|
||||||
switch opts.OrderDirection {
|
dir = "DESC"
|
||||||
default:
|
|
||||||
dir = "ASC"
|
|
||||||
case ListChildrenDirectionAsc:
|
|
||||||
dir = "ASC"
|
|
||||||
case ListChildrenDirectionDesc:
|
|
||||||
dir = "DESC"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply sorting with directories always first, then ID as tiebreaker.
|
// Apply sorting with directories always first, then ID as tiebreaker.
|
||||||
@@ -267,7 +305,16 @@ func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node,
|
|||||||
return nodes, c, nil
|
return nodes, c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateFileOptions) (*Node, error) {
|
func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, opts CreateFileOptions, scope *Scope) (*Node, error) {
|
||||||
|
if !isScopeSet(scope) {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationUpload, opts.ParentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
pid, err := vfs.generatePublicID()
|
pid, err := vfs.generatePublicID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -281,7 +328,7 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
|
|||||||
node := Node{
|
node := Node{
|
||||||
ID: id,
|
ID: id,
|
||||||
PublicID: pid,
|
PublicID: pid,
|
||||||
AccountID: accountID,
|
AccountID: scope.AccountID,
|
||||||
ParentID: opts.ParentID,
|
ParentID: opts.ParentID,
|
||||||
Kind: NodeKindFile,
|
Kind: NodeKindFile,
|
||||||
Status: NodeStatusPending,
|
Status: NodeStatusPending,
|
||||||
@@ -306,7 +353,12 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, accountID uuid
|
|||||||
return &node, nil
|
return &node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, content FileContent) error {
|
func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, content FileContent, scope *Scope) error {
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationUpload, node.ID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !ok {
|
||||||
|
return ErrAccessDenied
|
||||||
|
}
|
||||||
if content.Reader == nil && content.BlobKey.IsNil() {
|
if content.Reader == nil && content.BlobKey.IsNil() {
|
||||||
return blob.ErrInvalidFileContent
|
return blob.ErrInvalidFileContent
|
||||||
}
|
}
|
||||||
@@ -376,18 +428,22 @@ func (vfs *VirtualFS) WriteFile(ctx context.Context, db bun.IDB, node *Node, con
|
|||||||
setCols = append(setCols, "mime_type", "blob_key", "size", "status")
|
setCols = append(setCols, "mime_type", "blob_key", "size", "status")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.NewUpdate().Model(node).
|
if _, err := db.NewUpdate().Model(node).
|
||||||
Column(setCols...).
|
Column(setCols...).
|
||||||
WherePK().
|
WherePK().
|
||||||
Exec(ctx)
|
Exec(ctx); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node) (FileContent, error) {
|
func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (FileContent, error) {
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||||
|
return EmptyFileContent(), err
|
||||||
|
} else if !ok {
|
||||||
|
return EmptyFileContent(), ErrAccessDenied
|
||||||
|
}
|
||||||
if node.Kind != NodeKindFile {
|
if node.Kind != NodeKindFile {
|
||||||
return EmptyFileContent(), ErrUnsupportedOperation
|
return EmptyFileContent(), ErrUnsupportedOperation
|
||||||
}
|
}
|
||||||
@@ -415,7 +471,16 @@ func (vfs *VirtualFS) ReadFile(ctx context.Context, db bun.IDB, node *Node) (Fil
|
|||||||
return FileContentFromReaderWithSize(reader, node.Size), nil
|
return FileContentFromReaderWithSize(reader, node.Size), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID, parentID uuid.UUID, name string) (*Node, error) {
|
func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, parentID uuid.UUID, name string, scope *Scope) (*Node, error) {
|
||||||
|
if !isScopeSet(scope) {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, parentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
pid, err := vfs.generatePublicID()
|
pid, err := vfs.generatePublicID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -429,7 +494,7 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID
|
|||||||
node := &Node{
|
node := &Node{
|
||||||
ID: id,
|
ID: id,
|
||||||
PublicID: pid,
|
PublicID: pid,
|
||||||
AccountID: accountID,
|
AccountID: scope.AccountID,
|
||||||
ParentID: parentID,
|
ParentID: parentID,
|
||||||
Kind: NodeKindDirectory,
|
Kind: NodeKindDirectory,
|
||||||
Status: NodeStatusReady,
|
Status: NodeStatusReady,
|
||||||
@@ -447,8 +512,8 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, accountID
|
|||||||
return node, nil
|
return node, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node) (*Node, error) {
|
func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (*Node, error) {
|
||||||
deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node})
|
deleted, err := vfs.SoftDeleteNodes(ctx, db, []*Node{node}, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -458,21 +523,32 @@ func (vfs *VirtualFS) SoftDeleteNode(ctx context.Context, db bun.IDB, node *Node
|
|||||||
return deleted[0], nil
|
return deleted[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node) ([]*Node, error) {
|
func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*Node, scope *Scope) ([]*Node, error) {
|
||||||
|
if !scope.Allows(OperationDelete) {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
deletableNodes := make([]*Node, 0, len(nodes))
|
allowed, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||||
nodeIDs := make([]uuid.UUID, 0, len(nodes))
|
if err != nil {
|
||||||
for _, node := range nodes {
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return nil, ErrNodeNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
deletableNodes := make([]*Node, 0, len(allowed))
|
||||||
|
nodeIDs := make([]uuid.UUID, 0, len(allowed))
|
||||||
|
for _, node := range allowed {
|
||||||
if node.IsAccessible() {
|
if node.IsAccessible() {
|
||||||
nodeIDs = append(nodeIDs, node.ID)
|
nodeIDs = append(nodeIDs, node.ID)
|
||||||
deletableNodes = append(deletableNodes, node)
|
deletableNodes = append(deletableNodes, node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.NewUpdate().Model(&deletableNodes).
|
_, err = db.NewUpdate().Model(&deletableNodes).
|
||||||
Where("id IN (?)", bun.In(nodeIDs)).
|
Where("id IN (?)", bun.In(nodeIDs)).
|
||||||
Where("status = ?", NodeStatusReady).
|
Where("status = ?", NodeStatusReady).
|
||||||
Where("deleted_at IS NULL").
|
Where("deleted_at IS NULL").
|
||||||
@@ -486,7 +562,12 @@ func (vfs *VirtualFS) SoftDeleteNodes(ctx context.Context, db bun.IDB, nodes []*
|
|||||||
return deletableNodes, nil
|
return deletableNodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) error {
|
func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) error {
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationDelete, node.ID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !ok {
|
||||||
|
return ErrAccessDenied
|
||||||
|
}
|
||||||
if node.Status != NodeStatusReady {
|
if node.Status != NodeStatusReady {
|
||||||
return ErrNodeNotFound
|
return ErrNodeNotFound
|
||||||
}
|
}
|
||||||
@@ -507,10 +588,15 @@ func (vfs *VirtualFS) RestoreNode(ctx context.Context, db bun.IDB, node *Node) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, name string) error {
|
func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, name string, scope *Scope) error {
|
||||||
if !node.IsAccessible() {
|
if !node.IsAccessible() {
|
||||||
return ErrNodeNotFound
|
return ErrNodeNotFound
|
||||||
}
|
}
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, node.ID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !ok {
|
||||||
|
return ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
|
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -557,11 +643,25 @@ func (vfs *VirtualFS) RenameNode(ctx context.Context, db bun.IDB, node *Node, na
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, parentID uuid.UUID) error {
|
func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, parentID uuid.UUID, scope *Scope) error {
|
||||||
if !node.IsAccessible() {
|
if !node.IsAccessible() {
|
||||||
return ErrNodeNotFound
|
return ErrNodeNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check if the node is accessible
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, node.ID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !ok {
|
||||||
|
return ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the new parent is accessible
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, parentID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !ok {
|
||||||
|
return ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
|
oldKey, err := vfs.keyResolver.Resolve(ctx, db, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -612,15 +712,28 @@ func (vfs *VirtualFS) MoveNode(ctx context.Context, db bun.IDB, node *Node, pare
|
|||||||
// All nodes MUST have the same current parent directory; this constraint enables an
|
// All nodes MUST have the same current parent directory; this constraint enables an
|
||||||
// optimization where parent paths are computed only once (2 recursive queries total)
|
// optimization where parent paths are computed only once (2 recursive queries total)
|
||||||
// rather than computing full paths for each node individually (N queries).
|
// rather than computing full paths for each node individually (N queries).
|
||||||
func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID) (*MoveFilesResult, error) {
|
func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB, nodes []*Node, newParentID uuid.UUID, scope *Scope) (*MoveFilesResult, error) {
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationWrite, newParentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedNodes, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(allowedNodes) == 0 {
|
||||||
|
return nil, ErrNodeNotFound
|
||||||
|
}
|
||||||
|
|
||||||
// Validate all nodes are accessible
|
// Validate all nodes are accessible
|
||||||
nodeIDs := make([]uuid.UUID, len(nodes))
|
nodeIDs := make([]uuid.UUID, len(allowedNodes))
|
||||||
nodeNames := make([]string, len(nodes))
|
nodeNames := make([]string, len(allowedNodes))
|
||||||
for i, node := range nodes {
|
for i, node := range allowedNodes {
|
||||||
if !node.IsAccessible() {
|
if !node.IsAccessible() {
|
||||||
return nil, ErrNodeNotFound
|
return nil, ErrNodeNotFound
|
||||||
}
|
}
|
||||||
@@ -629,8 +742,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var conflicts []*Node
|
var conflicts []*Node
|
||||||
err := db.NewSelect().Model(&conflicts).
|
err = db.NewSelect().Model(&conflicts).
|
||||||
Where("account_id = ?", nodes[0].AccountID).
|
Where("account_id = ?", allowedNodes[0].AccountID).
|
||||||
Where("parent_id = ?", newParentID).
|
Where("parent_id = ?", newParentID).
|
||||||
Where("name IN (?)", bun.In(nodeNames)).
|
Where("name IN (?)", bun.In(nodeNames)).
|
||||||
Scan(ctx)
|
Scan(ctx)
|
||||||
@@ -643,8 +756,8 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
|||||||
conflictID[c.ID] = struct{}{}
|
conflictID[c.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
movableNodes := make([]*Node, 0, len(nodes)-len(conflicts))
|
movableNodes := make([]*Node, 0, len(allowedNodes)-len(conflicts))
|
||||||
for _, node := range nodes {
|
for _, node := range allowedNodes {
|
||||||
if _, ok := conflictID[node.ID]; !ok {
|
if _, ok := conflictID[node.ID]; !ok {
|
||||||
movableNodes = append(movableNodes, node)
|
movableNodes = append(movableNodes, node)
|
||||||
}
|
}
|
||||||
@@ -690,7 +803,7 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, node := range nodes {
|
for _, node := range allowedNodes {
|
||||||
node.ParentID = newParentID
|
node.ParentID = newParentID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,26 +814,42 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node) (Path, error) {
|
func (vfs *VirtualFS) RealPath(ctx context.Context, db bun.IDB, node *Node, scope *Scope) (Path, error) {
|
||||||
if !node.IsAccessible() {
|
if !node.IsAccessible() {
|
||||||
return nil, ErrNodeNotFound
|
return nil, ErrNodeNotFound
|
||||||
}
|
}
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationRead, node.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if !ok {
|
||||||
|
return nil, ErrAccessDenied
|
||||||
|
}
|
||||||
return buildNoteAbsolutePath(ctx, db, node)
|
return buildNoteAbsolutePath(ctx, db, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node) error {
|
func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, nodes []*Node, scope *Scope) error {
|
||||||
|
if !scope.Allows(OperationDelete) {
|
||||||
|
return ErrAccessDenied
|
||||||
|
}
|
||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, n := range nodes {
|
allowed, err := vfs.filterNodesByScope(ctx, db, scope, nodes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return ErrNodeNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range allowed {
|
||||||
if n.Kind != NodeKindFile {
|
if n.Kind != NodeKindFile {
|
||||||
return ErrUnsupportedOperation
|
return ErrUnsupportedOperation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deletedIDs := make([]uuid.UUID, 0, len(nodes))
|
deletedIDs := make([]uuid.UUID, 0, len(allowed))
|
||||||
for _, n := range nodes {
|
for _, n := range allowed {
|
||||||
err := vfs.permanentlyDeleteFileNode(ctx, db, n)
|
err := vfs.permanentlyDeleteFileNode(ctx, db, n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, blob.ErrNotFound) {
|
if errors.Is(err, blob.ErrNotFound) {
|
||||||
@@ -737,7 +866,7 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.NewDelete().Model((*Node)(nil)).
|
_, err = db.NewDelete().Model((*Node)(nil)).
|
||||||
Where("id IN (?)", bun.In(deletedIDs)).
|
Where("id IN (?)", bun.In(deletedIDs)).
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -747,7 +876,13 @@ func (vfs *VirtualFS) PermanentlyDeleteFiles(ctx context.Context, db bun.IDB, no
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node) error {
|
func (vfs *VirtualFS) PermanentlyDeleteNode(ctx context.Context, db bun.IDB, node *Node, scope *Scope) error {
|
||||||
|
if ok, err := vfs.canAccessNode(ctx, db, scope, OperationDelete, node.ID); err != nil {
|
||||||
|
return err
|
||||||
|
} else if !ok {
|
||||||
|
return ErrAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
switch node.Kind {
|
switch node.Kind {
|
||||||
case NodeKindFile:
|
case NodeKindFile:
|
||||||
return vfs.permanentlyDeleteFileNode(ctx, db, node)
|
return vfs.permanentlyDeleteFileNode(ctx, db, node)
|
||||||
|
|||||||
Reference in New Issue
Block a user