From 488277c1ec3316dbfdf0d98d130b69a39c5c543e Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Tue, 22 Oct 2024 23:27:54 +0200 Subject: [PATCH] Implement initial version of folders and files --- desktop/package-lock.json | 9 + desktop/package.json | 2 + desktop/src/lib/constants.ts | 1 + desktop/src/lib/id.ts | 1 + desktop/src/main/data/workspace/migrations.ts | 19 +++ desktop/src/main/data/workspace/schema.ts | 13 ++ desktop/src/main/file-manager.ts | 141 ++++++++++++++++ .../main/handlers/mutations/file-create.ts | 81 +++++++++ .../main/handlers/mutations/folder-create.ts | 69 ++++++++ desktop/src/main/handlers/mutations/index.ts | 4 + desktop/src/main/synchronizer.ts | 146 +++++++++------- .../src/operations/mutations/file-create.ts | 21 +++ .../src/operations/mutations/folder-create.ts | 20 +++ .../channel-container.tsx} | 4 +- .../chat-container.tsx} | 4 +- ...tainer-node.tsx => database-container.tsx} | 6 +- .../components/folders/folder-container.tsx | 119 +++++++++++++ .../folders/folder-create-dialog.tsx | 117 +++++++++++++ ...-container-node.tsx => page-container.tsx} | 4 +- ...ontainer-node.tsx => record-container.tsx} | 4 +- .../src/renderer/components/ui/dropzone.tsx | 45 +++++ .../workspaces/containers/container.tsx | 22 +-- .../workspaces/modals/modal-content.tsx | 20 ++- .../workspaces/sidebars/sidebar-footer.tsx | 2 +- .../workspaces/sidebars/sidebar-header.tsx | 2 +- .../sidebars/sidebar-space-item.tsx | 24 ++- desktop/src/types/files.ts | 4 + desktop/src/types/folders.ts | 29 ++++ desktop/src/types/workspaces.ts | 9 + server/package-lock.json | 35 ++++ server/package.json | 1 + server/src/api.ts | 2 + server/src/data/storage.ts | 26 +++ server/src/routes/files.ts | 159 ++++++++++++++++++ 34 files changed, 1063 insertions(+), 102 deletions(-) create mode 100644 desktop/src/main/file-manager.ts create mode 100644 desktop/src/main/handlers/mutations/file-create.ts create mode 100644 desktop/src/main/handlers/mutations/folder-create.ts create mode 100644 desktop/src/operations/mutations/file-create.ts create mode 100644 desktop/src/operations/mutations/folder-create.ts rename desktop/src/renderer/components/{chats/chat-container-node.tsx => channels/channel-container.tsx} (61%) rename desktop/src/renderer/components/{channels/channel-container-node.tsx => chats/chat-container.tsx} (60%) rename desktop/src/renderer/components/databases/{database-container-node.tsx => database-container.tsx} (85%) create mode 100644 desktop/src/renderer/components/folders/folder-container.tsx create mode 100644 desktop/src/renderer/components/folders/folder-create-dialog.tsx rename desktop/src/renderer/components/pages/{page-container-node.tsx => page-container.tsx} (75%) rename desktop/src/renderer/components/records/{record-container-node.tsx => record-container.tsx} (90%) create mode 100644 desktop/src/renderer/components/ui/dropzone.tsx create mode 100644 desktop/src/types/files.ts create mode 100644 desktop/src/types/folders.ts create mode 100644 server/src/routes/files.ts diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 9ac9532c..37462075 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -72,6 +72,7 @@ "kysely": "^0.27.4", "lodash": "^4.17.21", "lowlight": "^3.1.0", + "mime-types": "^2.1.35", "re-resizable": "^6.10.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", @@ -104,6 +105,7 @@ "@types/better-sqlite3": "^7.6.11", "@types/is-hotkey": "^0.1.10", "@types/lodash": "^4.17.12", + "@types/mime-types": "^2.1.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "@types/react-window": "^1.8.8", @@ -3710,6 +3712,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", diff --git a/desktop/package.json b/desktop/package.json index 8e0b6652..4bba65b5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -25,6 +25,7 @@ "@types/better-sqlite3": "^7.6.11", "@types/is-hotkey": "^0.1.10", "@types/lodash": "^4.17.12", + "@types/mime-types": "^2.1.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "@types/react-window": "^1.8.8", @@ -114,6 +115,7 @@ "kysely": "^0.27.4", "lodash": "^4.17.21", "lowlight": "^3.1.0", + "mime-types": "^2.1.35", "re-resizable": "^6.10.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", diff --git a/desktop/src/lib/constants.ts b/desktop/src/lib/constants.ts index d40d0137..a8cd0e5d 100644 --- a/desktop/src/lib/constants.ts +++ b/desktop/src/lib/constants.ts @@ -15,6 +15,7 @@ export const NodeTypes = { CalendarView: 'calendar_view', Field: 'field', SelectOption: 'select_option', + File: 'file', }; export const EditorNodeTypes = { diff --git a/desktop/src/lib/id.ts b/desktop/src/lib/id.ts index 1e72ec7f..f803ad62 100644 --- a/desktop/src/lib/id.ts +++ b/desktop/src/lib/id.ts @@ -43,6 +43,7 @@ export enum IdType { Emoji = 'em', Avatar = 'av', Icon = 'ic', + File = 'fi', } export const generateId = (type: IdType): string => { diff --git a/desktop/src/main/data/workspace/migrations.ts b/desktop/src/main/data/workspace/migrations.ts index c9569622..e217eded 100644 --- a/desktop/src/main/data/workspace/migrations.ts +++ b/desktop/src/main/data/workspace/migrations.ts @@ -267,6 +267,24 @@ const createNodeDeleteNameTrigger: Migration = { }, }; +const createUploadsTable: Migration = { + up: async (db) => { + await db.schema + .createTable('uploads') + .addColumn('node_id', 'text', (col) => + col.notNull().primaryKey().references('nodes.id'), + ) + .addColumn('created_at', 'text', (col) => col.notNull()) + .addColumn('updated_at', 'text') + .addColumn('progress', 'integer', (col) => col.defaultTo(0)) + .addColumn('retry_count', 'integer', (col) => col.defaultTo(0)) + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('uploads').execute(); + }, +}; + export const workspaceDatabaseMigrations: Record = { '00001_create_nodes_table': createNodesTable, '00002_create_node_names_table': createNodeNamesTable, @@ -277,4 +295,5 @@ export const workspaceDatabaseMigrations: Record = { '00007_create_node_insert_name_trigger': createNodeInsertNameTrigger, '00008_create_node_update_name_trigger': createNodeUpdateNameTrigger, '00009_create_node_delete_name_trigger': createNodeDeleteNameTrigger, + '00010_create_uploads_table': createUploadsTable, }; diff --git a/desktop/src/main/data/workspace/schema.ts b/desktop/src/main/data/workspace/schema.ts index ad91d8ee..e5158af0 100644 --- a/desktop/src/main/data/workspace/schema.ts +++ b/desktop/src/main/data/workspace/schema.ts @@ -35,7 +35,20 @@ export type SelectChange = Selectable; export type CreateChange = Insertable; export type UpdateChange = Updateable; +interface UploadTable { + node_id: ColumnType; + created_at: ColumnType; + updated_at: ColumnType; + progress: ColumnType; + retry_count: ColumnType; +} + +export type SelectUpload = Selectable; +export type CreateUpload = Insertable; +export type UpdateUpload = Updateable; + export interface WorkspaceDatabaseSchema { nodes: NodeTable; changes: ChangeTable; + uploads: UploadTable; } diff --git a/desktop/src/main/file-manager.ts b/desktop/src/main/file-manager.ts new file mode 100644 index 00000000..5767faf5 --- /dev/null +++ b/desktop/src/main/file-manager.ts @@ -0,0 +1,141 @@ +import { app } from 'electron'; +import path from 'path'; +import fs from 'fs'; +import FormData from 'form-data'; +import { ServerFileUploadResponse } from '@/types/files'; +import { WorkspaceCredentials } from '@/types/workspaces'; +import { databaseManager } from './data/database-manager'; +import { buildAxiosInstance } from '@/lib/servers'; +import { LocalNodeAttributes } from '@/types/nodes'; +import axios from 'axios'; + +class FileManager { + private readonly appPath: string; + + constructor() { + this.appPath = app.getPath('userData'); + } + + public copyFileToWorkspace( + filePath: string, + fileId: string, + fileExtension: string, + accountId: string, + workspaceId: string, + ): void { + const accountDir = path.join(this.appPath, accountId); + const workspaceDir = path.join(accountDir, 'workspaces', workspaceId); + const filesDir = path.join(workspaceDir, 'files'); + + if (!fs.existsSync(filesDir)) { + fs.mkdirSync(filesDir, { recursive: true }); + } + + const destinationFilePath = path.join( + filesDir, + `${fileId}${fileExtension}`, + ); + fs.copyFileSync(filePath, destinationFilePath); + } + + public async checkForUploads( + credentials: WorkspaceCredentials, + ): Promise { + const workspaceDatabase = await databaseManager.getWorkspaceDatabase( + credentials.userId, + ); + + const uploads = await workspaceDatabase + .selectFrom('uploads') + .selectAll() + .where('progress', '=', 0) + .execute(); + + if (uploads.length === 0) { + return; + } + + const accountDir = path.join(this.appPath, credentials.accountId); + const workspaceDir = path.join( + accountDir, + 'workspaces', + credentials.workspaceId, + ); + const filesDir = path.join(workspaceDir, 'files'); + + for (const upload of uploads) { + if (upload.retry_count >= 5) { + await workspaceDatabase + .deleteFrom('uploads') + .where('node_id', '=', upload.node_id) + .execute(); + + continue; + } + + const file = await workspaceDatabase + .selectFrom('nodes') + .selectAll() + .where('id', '=', upload.node_id) + .executeTakeFirst(); + + if (!file) { + await workspaceDatabase + .deleteFrom('uploads') + .where('node_id', '=', upload.node_id) + .execute(); + + continue; + } + + const attributes: LocalNodeAttributes = JSON.parse(file.attributes); + const filePath = path.join( + filesDir, + `${upload.node_id}${attributes.extension}`, + ); + + if (!fs.existsSync(filePath)) { + await workspaceDatabase + .deleteFrom('uploads') + .where('node_id', '=', upload.node_id) + .execute(); + + continue; + } + + const accountAxios = buildAxiosInstance( + credentials.serverDomain, + credentials.serverAttributes, + credentials.token, + ); + + try { + const { data } = await accountAxios.post( + `/v1/files/${credentials.workspaceId}/${upload.node_id}`, + ); + + const presignedUrl = data.url; + const fileStream = fs.createReadStream(filePath); + + const formData = new FormData(); + formData.append('file', fileStream); + + await axios.put(presignedUrl, formData); + + await workspaceDatabase + .deleteFrom('uploads') + .where('node_id', '=', upload.node_id) + .execute(); + } catch (error) { + console.log('error', error); + await workspaceDatabase + .updateTable('uploads') + .set((eb) => ({ retry_count: eb('retry_count', '+', 1) })) + .where('node_id', '=', upload.node_id) + .execute(); + } + } + } +} + +export const fileManager = new FileManager(); diff --git a/desktop/src/main/handlers/mutations/file-create.ts b/desktop/src/main/handlers/mutations/file-create.ts new file mode 100644 index 00000000..6e7260c3 --- /dev/null +++ b/desktop/src/main/handlers/mutations/file-create.ts @@ -0,0 +1,81 @@ +import fs from 'fs'; +import mime from 'mime-types'; +import path from 'path'; +import { databaseManager } from '@/main/data/database-manager'; +import { MutationHandler, MutationResult } from '@/operations/mutations'; +import { generateId, IdType } from '@/lib/id'; +import { FileCreateMutationInput } from '@/operations/mutations/file-create'; +import { buildCreateNode } from '@/lib/nodes'; +import { NodeTypes } from '@/lib/constants'; +import { fileManager } from '@/main/file-manager'; + +export class FileCreateMutationHandler + implements MutationHandler +{ + async handleMutation( + input: FileCreateMutationInput, + ): Promise> { + const workspaceDatabase = await databaseManager.getWorkspaceDatabase( + input.userId, + ); + + const filePath = input.filePath; + const fileName = path.basename(filePath); + const extension = path.extname(filePath); + const stats = fs.statSync(filePath); + + const size = stats.size; + const mimeType = mime.lookup(filePath); + if (mimeType === false) { + throw new Error('Invalid file type'); + } + + const id = generateId(IdType.File); + fileManager.copyFileToWorkspace( + filePath, + id, + extension, + input.accountId, + input.workspaceId, + ); + + await workspaceDatabase.transaction().execute(async (tx) => { + await tx + .insertInto('nodes') + .values( + buildCreateNode( + { + id: id, + attributes: { + type: NodeTypes.File, + parentId: input.parentId, + name: fileName, + fileName: fileName, + extension: extension, + size: size, + mimeType: mimeType, + }, + }, + input.userId, + ), + ) + .execute(); + + await tx + .insertInto('uploads') + .values({ + node_id: id, + created_at: new Date().toISOString(), + progress: 0, + retry_count: 0, + }) + .execute(); + }); + + return { + output: { + id: id, + }, + }; + } +} diff --git a/desktop/src/main/handlers/mutations/folder-create.ts b/desktop/src/main/handlers/mutations/folder-create.ts new file mode 100644 index 00000000..a0058664 --- /dev/null +++ b/desktop/src/main/handlers/mutations/folder-create.ts @@ -0,0 +1,69 @@ +import { databaseManager } from '@/main/data/database-manager'; +import { NodeTypes } from '@/lib/constants'; +import { generateId, IdType } from '@/lib/id'; +import { buildCreateNode, generateNodeIndex } from '@/lib/nodes'; +import { compareString } from '@/lib/utils'; +import { MutationHandler, MutationResult } from '@/operations/mutations'; +import { FolderCreateMutationInput } from '@/operations/mutations/folder-create'; + +export class FolderCreateMutationHandler + implements MutationHandler +{ + async handleMutation( + input: FolderCreateMutationInput, + ): Promise> { + const workspaceDatabase = await databaseManager.getWorkspaceDatabase( + input.userId, + ); + + let index: string | undefined = undefined; + if (input.generateIndex) { + const siblings = await workspaceDatabase + .selectFrom('nodes') + .selectAll() + .where('parent_id', '=', input.parentId) + .execute(); + + const maxIndex = + siblings.length > 0 + ? siblings.sort((a, b) => compareString(a.index, b.index))[ + siblings.length - 1 + ].index + : null; + + index = generateNodeIndex(maxIndex, null); + } + + const id = generateId(IdType.Folder); + await workspaceDatabase + .insertInto('nodes') + .values( + buildCreateNode( + { + id: id, + attributes: { + type: NodeTypes.Folder, + parentId: input.parentId, + index: index, + name: input.name, + }, + }, + input.userId, + ), + ) + .execute(); + + return { + output: { + id: id, + }, + changes: [ + { + type: 'workspace', + table: 'nodes', + userId: input.userId, + }, + ], + }; + } +} diff --git a/desktop/src/main/handlers/mutations/index.ts b/desktop/src/main/handlers/mutations/index.ts index e0316f5b..52c32ede 100644 --- a/desktop/src/main/handlers/mutations/index.ts +++ b/desktop/src/main/handlers/mutations/index.ts @@ -33,6 +33,8 @@ import { NodeServerUpdateMutationHandler } from '@/main/handlers/mutations/node- import { NodeServerDeleteMutationHandler } from '@/main/handlers/mutations/node-server-delete'; import { LogoutMutationHandler } from '@/main/handlers/mutations/logout'; import { NodeSyncMutationHandler } from '@/main/handlers/mutations/node-sync'; +import { FolderCreateMutationHandler } from '@/main/handlers/mutations/folder-create'; +import { FileCreateMutationHandler } from '@/main/handlers/mutations/file-create'; type MutationHandlerMap = { [K in keyof MutationMap]: MutationHandler; @@ -73,4 +75,6 @@ export const mutationHandlerMap: MutationHandlerMap = { node_server_delete: new NodeServerDeleteMutationHandler(), logout: new LogoutMutationHandler(), node_sync: new NodeSyncMutationHandler(), + folder_create: new FolderCreateMutationHandler(), + file_create: new FileCreateMutationHandler(), }; diff --git a/desktop/src/main/synchronizer.ts b/desktop/src/main/synchronizer.ts index a53ef017..9e16c70d 100644 --- a/desktop/src/main/synchronizer.ts +++ b/desktop/src/main/synchronizer.ts @@ -2,8 +2,10 @@ import { BackoffCalculator } from '@/lib/backoff-calculator'; import { buildAxiosInstance } from '@/lib/servers'; import { databaseManager } from '@/main/data/database-manager'; import { ServerSyncResponse } from '@/types/sync'; +import { WorkspaceCredentials } from '@/types/workspaces'; +import { fileManager } from './file-manager'; -const EVENT_LOOP_INTERVAL = 100; +const EVENT_LOOP_INTERVAL = 1000; class Synchronizer { private initiated: boolean = false; @@ -24,8 +26,8 @@ class Synchronizer { private async executeEventLoop() { try { - await this.checkForLoggedOutAccount(); - await this.checkForWorkspaceChanges(); + await this.syncLoggedOutAccounts(); + await this.syncWorkspaces(); } catch (error) { console.log('error', error); } @@ -33,7 +35,7 @@ class Synchronizer { setTimeout(this.executeEventLoop, EVENT_LOOP_INTERVAL); } - private async checkForLoggedOutAccount() { + private async syncLoggedOutAccounts() { const accounts = await databaseManager.appDatabase .selectFrom('accounts') .innerJoin('servers', 'accounts.server', 'servers.domain') @@ -92,7 +94,7 @@ class Synchronizer { } } - private async checkForWorkspaceChanges() { + private async syncWorkspaces() { const workspaces = await databaseManager.appDatabase .selectFrom('workspaces') .innerJoin('accounts', 'workspaces.account_id', 'accounts.id') @@ -100,6 +102,7 @@ class Synchronizer { .select([ 'workspaces.workspace_id', 'workspaces.user_id', + 'workspaces.account_id', 'accounts.token', 'servers.domain', 'servers.attributes', @@ -115,71 +118,24 @@ class Synchronizer { } } + const credentials: WorkspaceCredentials = { + workspaceId: workspace.workspace_id, + accountId: workspace.account_id, + userId: workspace.user_id, + token: workspace.token, + serverDomain: workspace.domain, + serverAttributes: workspace.attributes, + }; + try { - const workspaceDatabase = await databaseManager.getWorkspaceDatabase( - workspace.user_id, - ); - - const changes = await workspaceDatabase - .selectFrom('changes') - .selectAll() - .orderBy('id asc') - .limit(20) - .execute(); - - if (changes.length === 0) { - return; - } - - const axios = buildAxiosInstance( - workspace.domain, - workspace.attributes, - workspace.token, - ); - - const { data } = await axios.post( - `/v1/sync/${workspace.workspace_id}`, - { - changes: changes, - }, - ); - - const syncedChangeIds: number[] = []; - const unsyncedChangeIds: number[] = []; - for (const result of data.results) { - if (result.status === 'success') { - syncedChangeIds.push(result.id); - } else { - unsyncedChangeIds.push(result.id); - } - } - - if (syncedChangeIds.length > 0) { - await workspaceDatabase - .deleteFrom('changes') - .where('id', 'in', syncedChangeIds) - .execute(); - } - - if (unsyncedChangeIds.length > 0) { - await workspaceDatabase - .updateTable('changes') - .set((eb) => ({ retry_count: eb('retry_count', '+', 1) })) - .where('id', 'in', unsyncedChangeIds) - .execute(); - - //we just delete changes that have failed to sync for more than 5 times. - //in the future we might need to revert the change locally. - await workspaceDatabase - .deleteFrom('changes') - .where('retry_count', '>=', 5) - .execute(); - } + await this.checkForChanges(credentials); + // await fileManager.checkForUploads(credentials); if (this.backoffs.has(backoffKey)) { this.backoffs.delete(backoffKey); } } catch (error) { + console.log('error', error); if (!this.backoffs.has(backoffKey)) { this.backoffs.set(backoffKey, new BackoffCalculator()); } @@ -189,6 +145,68 @@ class Synchronizer { } } } + + private async checkForChanges(credentials: WorkspaceCredentials) { + const workspaceDatabase = await databaseManager.getWorkspaceDatabase( + credentials.userId, + ); + + const changes = await workspaceDatabase + .selectFrom('changes') + .selectAll() + .orderBy('id asc') + .limit(20) + .execute(); + + if (changes.length === 0) { + return; + } + + const axios = buildAxiosInstance( + credentials.serverDomain, + credentials.serverAttributes, + credentials.token, + ); + + const { data } = await axios.post( + `/v1/sync/${credentials.workspaceId}`, + { + changes: changes, + }, + ); + + const syncedChangeIds: number[] = []; + const unsyncedChangeIds: number[] = []; + for (const result of data.results) { + if (result.status === 'success') { + syncedChangeIds.push(result.id); + } else { + unsyncedChangeIds.push(result.id); + } + } + + if (syncedChangeIds.length > 0) { + await workspaceDatabase + .deleteFrom('changes') + .where('id', 'in', syncedChangeIds) + .execute(); + } + + if (unsyncedChangeIds.length > 0) { + await workspaceDatabase + .updateTable('changes') + .set((eb) => ({ retry_count: eb('retry_count', '+', 1) })) + .where('id', 'in', unsyncedChangeIds) + .execute(); + + //we just delete changes that have failed to sync for more than 5 times. + //in the future we might need to revert the change locally. + await workspaceDatabase + .deleteFrom('changes') + .where('retry_count', '>=', 5) + .execute(); + } + } } export const synchronizer = new Synchronizer(); diff --git a/desktop/src/operations/mutations/file-create.ts b/desktop/src/operations/mutations/file-create.ts new file mode 100644 index 00000000..89c26443 --- /dev/null +++ b/desktop/src/operations/mutations/file-create.ts @@ -0,0 +1,21 @@ +export type FileCreateMutationInput = { + type: 'file_create'; + userId: string; + workspaceId: string; + accountId: string; + parentId: string; + filePath: string; +}; + +export type FileCreateMutationOutput = { + id: string | null; +}; + +declare module '@/operations/mutations' { + interface MutationMap { + file_create: { + input: FileCreateMutationInput; + output: FileCreateMutationOutput; + }; + } +} diff --git a/desktop/src/operations/mutations/folder-create.ts b/desktop/src/operations/mutations/folder-create.ts new file mode 100644 index 00000000..13a4ee09 --- /dev/null +++ b/desktop/src/operations/mutations/folder-create.ts @@ -0,0 +1,20 @@ +export type FolderCreateMutationInput = { + type: 'folder_create'; + userId: string; + parentId: string; + name: string; + generateIndex: boolean; +}; + +export type FolderCreateMutationOutput = { + id: string; +}; + +declare module '@/operations/mutations' { + interface MutationMap { + folder_create: { + input: FolderCreateMutationInput; + output: FolderCreateMutationOutput; + }; + } +} diff --git a/desktop/src/renderer/components/chats/chat-container-node.tsx b/desktop/src/renderer/components/channels/channel-container.tsx similarity index 61% rename from desktop/src/renderer/components/chats/chat-container-node.tsx rename to desktop/src/renderer/components/channels/channel-container.tsx index 9194faaa..22361486 100644 --- a/desktop/src/renderer/components/chats/chat-container-node.tsx +++ b/desktop/src/renderer/components/channels/channel-container.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Conversation } from '@/renderer/components/messages/conversation'; -interface ChatContainerNodeProps { +interface ChannelContainerProps { nodeId: string; } -export const ChatContainerNode = ({ nodeId }: ChatContainerNodeProps) => { +export const ChannelContainer = ({ nodeId }: ChannelContainerProps) => { return ; }; diff --git a/desktop/src/renderer/components/channels/channel-container-node.tsx b/desktop/src/renderer/components/chats/chat-container.tsx similarity index 60% rename from desktop/src/renderer/components/channels/channel-container-node.tsx rename to desktop/src/renderer/components/chats/chat-container.tsx index 10c323a1..1c2969c0 100644 --- a/desktop/src/renderer/components/channels/channel-container-node.tsx +++ b/desktop/src/renderer/components/chats/chat-container.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Conversation } from '@/renderer/components/messages/conversation'; -interface ChannelContainerNodeProps { +interface ChatContainerProps { nodeId: string; } -export const ChannelContainerNode = ({ nodeId }: ChannelContainerNodeProps) => { +export const ChatContainer = ({ nodeId }: ChatContainerProps) => { return ; }; diff --git a/desktop/src/renderer/components/databases/database-container-node.tsx b/desktop/src/renderer/components/databases/database-container.tsx similarity index 85% rename from desktop/src/renderer/components/databases/database-container-node.tsx rename to desktop/src/renderer/components/databases/database-container.tsx index 9da93e71..48180b6a 100644 --- a/desktop/src/renderer/components/databases/database-container-node.tsx +++ b/desktop/src/renderer/components/databases/database-container.tsx @@ -4,13 +4,11 @@ import { Database } from '@/renderer/components/databases/database'; import { DatabaseViews } from '@/renderer/components/databases/database-views'; import { useWorkspace } from '@/renderer/contexts/workspace'; -interface DatabaseContainerNodeProps { +interface DatabaseContainerProps { nodeId: string; } -export const DatabaseContainerNode = ({ - nodeId, -}: DatabaseContainerNodeProps) => { +export const DatabaseContainer = ({ nodeId }: DatabaseContainerProps) => { const workspace = useWorkspace(); const { data: views, isPending: isViewsPending } = useQuery({ diff --git a/desktop/src/renderer/components/folders/folder-container.tsx b/desktop/src/renderer/components/folders/folder-container.tsx new file mode 100644 index 00000000..5a380156 --- /dev/null +++ b/desktop/src/renderer/components/folders/folder-container.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { Dropzone } from '@/renderer/components/ui/dropzone'; +import { Button } from '@/renderer/components/ui/button'; +import { Icon } from '@/renderer/components/ui/icon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/renderer/components/ui/dropdown-menu'; +import { folderLayouts, FolderLayoutType } from '@/types/folders'; +import { ScrollArea } from '@/renderer/components/ui/scroll-area'; +import { useMutation } from '@/renderer/hooks/use-mutation'; +import { useWorkspace } from '@/renderer/contexts/workspace'; + +interface FolderContainerProps { + nodeId: string; +} + +export const FolderContainer = ({ nodeId }: FolderContainerProps) => { + const workspace = useWorkspace(); + const { mutate, isPending } = useMutation(); + + const [layout, setLayout] = React.useState('grid'); + const currentLayout = + folderLayouts.find((l) => l.value === layout) ?? folderLayouts[0]; + + const isDialogOpenedRef = React.useRef(false); + + const openFileDialog = async () => { + if (isDialogOpenedRef.current) { + return; + } + + isDialogOpenedRef.current = true; + const result = await window.neuron.openFileDialog({ + properties: ['openFile'], + buttonLabel: 'Upload', + title: 'Upload files to folder', + }); + + if (result.canceled) { + isDialogOpenedRef.current = false; + return; + } + + mutate({ + input: { + type: 'file_create', + accountId: workspace.accountId, + workspaceId: workspace.id, + userId: workspace.userId, + filePath: result.filePaths[0], + parentId: nodeId, + }, + }); + + isDialogOpenedRef.current = false; + }; + + return ( + { + files.forEach((file) => console.log(file)); + }} + > +
+
+
+ +
+
+ + + + + + + + Layout + + {folderLayouts.map((item) => ( + setLayout(item.value)} + > +
+ +

{item.name}

+ {layout === item.value && } +
+
+ ))} +
+
+
+
+ + {/* */} + List files here + +
+ {/* */} +
+ ); +}; diff --git a/desktop/src/renderer/components/folders/folder-create-dialog.tsx b/desktop/src/renderer/components/folders/folder-create-dialog.tsx new file mode 100644 index 00000000..aec924ff --- /dev/null +++ b/desktop/src/renderer/components/folders/folder-create-dialog.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { useWorkspace } from '@/renderer/contexts/workspace'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/renderer/components/ui/dialog'; +import { z } from 'zod'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/renderer/components/ui/form'; +import { Input } from '@/renderer/components/ui/input'; +import { Button } from '@/renderer/components/ui/button'; +import { Spinner } from '@/renderer/components/ui/spinner'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation } from '@/renderer/hooks/use-mutation'; + +const formSchema = z.object({ + name: z.string().min(3, 'Name must be at least 3 characters long.'), +}); + +interface FolderCreateDialogProps { + spaceId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const FolderCreateDialog = ({ + spaceId, + open, + onOpenChange, +}: FolderCreateDialogProps) => { + const workspace = useWorkspace(); + const { mutate, isPending } = useMutation(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + }, + }); + + const handleCancel = () => { + form.reset(); + onOpenChange(false); + }; + + const handleSubmit = async (values: z.infer) => { + mutate({ + input: { + type: 'folder_create', + parentId: spaceId, + name: values.name, + generateIndex: true, + userId: workspace.userId, + }, + onSuccess(output) { + onOpenChange(false); + form.reset(); + workspace.navigateToNode(output.id); + }, + }); + }; + + return ( + + + + Create folder + + Create a new folder to organize your files + + +
+ +
+ ( + + Name * + + + + + + )} + /> +
+ + + + +
+ +
+
+ ); +}; diff --git a/desktop/src/renderer/components/pages/page-container-node.tsx b/desktop/src/renderer/components/pages/page-container.tsx similarity index 75% rename from desktop/src/renderer/components/pages/page-container-node.tsx rename to desktop/src/renderer/components/pages/page-container.tsx index 5e4a925c..72f79671 100644 --- a/desktop/src/renderer/components/pages/page-container-node.tsx +++ b/desktop/src/renderer/components/pages/page-container.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { ScrollArea } from '@/renderer/components/ui/scroll-area'; import { Document } from '@/renderer/components/documents/document'; -interface PageContainerNodeProps { +interface PageContainerProps { nodeId: string; } -export const PageContainerNode = ({ nodeId }: PageContainerNodeProps) => { +export const PageContainer = ({ nodeId }: PageContainerProps) => { return ( diff --git a/desktop/src/renderer/components/records/record-container-node.tsx b/desktop/src/renderer/components/records/record-container.tsx similarity index 90% rename from desktop/src/renderer/components/records/record-container-node.tsx rename to desktop/src/renderer/components/records/record-container.tsx index 196d26cc..3a2d8258 100644 --- a/desktop/src/renderer/components/records/record-container-node.tsx +++ b/desktop/src/renderer/components/records/record-container.tsx @@ -7,11 +7,11 @@ import { Document } from '@/renderer/components/documents/document'; import { Separator } from '@/renderer/components/ui/separator'; import { useWorkspace } from '@/renderer/contexts/workspace'; -interface RecordContainerNodeProps { +interface RecordContainerProps { nodeId: string; } -export const RecordContainerNode = ({ nodeId }: RecordContainerNodeProps) => { +export const RecordContainer = ({ nodeId }: RecordContainerProps) => { const workspace = useWorkspace(); const { data: record, isPending: isRecordPending } = useQuery({ diff --git a/desktop/src/renderer/components/ui/dropzone.tsx b/desktop/src/renderer/components/ui/dropzone.tsx new file mode 100644 index 00000000..188fcb06 --- /dev/null +++ b/desktop/src/renderer/components/ui/dropzone.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useDrop } from 'react-dnd'; +import { NativeTypes } from 'react-dnd-html5-backend'; + +import { Icon } from '@/renderer/components/ui/icon'; + +interface DropzoneProps { + text: string; + children: React.ReactNode; + onDrop: (files: File[]) => void; +} + +const Dropzone = ({ text, children, onDrop }: DropzoneProps) => { + const [{ canDrop, isOver }, drop] = useDrop({ + accept: NativeTypes.FILE, + drop: (item: { files: File[] }) => { + onDrop(item.files); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), + }); + const divRef = React.useRef(null); + const dropRef = drop(divRef); + + const isActive = canDrop && isOver; + return ( +
+ {isActive && ( +
+
+
+ +

{text}

+
+
+
+ )} + {children} +
+ ); +}; + +export { Dropzone }; diff --git a/desktop/src/renderer/components/workspaces/containers/container.tsx b/desktop/src/renderer/components/workspaces/containers/container.tsx index 013b9dca..3fd87f0f 100644 --- a/desktop/src/renderer/components/workspaces/containers/container.tsx +++ b/desktop/src/renderer/components/workspaces/containers/container.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { match } from 'ts-pattern'; import { useParams } from 'react-router-dom'; -import { PageContainerNode } from '@/renderer/components/pages/page-container-node'; -import { ChannelContainerNode } from '@/renderer/components/channels/channel-container-node'; +import { PageContainer } from '@/renderer/components/pages/page-container'; +import { ChannelContainer } from '@/renderer/components/channels/channel-container'; import { ContainerHeader } from '@/renderer/components/workspaces/containers/container-header'; -import { DatabaseContainerNode } from '@/renderer/components/databases/database-container-node'; -import { RecordContainerNode } from '@/renderer/components/records/record-container-node'; -import { ChatContainerNode } from '@/renderer/components/chats/chat-container-node'; +import { DatabaseContainer } from '@/renderer/components/databases/database-container'; +import { RecordContainer } from '@/renderer/components/records/record-container'; +import { ChatContainer } from '@/renderer/components/chats/chat-container'; +import { FolderContainer } from '@/renderer/components/folders/folder-container'; import { useWorkspace } from '@/renderer/contexts/workspace'; import { getIdType, IdType } from '@/lib/id'; @@ -22,11 +23,12 @@ export const Container = () => {
{match(idType) - .with(IdType.Channel, () => ) - .with(IdType.Page, () => ) - .with(IdType.Database, () => ) - .with(IdType.Record, () => ) - .with(IdType.Chat, () => ) + .with(IdType.Channel, () => ) + .with(IdType.Page, () => ) + .with(IdType.Database, () => ) + .with(IdType.Record, () => ) + .with(IdType.Chat, () => ) + .with(IdType.Folder, () => ) .otherwise(() => null)}
); diff --git a/desktop/src/renderer/components/workspaces/modals/modal-content.tsx b/desktop/src/renderer/components/workspaces/modals/modal-content.tsx index b51d999c..deb60152 100644 --- a/desktop/src/renderer/components/workspaces/modals/modal-content.tsx +++ b/desktop/src/renderer/components/workspaces/modals/modal-content.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { match } from 'ts-pattern'; -import { ChannelContainerNode } from '@/renderer/components/channels/channel-container-node'; -import { PageContainerNode } from '@/renderer/components/pages/page-container-node'; -import { DatabaseContainerNode } from '@/renderer/components/databases/database-container-node'; -import { RecordContainerNode } from '@/renderer/components/records/record-container-node'; +import { ChannelContainer } from '@/renderer/components/channels/channel-container'; +import { PageContainer } from '@/renderer/components/pages/page-container'; +import { DatabaseContainer } from '@/renderer/components/databases/database-container'; +import { RecordContainer } from '@/renderer/components/records/record-container'; +import { ChatContainer } from '@/renderer/components/chats/chat-container'; +import { FolderContainer } from '@/renderer/components/folders/folder-container'; import { getIdType, IdType } from '@/lib/id'; interface ModalContentProps { @@ -15,10 +17,12 @@ export const ModalContent = ({ nodeId }: ModalContentProps) => { return (
{match(idType) - .with(IdType.Channel, () => ) - .with(IdType.Page, () => ) - .with(IdType.Database, () => ) - .with(IdType.Record, () => ) + .with(IdType.Channel, () => ) + .with(IdType.Page, () => ) + .with(IdType.Database, () => ) + .with(IdType.Record, () => ) + .with(IdType.Chat, () => ) + .with(IdType.Folder, () => ) .otherwise(() => null)}
); diff --git a/desktop/src/renderer/components/workspaces/sidebars/sidebar-footer.tsx b/desktop/src/renderer/components/workspaces/sidebars/sidebar-footer.tsx index 765915c5..baea46fe 100644 --- a/desktop/src/renderer/components/workspaces/sidebars/sidebar-footer.tsx +++ b/desktop/src/renderer/components/workspaces/sidebars/sidebar-footer.tsx @@ -40,7 +40,7 @@ export function SidebarFooter() { {account.name} {account.email} - + { {workspace.name} Free Plan - + { > - - + + {node.name} @@ -146,6 +150,7 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => { onClick={() => { workspace.navigateToNode(child.id); }} + className="cursor-pointer" > @@ -177,6 +182,13 @@ export const SidebarSpaceItem = ({ node }: SidebarSpaceNodeProps) => { onOpenChange={setOpenCreateDatabase} /> )} + {openCreateFolder && ( + + )} ); }; diff --git a/desktop/src/types/files.ts b/desktop/src/types/files.ts new file mode 100644 index 00000000..4afee060 --- /dev/null +++ b/desktop/src/types/files.ts @@ -0,0 +1,4 @@ +export type ServerFileUploadResponse = { + id: string; + url: string; +}; diff --git a/desktop/src/types/folders.ts b/desktop/src/types/folders.ts new file mode 100644 index 00000000..7ec51088 --- /dev/null +++ b/desktop/src/types/folders.ts @@ -0,0 +1,29 @@ +export type FolderLayoutType = 'grid' | 'list' | 'gallery'; + +export type FolderLayout = { + value: FolderLayoutType; + name: string; + description: string; + icon: string; +}; + +export const folderLayouts: FolderLayout[] = [ + { + name: 'Grid', + value: 'grid', + description: 'Show files in grid layout', + icon: 'layout-grid-line', + }, + { + name: 'List', + value: 'list', + description: 'Show files in list layout', + icon: 'list-indefinite', + }, + { + name: 'Gallery', + value: 'gallery', + description: 'Show files in gallery layout', + icon: 'layout-bottom-line', + }, +]; diff --git a/desktop/src/types/workspaces.ts b/desktop/src/types/workspaces.ts index ecba692d..ea3a55ce 100644 --- a/desktop/src/types/workspaces.ts +++ b/desktop/src/types/workspaces.ts @@ -66,3 +66,12 @@ export type BreadcrumbNode = { name: string | null; avatar: string | null; }; + +export type WorkspaceCredentials = { + workspaceId: string; + accountId: string; + userId: string; + token: string; + serverDomain: string; + serverAttributes: string; +}; diff --git a/server/package-lock.json b/server/package-lock.json index 3e118856..66f46c54 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-s3": "^3.675.0", + "@aws-sdk/s3-request-presigner": "^3.675.0", "axios": "^1.7.7", "bcrypt": "^5.1.1", "bullmq": "^5.21.1", @@ -819,6 +820,25 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.675.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.675.0.tgz", + "integrity": "sha512-/2KWrFjB2FWTKV8nKK1gbufY1IX9GZy4yXVVKjdLxMpM0O6JIg79S0KGvkEZtCZW4SKen0sExsCU5Dsc1RMfwA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.674.0", + "@aws-sdk/types": "3.667.0", + "@aws-sdk/util-format-url": "3.667.0", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.674.0", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.674.0.tgz", @@ -895,6 +915,21 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.667.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.667.0.tgz", + "integrity": "sha512-S0D731SnEPnTfbJ/Dldw5dDrOc8uipK6NLXHDs2xIq0t61iwZLMEiN8yWCs2wAZVVJKpldUM1THLaaufU9SSSA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.667.0", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.568.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", diff --git a/server/package.json b/server/package.json index 0f22d703..0c352346 100644 --- a/server/package.json +++ b/server/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.675.0", + "@aws-sdk/s3-request-presigner": "^3.675.0", "axios": "^1.7.7", "bcrypt": "^5.1.1", "bullmq": "^5.21.1", diff --git a/server/src/api.ts b/server/src/api.ts index 214112c4..4a94e59b 100644 --- a/server/src/api.ts +++ b/server/src/api.ts @@ -8,6 +8,7 @@ import { authMiddleware } from '@/middlewares/auth'; import { syncRouter } from '@/routes/sync'; import { configRouter } from '@/routes/config'; import { avatarsRouter } from '@/routes/avatars'; +import { filesRouter } from '@/routes/files'; import { socketManager } from '@/sockets/socket-manager'; export const initApi = () => { @@ -26,6 +27,7 @@ export const initApi = () => { app.use('/v1/workspaces', authMiddleware, workspacesRouter); app.use('/v1/sync', authMiddleware, syncRouter); app.use('/v1/avatars', authMiddleware, avatarsRouter); + app.use('/v1/files', authMiddleware, filesRouter); const server = http.createServer(app); socketManager.init(server); diff --git a/server/src/data/storage.ts b/server/src/data/storage.ts index 4a08e50e..275cff30 100644 --- a/server/src/data/storage.ts +++ b/server/src/data/storage.ts @@ -6,6 +6,12 @@ const AVATARS_STORAGE_REGION = process.env.AVATARS_STORAGE_REGION; const AVATARS_STORAGE_ACCESS_KEY = process.env.AVATARS_STORAGE_ACCESS_KEY; const AVATARS_STORAGE_SECRET_KEY = process.env.AVATARS_STORAGE_SECRET_KEY; +const FILES_STORAGE_ENDPOINT = process.env.FILES_STORAGE_ENDPOINT; +const FILES_STORAGE_BUCKET_NAME = process.env.FILES_STORAGE_BUCKET_NAME; +const FILES_STORAGE_REGION = process.env.FILES_STORAGE_REGION; +const FILES_STORAGE_ACCESS_KEY = process.env.FILES_STORAGE_ACCESS_KEY; +const FILES_STORAGE_SECRET_KEY = process.env.FILES_STORAGE_SECRET_KEY; + if ( !AVATARS_STORAGE_ENDPOINT || !AVATARS_STORAGE_ACCESS_KEY || @@ -16,6 +22,16 @@ if ( throw new Error('Avatar storage credentials not set'); } +if ( + !FILES_STORAGE_ENDPOINT || + !FILES_STORAGE_ACCESS_KEY || + !FILES_STORAGE_SECRET_KEY || + !FILES_STORAGE_BUCKET_NAME || + !FILES_STORAGE_REGION +) { + throw new Error('Files storage credentials not set'); +} + export const avatarStorage = new S3Client({ endpoint: AVATARS_STORAGE_ENDPOINT, region: AVATARS_STORAGE_REGION, @@ -25,6 +41,16 @@ export const avatarStorage = new S3Client({ }, }); +export const filesStorage = new S3Client({ + endpoint: FILES_STORAGE_ENDPOINT, + region: FILES_STORAGE_REGION, + credentials: { + accessKeyId: FILES_STORAGE_ACCESS_KEY, + secretAccessKey: FILES_STORAGE_SECRET_KEY, + }, +}); + export const BUCKET_NAMES = { AVATARS: AVATARS_STORAGE_BUCKET_NAME, + FILES: FILES_STORAGE_BUCKET_NAME, }; diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts new file mode 100644 index 00000000..2efa28a3 --- /dev/null +++ b/server/src/routes/files.ts @@ -0,0 +1,159 @@ +import { database } from '@/data/database'; +import { BUCKET_NAMES, filesStorage } from '@/data/storage'; +import { fetchCollaboratorRole } from '@/lib/nodes'; +import { ApiError, NeuronRequest, NeuronResponse } from '@/types/api'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Router } from 'express'; + +export const filesRouter = Router(); + +filesRouter.get( + '/:workspaceId/:fileId', + async (req: NeuronRequest, res: NeuronResponse) => { + const workspaceId = req.params.workspaceId as string; + const fileId = req.params.fileId as string; + + if (!req.account) { + return res.status(401).json({ + code: ApiError.Unauthorized, + message: 'Unauthorized.', + }); + } + + const workspace = await database + .selectFrom('workspaces') + .selectAll() + .where('id', '=', workspaceId) + .executeTakeFirst(); + + if (!workspace) { + return res.status(404).json({ + code: ApiError.ResourceNotFound, + message: 'Workspace not found.', + }); + } + + const workspaceUser = await database + .selectFrom('workspace_users') + .selectAll() + .where('workspace_id', '=', workspace.id) + .where('account_id', '=', req.account.id) + .executeTakeFirst(); + + if (!workspaceUser) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + const role = await fetchCollaboratorRole(fileId, workspaceUser.id); + if (role === null) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + const node = await database + .selectFrom('nodes') + .selectAll() + .where('id', '=', fileId) + .executeTakeFirst(); + + if (!node) { + return res.status(404).json({ + code: ApiError.ResourceNotFound, + message: 'File not found.', + }); + } + + //generate presigned url for download + const command = new GetObjectCommand({ + Bucket: BUCKET_NAMES.FILES, + Key: `files/${fileId}${node.attributes.extension}`, + }); + + const presignedUrl = await getSignedUrl(filesStorage, command, { + expiresIn: 60 * 60 * 4, // 4 hours + }); + + res.status(200).json({ url: presignedUrl }); + }, +); + +filesRouter.post( + '/:workspaceId/:fileId', + async (req: NeuronRequest, res: NeuronResponse) => { + const workspaceId = req.params.workspaceId as string; + const fileId = req.params.fileId as string; + + if (!req.account) { + return res.status(401).json({ + code: ApiError.Unauthorized, + message: 'Unauthorized.', + }); + } + + const workspace = await database + .selectFrom('workspaces') + .selectAll() + .where('id', '=', workspaceId) + .executeTakeFirst(); + + if (!workspace) { + return res.status(404).json({ + code: ApiError.ResourceNotFound, + message: 'Workspace not found.', + }); + } + + const workspaceUser = await database + .selectFrom('workspace_users') + .selectAll() + .where('workspace_id', '=', workspace.id) + .where('account_id', '=', req.account.id) + .executeTakeFirst(); + + if (!workspaceUser) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + const role = await fetchCollaboratorRole(fileId, workspaceUser.id); + if (role === null) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + + const node = await database + .selectFrom('nodes') + .selectAll() + .where('id', '=', fileId) + .executeTakeFirst(); + + if (!node) { + return res.status(404).json({ + code: ApiError.ResourceNotFound, + message: 'File not found.', + }); + } + + //generate presigned url for upload + const command = new PutObjectCommand({ + Bucket: BUCKET_NAMES.FILES, + Key: `files/${fileId}${node.attributes.extension}`, + }); + + const presignedUrl = await getSignedUrl(filesStorage, command, { + expiresIn: 60 * 60 * 4, // 4 hours + }); + + res.status(200).json({ url: presignedUrl }); + }, +);