diff --git a/apps/desktop/src/main/bootstrapper.ts b/apps/desktop/src/main/bootstrapper.ts index aeee2486..501dfa02 100644 --- a/apps/desktop/src/main/bootstrapper.ts +++ b/apps/desktop/src/main/bootstrapper.ts @@ -6,6 +6,7 @@ import { serverService } from '@/main/services/server-service'; import { accountService } from '@/main/services/account-service'; import { syncService } from '@/main/services/sync-service'; import { notificationService } from '@/main/services/notification-service'; +import { fileService } from '@/main/services/file-service'; const EVENT_LOOP_INTERVAL = 1000 * 60; @@ -34,7 +35,7 @@ class Bootstrapper { notificationService.init(); if (!this.eventLoop) { - this.eventLoop = setTimeout(this.executeEventLoop, EVENT_LOOP_INTERVAL); + this.eventLoop = setTimeout(this.executeEventLoop, 50); } } @@ -45,6 +46,8 @@ class Bootstrapper { await socketService.checkConnections(); await accountService.syncDeletedTokens(); await syncService.syncAllWorkspaces(); + await fileService.syncFiles(); + notificationService.checkBadge(); } catch (error) { console.log('error', error); diff --git a/apps/desktop/src/main/data/workspace/migrations.ts b/apps/desktop/src/main/data/workspace/migrations.ts index 3c999b2d..3781a85f 100644 --- a/apps/desktop/src/main/data/workspace/migrations.ts +++ b/apps/desktop/src/main/data/workspace/migrations.ts @@ -90,8 +90,10 @@ const createDownloadsTable: Migration = { .addColumn('node_id', 'text', (col) => col.notNull().primaryKey().references('nodes.id') ) + .addColumn('upload_id', 'text', (col) => col.notNull()) .addColumn('created_at', 'text', (col) => col.notNull()) .addColumn('updated_at', 'text') + .addColumn('completed_at', 'text') .addColumn('progress', 'integer', (col) => col.defaultTo(0)) .addColumn('retry_count', 'integer', (col) => col.defaultTo(0)) .execute(); @@ -108,6 +110,7 @@ const createUploadsTable: Migration = { .addColumn('node_id', 'text', (col) => col.notNull().primaryKey().references('nodes.id') ) + .addColumn('upload_id', 'text', (col) => col.notNull()) .addColumn('created_at', 'text', (col) => col.notNull()) .addColumn('updated_at', 'text') .addColumn('progress', 'integer', (col) => col.defaultTo(0)) diff --git a/apps/desktop/src/main/data/workspace/schema.ts b/apps/desktop/src/main/data/workspace/schema.ts index c95a44f9..6c1d9860 100644 --- a/apps/desktop/src/main/data/workspace/schema.ts +++ b/apps/desktop/src/main/data/workspace/schema.ts @@ -58,6 +58,7 @@ export type UpdateChange = Updateable; interface UploadTable { node_id: ColumnType; + upload_id: ColumnType; created_at: ColumnType; updated_at: ColumnType; progress: ColumnType; @@ -70,8 +71,10 @@ export type UpdateUpload = Updateable; interface DownloadTable { node_id: ColumnType; + upload_id: ColumnType; created_at: ColumnType; updated_at: ColumnType; + completed_at: ColumnType; progress: ColumnType; retry_count: ColumnType; } diff --git a/apps/desktop/src/main/mutations/file-create.ts b/apps/desktop/src/main/mutations/file-create.ts index 2bb5769c..9014544d 100644 --- a/apps/desktop/src/main/mutations/file-create.ts +++ b/apps/desktop/src/main/mutations/file-create.ts @@ -1,5 +1,5 @@ import { MutationHandler } from '@/main/types'; -import { generateId, IdType } from '@colanode/core'; +import { extractFileType, generateId, IdType } from '@colanode/core'; import { FileCreateMutationInput, FileCreateMutationOutput, @@ -19,43 +19,50 @@ export class FileCreateMutationHandler throw new Error('Invalid file'); } - const id = generateId(IdType.File); + const fileId = generateId(IdType.File); + const uploadId = generateId(IdType.Upload); fileService.copyFileToWorkspace( input.filePath, - id, + fileId, metadata.extension, input.userId ); const attributes: FileAttributes = { type: 'file', + subtype: extractFileType(metadata.mimeType), parentId: input.parentId, name: metadata.name, fileName: metadata.name, extension: metadata.extension, size: metadata.size, mimeType: metadata.mimeType, + uploadId, + uploadStatus: 'pending', }; await nodeService.createNode(input.userId, { - id, + id: fileId, attributes, upload: { - node_id: id, + node_id: fileId, created_at: new Date().toISOString(), progress: 0, retry_count: 0, + upload_id: uploadId, }, download: { - node_id: id, + node_id: fileId, + upload_id: uploadId, created_at: new Date().toISOString(), - progress: 0, + progress: 100, retry_count: 0, + completed_at: new Date().toISOString(), }, }); return { - id: id, + id: fileId, }; } } diff --git a/apps/desktop/src/main/mutations/file-download.ts b/apps/desktop/src/main/mutations/file-download.ts index 07ebf322..bbe25f1e 100644 --- a/apps/desktop/src/main/mutations/file-download.ts +++ b/apps/desktop/src/main/mutations/file-download.ts @@ -5,6 +5,7 @@ import { FileDownloadMutationInput, FileDownloadMutationOutput, } from '@/shared/mutations/file-download'; +import { mapNode } from '@/main/utils'; export class FileDownloadMutationHandler implements MutationHandler @@ -28,6 +29,13 @@ export class FileDownloadMutationHandler }; } + const file = mapNode(node); + if (file.attributes.type !== 'file') { + return { + success: false, + }; + } + const download = await workspaceDatabase .selectFrom('downloads') .selectAll() @@ -45,6 +53,7 @@ export class FileDownloadMutationHandler .insertInto('downloads') .values({ node_id: input.fileId, + upload_id: file.attributes.uploadId, created_at: createdAt.toISOString(), progress: 0, retry_count: 0, @@ -56,6 +65,7 @@ export class FileDownloadMutationHandler userId: input.userId, download: { nodeId: node.id, + uploadId: file.attributes.uploadId, createdAt: createdAt.toISOString(), updatedAt: null, progress: 0, diff --git a/apps/desktop/src/main/mutations/message-create.ts b/apps/desktop/src/main/mutations/message-create.ts index 9681ec99..b21f1db8 100644 --- a/apps/desktop/src/main/mutations/message-create.ts +++ b/apps/desktop/src/main/mutations/message-create.ts @@ -1,4 +1,10 @@ -import { generateId, IdType, EditorNodeTypes, NodeTypes } from '@colanode/core'; +import { + generateId, + IdType, + EditorNodeTypes, + NodeTypes, + extractFileType, +} from '@colanode/core'; import { MutationHandler } from '@/main/types'; import { MessageCreateMutationInput, @@ -57,6 +63,7 @@ export class MessageCreateMutationHandler } const fileId = generateId(IdType.File); + const uploadId = generateId(IdType.Upload); block.id = fileId; block.type = NodeTypes.File; @@ -65,18 +72,22 @@ export class MessageCreateMutationHandler fileService.copyFileToWorkspace( path, fileId, + uploadId, metadata.extension, input.userId ); const fileAttributes: FileAttributes = { type: 'file', + subtype: extractFileType(metadata.mimeType), parentId: messageId, name: metadata.name, fileName: metadata.name, mimeType: metadata.mimeType, size: metadata.size, extension: metadata.extension, + uploadId, + uploadStatus: 'pending', }; inputs.push({ @@ -84,12 +95,15 @@ export class MessageCreateMutationHandler attributes: fileAttributes, download: { node_id: fileId, + upload_id: uploadId, created_at: createdAt, progress: 100, retry_count: 0, + completed_at: new Date().toISOString(), }, upload: { node_id: fileId, + upload_id: uploadId, created_at: createdAt, progress: 0, retry_count: 0, diff --git a/apps/desktop/src/main/queries/download-get.ts b/apps/desktop/src/main/queries/download-get.ts new file mode 100644 index 00000000..ebacb82d --- /dev/null +++ b/apps/desktop/src/main/queries/download-get.ts @@ -0,0 +1,87 @@ +import { DownloadGetQueryInput } from '@/shared/queries/download-get'; +import { databaseService } from '@/main/data/database-service'; +import { Download } from '@/shared/types/nodes'; +import { ChangeCheckResult, QueryHandler } from '@/main/types'; +import { mapDownload } from '@/main/utils'; +import { Event } from '@/shared/types/events'; +import { SelectDownload } from '@/main/data/workspace/schema'; + +export class DownloadGetQueryHandler + implements QueryHandler +{ + public async handleQuery( + input: DownloadGetQueryInput + ): Promise { + const row = await this.fetchDownload(input); + return row ? mapDownload(row) : null; + } + + public async checkForChanges( + event: Event, + input: DownloadGetQueryInput, + _: Download | null + ): Promise> { + if ( + event.type === 'workspace_deleted' && + event.workspace.userId === input.userId + ) { + return { + hasChanges: true, + result: null, + }; + } + + if ( + event.type === 'download_created' && + event.userId === input.userId && + event.download.nodeId === input.nodeId + ) { + return { + hasChanges: true, + result: event.download, + }; + } + + if ( + event.type === 'download_updated' && + event.userId === input.userId && + event.download.nodeId === input.nodeId + ) { + return { + hasChanges: true, + result: event.download, + }; + } + + if ( + event.type === 'download_deleted' && + event.userId === input.userId && + event.download.nodeId === input.nodeId + ) { + return { + hasChanges: true, + result: null, + }; + } + + return { + hasChanges: false, + }; + } + + private async fetchDownload( + input: DownloadGetQueryInput + ): Promise { + const workspaceDatabase = await databaseService.getWorkspaceDatabase( + input.userId + ); + + const row = await workspaceDatabase + .selectFrom('downloads') + .selectAll() + .where('node_id', '=', input.nodeId) + .executeTakeFirst(); + + return row; + } +} diff --git a/apps/desktop/src/main/queries/file-list.ts b/apps/desktop/src/main/queries/file-list.ts index dfd91f50..10f65116 100644 --- a/apps/desktop/src/main/queries/file-list.ts +++ b/apps/desktop/src/main/queries/file-list.ts @@ -1,20 +1,11 @@ import { FileListQueryInput } from '@/shared/queries/file-list'; import { databaseService } from '@/main/data/database-service'; import { ChangeCheckResult, QueryHandler } from '@/main/types'; -import { NodeTypes } from '@colanode/core'; +import { NodeTypes, FileNode } from '@colanode/core'; import { compareString } from '@/shared/lib/utils'; -import { FileNode } from '@/shared/types/files'; import { Event } from '@/shared/types/events'; - -interface FileRow { - id: string; - attributes: string; - parent_id: string | null; - type: string; - download_progress?: number | null; - created_at: string; - created_by: string; -} +import { SelectNode } from '@/main/data/workspace/schema'; +import { mapNode } from '@/main/utils'; export class FileListQueryHandler implements QueryHandler { public async handleQuery(input: FileListQueryInput): Promise { @@ -56,18 +47,19 @@ export class FileListQueryHandler implements QueryHandler { event.node.type === 'file' && event.node.parentId === input.parentId ) { - const newOutput = [...output]; - const file = newOutput.find((file) => file.id === event.node.id); + const file = output.find((file) => file.id === event.node.id); if (file) { - file.name = event.node.attributes.name; - file.mimeType = event.node.attributes.mimeType; - file.size = event.node.attributes.size; - file.extension = event.node.attributes.extension; - file.fileName = event.node.attributes.fileName; + const newResult = output.map((file) => { + if (file.id === event.node.id) { + return event.node as FileNode; + } + + return file; + }); return { hasChanges: true, - result: newOutput, + result: newResult, }; } } @@ -80,27 +72,10 @@ export class FileListQueryHandler implements QueryHandler { ) { const file = output.find((file) => file.id === event.node.id); if (file) { - const newOutput = output.filter((file) => file.id !== event.node.id); + const newResult = await this.handleQuery(input); return { hasChanges: true, - result: newOutput, - }; - } - } - - if ( - (event.type === 'download_created' || - event.type === 'download_updated') && - event.userId === input.userId - ) { - const newOutput = [...output]; - const nodeId = event.download.nodeId; - const file = newOutput.find((file) => file.id === nodeId); - if (file) { - file.downloadProgress = event.download.progress; - return { - hasChanges: true, - result: newOutput, + result: newResult, }; } } @@ -110,7 +85,7 @@ export class FileListQueryHandler implements QueryHandler { }; } - private async fetchFiles(input: FileListQueryInput): Promise { + private async fetchFiles(input: FileListQueryInput): Promise { const workspaceDatabase = await databaseService.getWorkspaceDatabase( input.userId ); @@ -118,18 +93,7 @@ export class FileListQueryHandler implements QueryHandler { const offset = (input.page - 1) * input.count; const files = await workspaceDatabase .selectFrom('nodes') - .leftJoin('downloads', (join) => - join.onRef('nodes.id', '=', 'downloads.node_id') - ) - .select([ - 'nodes.id', - 'nodes.attributes', - 'nodes.parent_id', - 'nodes.type', - 'downloads.progress as download_progress', - 'nodes.created_at', - 'nodes.created_by', - ]) + .selectAll() .where((eb) => eb.and([ eb('parent_id', '=', input.parentId), @@ -144,20 +108,16 @@ export class FileListQueryHandler implements QueryHandler { return files; } - private buildFiles = (fileRows: FileRow[]): FileNode[] => { + private buildFiles = (rows: SelectNode[]): FileNode[] => { + const nodes = rows.map(mapNode); const files: FileNode[] = []; - for (const fileRow of fileRows) { - const attributes = JSON.parse(fileRow.attributes); - files.push({ - id: fileRow.id, - name: attributes.name, - mimeType: attributes.mimeType, - size: attributes.size, - extension: attributes.extension, - fileName: attributes.fileName, - createdAt: fileRow.created_at, - downloadProgress: fileRow.download_progress, - }); + + for (const node of nodes) { + if (node.type !== 'file') { + continue; + } + + files.push(node); } return files.sort((a, b) => compareString(a.id, b.id)); diff --git a/apps/desktop/src/main/queries/index.ts b/apps/desktop/src/main/queries/index.ts index c1c47364..bfdfea60 100644 --- a/apps/desktop/src/main/queries/index.ts +++ b/apps/desktop/src/main/queries/index.ts @@ -17,6 +17,7 @@ import { RadarDataGetQueryHandler } from '@/main/queries/radar-data-get'; import { FileMetadataGetQueryHandler } from '@/main/queries/file-metadata-get'; import { AccountGetQueryHandler } from '@/main/queries/account-get'; import { WorkspaceGetQueryHandler } from '@/main/queries/workspace-get'; +import { DownloadGetQueryHandler } from '@/main/queries/download-get'; type QueryHandlerMap = { [K in keyof QueryMap]: QueryHandler; @@ -40,4 +41,5 @@ export const queryHandlerMap: QueryHandlerMap = { file_metadata_get: new FileMetadataGetQueryHandler(), account_get: new AccountGetQueryHandler(), workspace_get: new WorkspaceGetQueryHandler(), + download_get: new DownloadGetQueryHandler(), }; diff --git a/apps/desktop/src/main/services/account-service.ts b/apps/desktop/src/main/services/account-service.ts index fd082b52..ef48bb3d 100644 --- a/apps/desktop/src/main/services/account-service.ts +++ b/apps/desktop/src/main/services/account-service.ts @@ -20,8 +20,10 @@ class AccountService { .execute(); for (const account of accounts) { - this.syncAccount(account); + await this.syncAccount(account); } + + await this.syncDeletedTokens(); } private async syncAccount(account: SelectAccount) { diff --git a/apps/desktop/src/main/services/file-service.ts b/apps/desktop/src/main/services/file-service.ts index a38b51de..c7c7493b 100644 --- a/apps/desktop/src/main/services/file-service.ts +++ b/apps/desktop/src/main/services/file-service.ts @@ -3,20 +3,40 @@ import path from 'path'; import fs from 'fs'; import axios from 'axios'; import mime from 'mime-types'; -import { - FileMetadata, - ServerFileDownloadResponse, - ServerFileUploadResponse, -} from '@/shared/types/files'; -import { WorkspaceCredentials } from '@/shared/types/workspaces'; +import { FileMetadata } from '@/shared/types/files'; import { databaseService } from '@/main/data/database-service'; import { httpClient } from '@/shared/lib/http-client'; import { getWorkspaceFilesDirectoryPath } from '@/main/utils'; -import { FileAttributes } from '@colanode/core'; +import { + CompleteUploadOutput, + CreateDownloadOutput, + CreateUploadOutput, + extractFileType, + FileAttributes, +} from '@colanode/core'; import { eventBus } from '@/shared/lib/event-bus'; import { serverService } from '@/main/services/server-service'; +type WorkspaceFileState = { + isUploading: boolean; + isDownloading: boolean; + isUploadScheduled: boolean; + isDownloadScheduled: boolean; +}; + class FileService { + private fileStates: Map = new Map(); + + constructor() { + eventBus.subscribe((event) => { + if (event.type === 'download_created') { + this.syncWorkspaceDownloads(event.userId); + } else if (event.type === 'upload_created') { + this.syncWorkspaceUploads(event.userId); + } + }); + } + public async handleFileRequest(request: Request): Promise { const url = request.url.replace('local-file://', ''); const [userId, file] = url.split('/'); @@ -39,6 +59,7 @@ class FileService { public copyFileToWorkspace( filePath: string, fileId: string, + uploadId: string, fileExtension: string, userId: string ): void { @@ -50,7 +71,7 @@ class FileService { const destinationFilePath = path.join( filesDir, - `${fileId}${fileExtension}` + `${fileId}_${uploadId}${fileExtension}` ); fs.copyFileSync(filePath, destinationFilePath); } @@ -78,6 +99,7 @@ class FileService { } const stats = fs.statSync(filePath); + const type = extractFileType(mimeType); return { path: filePath, @@ -85,15 +107,101 @@ class FileService { extension: path.extname(filePath), name: path.basename(filePath), size: stats.size, + type, }; } - public async checkForUploads( - credentials: WorkspaceCredentials - ): Promise { - const workspaceDatabase = await databaseService.getWorkspaceDatabase( - credentials.userId - ); + public async syncFiles() { + const workspaces = await databaseService.appDatabase + .selectFrom('workspaces') + .select(['user_id']) + .execute(); + + for (const workspace of workspaces) { + this.uploadWorkspaceFiles(workspace.user_id); + } + } + + public async syncWorkspaceUploads(userId: string): Promise { + if (!this.fileStates.has(userId)) { + this.fileStates.set(userId, { + isUploading: false, + isDownloading: false, + isUploadScheduled: false, + isDownloadScheduled: false, + }); + } + + const fileState = this.fileStates.get(userId)!; + if (fileState.isUploading) { + fileState.isUploadScheduled = true; + return; + } + + fileState.isUploading = true; + try { + await this.uploadWorkspaceFiles(userId); + } catch (error) { + console.log('error', error); + } finally { + fileState.isUploading = false; + if (fileState.isUploadScheduled) { + fileState.isUploadScheduled = false; + this.syncWorkspaceUploads(userId); + } + } + } + + public async syncWorkspaceDownloads(userId: string): Promise { + if (!this.fileStates.has(userId)) { + this.fileStates.set(userId, { + isUploading: false, + isDownloading: false, + isUploadScheduled: false, + isDownloadScheduled: false, + }); + } + + const fileState = this.fileStates.get(userId)!; + if (fileState.isDownloading) { + fileState.isDownloadScheduled = true; + return; + } + + fileState.isDownloading = true; + try { + await this.downloadWorkspaceFiles(userId); + } catch (error) { + console.log('error', error); + } finally { + fileState.isDownloading = false; + if (fileState.isDownloadScheduled) { + fileState.isDownloadScheduled = false; + this.syncWorkspaceDownloads(userId); + } + } + } + + private async uploadWorkspaceFiles(userId: string): Promise { + if (!this.fileStates.has(userId)) { + this.fileStates.set(userId, { + isUploading: false, + isDownloading: false, + isUploadScheduled: false, + isDownloadScheduled: false, + }); + } + + const fileState = this.fileStates.get(userId)!; + if (fileState.isUploading) { + fileState.isUploadScheduled = true; + return; + } + + fileState.isUploading = true; + + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); const uploads = await workspaceDatabase .selectFrom('uploads') @@ -105,7 +213,26 @@ class FileService { return; } - const filesDir = getWorkspaceFilesDirectoryPath(credentials.userId); + const workspace = await databaseService.appDatabase + .selectFrom('workspaces') + .innerJoin('accounts', 'workspaces.account_id', 'accounts.id') + .innerJoin('servers', 'accounts.server', 'servers.domain') + .select([ + 'workspaces.workspace_id', + 'workspaces.user_id', + 'workspaces.account_id', + 'accounts.token', + 'servers.domain', + 'servers.attributes', + ]) + .where('workspaces.user_id', '=', userId) + .executeTakeFirst(); + + if (!workspace) { + return; + } + + const filesDir = getWorkspaceFilesDirectoryPath(userId); for (const upload of uploads) { if (upload.retry_count >= 5) { await workspaceDatabase @@ -134,7 +261,7 @@ class FileService { const attributes: FileAttributes = JSON.parse(file.attributes); const filePath = path.join( filesDir, - `${upload.node_id}${attributes.extension}` + `${upload.node_id}_${upload.upload_id}${attributes.extension}` ); if (!fs.existsSync(filePath)) { @@ -146,17 +273,20 @@ class FileService { continue; } - if (!serverService.isAvailable(credentials.serverDomain)) { + if (!serverService.isAvailable(workspace.domain)) { continue; } try { - const { data } = await httpClient.post( - `/v1/files/${credentials.workspaceId}/${upload.node_id}`, - {}, + const { data } = await httpClient.post( + `/v1/files/${workspace.workspace_id}`, { - domain: credentials.serverDomain, - token: credentials.token, + fileId: file.id, + uploadId: upload.upload_id, + }, + { + domain: workspace.domain, + token: workspace.token, } ); @@ -169,6 +299,19 @@ class FileService { }, }); + const { status } = await httpClient.put( + `/v1/files/${workspace.workspace_id}/${data.uploadId}`, + {}, + { + domain: workspace.domain, + token: workspace.token, + } + ); + + if (status !== 200) { + continue; + } + await workspaceDatabase .deleteFrom('uploads') .where('node_id', '=', upload.node_id) @@ -184,12 +327,9 @@ class FileService { } } - public async checkForDownloads( - credentials: WorkspaceCredentials - ): Promise { - const workspaceDatabase = await databaseService.getWorkspaceDatabase( - credentials.userId - ); + public async downloadWorkspaceFiles(userId: string): Promise { + const workspaceDatabase = + await databaseService.getWorkspaceDatabase(userId); const downloads = await workspaceDatabase .selectFrom('downloads') @@ -201,7 +341,15 @@ class FileService { return; } - const filesDir = getWorkspaceFilesDirectoryPath(credentials.userId); + const workspace = await databaseService.appDatabase + .selectFrom('workspaces') + .innerJoin('accounts', 'workspaces.account_id', 'accounts.id') + .innerJoin('servers', 'accounts.server', 'servers.domain') + .select(['workspaces.workspace_id', 'accounts.token', 'servers.domain']) + .where('workspaces.user_id', '=', userId) + .executeTakeFirst(); + + const filesDir = getWorkspaceFilesDirectoryPath(userId); if (!fs.existsSync(filesDir)) { fs.mkdirSync(filesDir, { recursive: true }); } @@ -221,9 +369,10 @@ class FileService { eventBus.publish({ type: 'download_deleted', - userId: credentials.userId, + userId, download: { nodeId: download.node_id, + uploadId: download.upload_id, createdAt: download.created_at, updatedAt: download.updated_at, progress: download.progress, @@ -237,7 +386,7 @@ class FileService { const attributes: FileAttributes = JSON.parse(file.attributes); const filePath = path.join( filesDir, - `${download.node_id}${attributes.extension}` + `${download.node_id}_${download.upload_id}${attributes.extension}` ); if (fs.existsSync(filePath)) { @@ -245,15 +394,17 @@ class FileService { .updateTable('downloads') .set({ progress: 100, + completed_at: new Date().toISOString(), }) .where('node_id', '=', download.node_id) .execute(); eventBus.publish({ type: 'download_updated', - userId: credentials.userId, + userId, download: { nodeId: download.node_id, + uploadId: download.upload_id, createdAt: download.created_at, updatedAt: download.updated_at, progress: 100, @@ -264,16 +415,16 @@ class FileService { continue; } - if (!serverService.isAvailable(credentials.serverDomain)) { + if (!serverService.isAvailable(workspace.domain)) { continue; } try { - const { data } = await httpClient.get( - `/v1/files/${credentials.workspaceId}/${download.node_id}`, + const { data } = await httpClient.get( + `/v1/files/${workspace.workspace_id}/${download.node_id}`, { - domain: credentials.serverDomain, - token: credentials.token, + domain: workspace.domain, + token: workspace.token, } ); @@ -293,9 +444,10 @@ class FileService { eventBus.publish({ type: 'download_updated', - userId: credentials.userId, + userId, download: { nodeId: download.node_id, + uploadId: download.upload_id, createdAt: download.created_at, updatedAt: download.updated_at, progress: 100, @@ -313,9 +465,10 @@ class FileService { eventBus.publish({ type: 'download_updated', - userId: credentials.userId, + userId, download: { nodeId: download.node_id, + uploadId: download.upload_id, createdAt: download.created_at, updatedAt: download.updated_at, progress: download.progress, diff --git a/apps/desktop/src/main/services/node-service.ts b/apps/desktop/src/main/services/node-service.ts index 63e5b990..8dbb7b26 100644 --- a/apps/desktop/src/main/services/node-service.ts +++ b/apps/desktop/src/main/services/node-service.ts @@ -162,6 +162,7 @@ class NodeService { if (createdDownloadRow) { createdDownloads.push({ nodeId: createdDownloadRow.node_id, + uploadId: createdDownloadRow.upload_id, createdAt: createdDownloadRow.created_at, updatedAt: createdDownloadRow.updated_at, progress: createdDownloadRow.progress, diff --git a/apps/desktop/src/main/services/query-service.ts b/apps/desktop/src/main/services/query-service.ts index 504e176f..fa2ec94a 100644 --- a/apps/desktop/src/main/services/query-service.ts +++ b/apps/desktop/src/main/services/query-service.ts @@ -66,6 +66,7 @@ class QueryService { >; let result = query.result; + let hasChanges = false; for (const event of events) { const changeCheckResult = await handler.checkForChanges( event, @@ -75,9 +76,14 @@ class QueryService { if (changeCheckResult.hasChanges) { result = changeCheckResult.result; + hasChanges = true; } } + if (!hasChanges) { + continue; + } + if (isEqual(result, query.result)) { continue; } diff --git a/apps/desktop/src/main/utils.ts b/apps/desktop/src/main/utils.ts index 20ff3934..819a2f88 100644 --- a/apps/desktop/src/main/utils.ts +++ b/apps/desktop/src/main/utils.ts @@ -9,6 +9,7 @@ import { import path from 'path'; import { SelectChange, + SelectDownload, SelectNode, WorkspaceDatabaseSchema, } from '@/main/data/workspace/schema'; @@ -21,6 +22,7 @@ import { } from './data/app/schema'; import { Workspace } from '@/shared/types/workspaces'; import { Server } from '@/shared/types/servers'; +import { Download } from '@/shared/types/nodes'; export const appPath = app.getPath('userData'); @@ -145,3 +147,14 @@ export const mapServer = (row: SelectServer): Server => { lastSyncedAt: row.last_synced_at ? new Date(row.last_synced_at) : null, }; }; + +export const mapDownload = (row: SelectDownload): Download => { + return { + nodeId: row.node_id, + uploadId: row.upload_id, + progress: row.progress, + createdAt: row.created_at, + updatedAt: row.updated_at, + retryCount: row.retry_count, + }; +}; diff --git a/apps/desktop/src/renderer/components/files/file-block.tsx b/apps/desktop/src/renderer/components/files/file-block.tsx index c705ed4b..6b826b30 100644 --- a/apps/desktop/src/renderer/components/files/file-block.tsx +++ b/apps/desktop/src/renderer/components/files/file-block.tsx @@ -1,8 +1,6 @@ -import { getFileUrl } from '@/shared/lib/files'; import { useWorkspace } from '@/renderer/contexts/workspace'; import { useQuery } from '@/renderer/hooks/use-query'; import { FilePreview } from '@/renderer/components/files/file-preview'; -import { FileDownload } from '@/renderer/components/files/file-download'; interface FileBlockProps { id: string; @@ -21,8 +19,6 @@ export const FileBlock = ({ id }: FileBlockProps) => { return null; } - const downloadProgress: number = 0; - const url = getFileUrl(workspace.userId, data.id, data.attributes.extension); return (
{ workspace.openInModal(id); }} > - {downloadProgress !== 100 ? ( - - ) : ( - - )} +
); }; diff --git a/apps/desktop/src/renderer/components/files/file-body.tsx b/apps/desktop/src/renderer/components/files/file-body.tsx index 5e3aa3e2..00cfbc8b 100644 --- a/apps/desktop/src/renderer/components/files/file-body.tsx +++ b/apps/desktop/src/renderer/components/files/file-body.tsx @@ -2,10 +2,8 @@ import { FileNode } from '@colanode/core'; import { Button } from '@/renderer/components/ui/button'; import { useWorkspace } from '@/renderer/contexts/workspace'; import { SquareArrowOutUpRight } from 'lucide-react'; -import { FileDownload } from '@/renderer/components/files/file-download'; import { FilePreview } from '@/renderer/components/files/file-preview'; import { FileSidebar } from '@/renderer/components/files/file-sidebar'; -import { getFileUrl } from '@/shared/lib/files'; interface FileBodyProps { file: FileNode; @@ -13,9 +11,7 @@ interface FileBodyProps { export const FileBody = ({ file }: FileBodyProps) => { const workspace = useWorkspace(); - const downloadProgress = 100; - const url = getFileUrl(workspace.userId, file.id, file.attributes.extension); return (
@@ -35,20 +31,12 @@ export const FileBody = ({ file }: FileBodyProps) => { Open
-
- {downloadProgress !== 100 ? ( - - ) : ( - - )} +
+
- +
); diff --git a/apps/desktop/src/renderer/components/files/file-download.tsx b/apps/desktop/src/renderer/components/files/file-download.tsx index f96a43f4..4c856021 100644 --- a/apps/desktop/src/renderer/components/files/file-download.tsx +++ b/apps/desktop/src/renderer/components/files/file-download.tsx @@ -12,7 +12,8 @@ export const FileDownload = ({ id, downloadProgress }: FileDownloadProps) => { const workspace = useWorkspace(); const { mutate } = useMutation(); - if (downloadProgress === null) { + const isDownloading = typeof downloadProgress === 'number'; + if (!isDownloading) { return (
{
- + {container.mode === 'main' && } {container.mode === 'modal' && ( )} diff --git a/apps/desktop/src/renderer/components/files/file-preview.tsx b/apps/desktop/src/renderer/components/files/file-preview.tsx index 181bdce9..e30e865c 100644 --- a/apps/desktop/src/renderer/components/files/file-preview.tsx +++ b/apps/desktop/src/renderer/components/files/file-preview.tsx @@ -1,20 +1,43 @@ import { match } from 'ts-pattern'; -import { getFilePreviewType } from '@/shared/lib/files'; import { FilePreviewImage } from '@/renderer/components/files/previews/file-preview-image'; import { FilePreviewVideo } from '@/renderer/components/files/previews/file-preview-video'; import { FilePreviewOther } from '@/renderer/components/files/previews/file-preview-other'; +import { FileNode } from '@colanode/core'; +import { useWorkspace } from '@/renderer/contexts/workspace'; +import { useQuery } from '@/renderer/hooks/use-query'; +import { getFileUrl } from '@/shared/lib/files'; +import { FileDownload } from '@/renderer/components/files/file-download'; interface FilePreviewProps { - url: string; - name: string; - mimeType: string; + file: FileNode; } -export const FilePreview = ({ url, name, mimeType }: FilePreviewProps) => { - const previewType = getFilePreviewType(mimeType); - return match(previewType) - .with('image', () => ) +export const FilePreview = ({ file }: FilePreviewProps) => { + const workspace = useWorkspace(); + const { data } = useQuery({ + type: 'download_get', + nodeId: file.id, + userId: workspace.userId, + }); + + if (!data || data.progress !== 100) { + return ; + } + + const url = getFileUrl( + workspace.userId, + file.id, + file.attributes.uploadId, + file.attributes.extension + ); + + return match(file.attributes.subtype) + .with('image', () => ( + + )) .with('video', () => ) - .with('other', () => ) + .with('other', () => ( + + )) .otherwise(() => null); }; diff --git a/apps/desktop/src/renderer/components/files/file-sidebar.tsx b/apps/desktop/src/renderer/components/files/file-sidebar.tsx index dace3e42..6d65f8e1 100644 --- a/apps/desktop/src/renderer/components/files/file-sidebar.tsx +++ b/apps/desktop/src/renderer/components/files/file-sidebar.tsx @@ -9,7 +9,6 @@ import { useQuery } from '@/renderer/hooks/use-query'; interface FileSidebarProps { file: FileNode; - downloadProgress: number; } const FileMeta = ({ title, value }: { title: string; value: string }) => { @@ -21,7 +20,7 @@ const FileMeta = ({ title, value }: { title: string; value: string }) => { ); }; -export const FileSidebar = ({ file, downloadProgress }: FileSidebarProps) => { +export const FileSidebar = ({ file }: FileSidebarProps) => { const workspace = useWorkspace(); const { data } = useQuery({ type: 'node_get', @@ -35,11 +34,7 @@ export const FileSidebar = ({ file, downloadProgress }: FileSidebarProps) => {
{ +export const FileThumbnail = ({ file, className }: FileThumbnailProps) => { const workspace = useWorkspace(); + const { data } = useQuery({ + type: 'download_get', + nodeId: file.id, + userId: workspace.userId, + }); - if (downloadProgress === 100 && mimeType.startsWith('image')) { - const url = getFileUrl(workspace.userId, id, extension); + if ( + file.attributes.subtype === 'image' && + data && + data.progress === 100 && + data.uploadId === file.attributes.uploadId + ) { + const url = getFileUrl( + workspace.userId, + file.id, + file.attributes.uploadId, + file.attributes.extension + ); return ( {name} ); } - return ; + return ; }; diff --git a/apps/desktop/src/renderer/components/folders/grids/grid-file.tsx b/apps/desktop/src/renderer/components/folders/grids/grid-file.tsx index 568ecdc2..ee8b36fb 100644 --- a/apps/desktop/src/renderer/components/folders/grids/grid-file.tsx +++ b/apps/desktop/src/renderer/components/folders/grids/grid-file.tsx @@ -1,7 +1,7 @@ -import { FileNode } from '@/shared/types/files'; +import { FileNode } from '@colanode/core'; import { FileThumbnail } from '@/renderer/components/files/file-thumbnail'; import { FileContextMenu } from '@/renderer/components/files/file-context-menu'; -import { GridItem } from './grid-item'; +import { GridItem } from '@/renderer/components/folders/grids/grid-item'; interface GridFileProps { file: FileNode; @@ -12,20 +12,13 @@ export const GridFile = ({ file }: GridFileProps) => {
- +

- {file.name} + {file.attributes.name}

diff --git a/apps/desktop/src/renderer/contexts/folder.ts b/apps/desktop/src/renderer/contexts/folder.ts index 8c8b8d6d..58b6f3d9 100644 --- a/apps/desktop/src/renderer/contexts/folder.ts +++ b/apps/desktop/src/renderer/contexts/folder.ts @@ -1,4 +1,4 @@ -import { FileNode } from '@/shared/types/files'; +import { FileNode } from '@colanode/core'; import { createContext, useContext } from 'react'; interface FolderContext { diff --git a/apps/desktop/src/renderer/editor/extensions/file-placeholder.tsx b/apps/desktop/src/renderer/editor/extensions/file-placeholder.tsx index 29d1f778..91ca0743 100644 --- a/apps/desktop/src/renderer/editor/extensions/file-placeholder.tsx +++ b/apps/desktop/src/renderer/editor/extensions/file-placeholder.tsx @@ -32,6 +32,9 @@ export const FilePlaceholderNode = Node.create({ mimeType: { default: null, }, + type: { + default: null, + }, }; }, renderHTML({ HTMLAttributes }) { @@ -57,6 +60,7 @@ export const FilePlaceholderNode = Node.create({ extension: metadata.extension, mimeType: metadata.mimeType, name: metadata.name, + type: metadata.type, }, }) .run(); diff --git a/apps/desktop/src/renderer/editor/views/file-placeholder.tsx b/apps/desktop/src/renderer/editor/views/file-placeholder.tsx index 75a1c00f..db5f62ff 100644 --- a/apps/desktop/src/renderer/editor/views/file-placeholder.tsx +++ b/apps/desktop/src/renderer/editor/views/file-placeholder.tsx @@ -1,8 +1,11 @@ import { type NodeViewProps } from '@tiptap/core'; +import { match } from 'ts-pattern'; import { NodeViewWrapper } from '@tiptap/react'; -import { FilePreview } from '@/renderer/components/files/file-preview'; import { getFilePlaceholderUrl } from '@/shared/lib/files'; import { X } from 'lucide-react'; +import { FilePreviewImage } from '@/renderer/components/files/previews/file-preview-image'; +import { FilePreviewVideo } from '@/renderer/components/files/previews/file-preview-video'; +import { FilePreviewOther } from '@/renderer/components/files/previews/file-preview-other'; export const FilePlaceholderNodeView = ({ node, @@ -11,6 +14,7 @@ export const FilePlaceholderNodeView = ({ const path = node.attrs.path; const mimeType = node.attrs.mimeType; const name = node.attrs.name; + const type = node.attrs.type; if (!path || !mimeType) { return null; @@ -29,7 +33,11 @@ export const FilePlaceholderNodeView = ({ > - + {match(type) + .with('image', () => ) + .with('video', () => ) + .with('other', () => ) + .otherwise(() => null)}
); diff --git a/apps/desktop/src/renderer/root.tsx b/apps/desktop/src/renderer/root.tsx index 02a6fa74..681dbc76 100644 --- a/apps/desktop/src/renderer/root.tsx +++ b/apps/desktop/src/renderer/root.tsx @@ -74,14 +74,7 @@ const Root = () => { return; } - const existingData = queryClient.getQueryData([queryId]); - - if (!existingData) { - window.colanode.unsubscribeQuery(queryId); - return; - } else { - queryClient.setQueryData([queryId], result); - } + queryClient.setQueryData([queryId], result); } }); diff --git a/apps/desktop/src/shared/lib/files.ts b/apps/desktop/src/shared/lib/files.ts index bafa3c25..61847d5e 100644 --- a/apps/desktop/src/shared/lib/files.ts +++ b/apps/desktop/src/shared/lib/files.ts @@ -1,5 +1,3 @@ -import { FilePreviewType } from '@/shared/types/files'; - export const formatBytes = (bytes: number, decimals?: number): string => { if (bytes === 0) { return '0 Bytes'; @@ -12,24 +10,13 @@ export const formatBytes = (bytes: number, decimals?: number): string => { return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; }; -export const getFilePreviewType = (mimeType: string): FilePreviewType => { - if (mimeType.startsWith('image')) { - return 'image'; - } - - if (mimeType.startsWith('video')) { - return 'video'; - } - - return 'other'; -}; - export const getFileUrl = ( userId: string, fileId: string, + uploadId: string, extension: string ) => { - return `local-file://${userId}/${fileId}${extension}`; + return `local-file://${userId}/${fileId}_${uploadId}${extension}`; }; export const getFilePlaceholderUrl = (path: string) => { diff --git a/apps/desktop/src/shared/queries/download-get.ts b/apps/desktop/src/shared/queries/download-get.ts new file mode 100644 index 00000000..8d0bf2f3 --- /dev/null +++ b/apps/desktop/src/shared/queries/download-get.ts @@ -0,0 +1,16 @@ +import { Download } from '@/shared/types/nodes'; + +export type DownloadGetQueryInput = { + type: 'download_get'; + nodeId: string; + userId: string; +}; + +declare module '@/shared/queries' { + interface QueryMap { + download_get: { + input: DownloadGetQueryInput; + output: Download | null; + }; + } +} diff --git a/apps/desktop/src/shared/queries/file-list.ts b/apps/desktop/src/shared/queries/file-list.ts index cd18754f..0ab46d21 100644 --- a/apps/desktop/src/shared/queries/file-list.ts +++ b/apps/desktop/src/shared/queries/file-list.ts @@ -1,4 +1,4 @@ -import { FileNode } from '@/shared/types/files'; +import { FileNode } from '@colanode/core'; export type FileListQueryInput = { type: 'file_list'; diff --git a/apps/desktop/src/shared/types/files.ts b/apps/desktop/src/shared/types/files.ts index e090a49d..5c9624f8 100644 --- a/apps/desktop/src/shared/types/files.ts +++ b/apps/desktop/src/shared/types/files.ts @@ -1,25 +1,4 @@ -export type ServerFileUploadResponse = { - id: string; - url: string; -}; - -export type ServerFileDownloadResponse = { - id: string; - url: string; -}; - -export type FileNode = { - id: string; - name: string; - mimeType: string; - size: number; - extension: string; - fileName: string; - createdAt: string; - downloadProgress?: number | null; -}; - -export type FilePreviewType = 'image' | 'video' | 'other'; +import { FileType } from '@colanode/core'; export type FileMetadata = { path: string; @@ -27,4 +6,5 @@ export type FileMetadata = { extension: string; name: string; size: number; + type: FileType; }; diff --git a/apps/desktop/src/shared/types/nodes.ts b/apps/desktop/src/shared/types/nodes.ts index a5af6ba3..23b3e046 100644 --- a/apps/desktop/src/shared/types/nodes.ts +++ b/apps/desktop/src/shared/types/nodes.ts @@ -20,6 +20,7 @@ export type UserNode = { export type Download = { nodeId: string; + uploadId: string; createdAt: string; updatedAt: string | null; progress: number; diff --git a/apps/server/src/data/migrations.ts b/apps/server/src/data/migrations.ts index 5ab4558d..d63aeb62 100644 --- a/apps/server/src/data/migrations.ts +++ b/apps/server/src/data/migrations.ts @@ -253,6 +253,29 @@ const createDeviceNodesTable: Migration = { }, }; +const createUploadsTable: Migration = { + up: async (db) => { + await db.schema + .createTable('uploads') + .addColumn('node_id', 'varchar(30)', (col) => + col.notNull().references('nodes.id').onDelete('no action').primaryKey() + ) + .addColumn('upload_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('workspace_id', 'varchar(30)', (col) => col.notNull()) + .addColumn('path', 'varchar(256)', (col) => col.notNull()) + .addColumn('size', 'integer', (col) => col.notNull()) + .addColumn('mime_type', 'varchar(256)', (col) => col.notNull()) + .addColumn('type', 'varchar(30)', (col) => col.notNull()) + .addColumn('created_by', 'varchar(30)', (col) => col.notNull()) + .addColumn('created_at', 'timestamptz', (col) => col.notNull()) + .addColumn('completed_at', 'timestamptz', (col) => col.notNull()) + .execute(); + }, + down: async (db) => { + await db.schema.dropTable('uploads').execute(); + }, +}; + export const databaseMigrations: Record = { '00001_create_accounts_table': createAccountsTable, '00002_create_devices_table': createDevicesTable, @@ -262,4 +285,5 @@ export const databaseMigrations: Record = { '00006_create_node_paths_table': createNodePathsTable, '00007_create_user_nodes_table': createUserNodesTable, '00008_create_device_nodes_table': createDeviceNodesTable, + '00009_create_uploads_table': createUploadsTable, }; diff --git a/apps/server/src/data/schema.ts b/apps/server/src/data/schema.ts index 39555361..1273925b 100644 --- a/apps/server/src/data/schema.ts +++ b/apps/server/src/data/schema.ts @@ -146,6 +146,19 @@ export type SelectDeviceNode = Selectable; export type CreateDeviceNode = Insertable; export type UpdateDeviceNode = Updateable; +interface UploadTable { + node_id: ColumnType; + upload_id: ColumnType; + workspace_id: ColumnType; + path: ColumnType; + size: ColumnType; + mime_type: ColumnType; + type: ColumnType; + created_by: ColumnType; + created_at: ColumnType; + completed_at: ColumnType; +} + export interface DatabaseSchema { accounts: AccountTable; devices: DeviceTable; @@ -155,4 +168,5 @@ export interface DatabaseSchema { node_paths: NodePathTable; user_nodes: UserNodeTable; device_nodes: DeviceNodeTable; + uploads: UploadTable; } diff --git a/apps/server/src/routes/files.ts b/apps/server/src/routes/files.ts index cdde8667..14f3695e 100644 --- a/apps/server/src/routes/files.ts +++ b/apps/server/src/routes/files.ts @@ -3,9 +3,25 @@ import { BUCKET_NAMES, filesStorage } from '@/data/storage'; import { hasCollaboratorAccess, hasViewerAccess } from '@/lib/constants'; import { fetchNodeRole } from '@/lib/nodes'; import { ApiError, ColanodeRequest, ColanodeResponse } from '@/types/api'; -import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import { + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Router } from 'express'; +import { + CreateDownloadOutput, + CreateUploadInput, + CreateUploadOutput, + extractFileType, + generateId, + IdType, + UploadMetadata, +} from '@colanode/core'; +import { redis } from '@/data/redis'; +import { YDoc } from '@colanode/crdt'; +import { enqueueEvent } from '@/queues/events'; export const filesRouter = Router(); @@ -77,25 +93,43 @@ filesRouter.get( }); } + // check if the upload is completed + const upload = await database + .selectFrom('uploads') + .selectAll() + .where('node_id', '=', fileId) + .executeTakeFirst(); + + if (!upload) { + return res.status(400).json({ + code: ApiError.BadRequest, + message: 'Upload not completed.', + }); + } + //generate presigned url for download const command = new GetObjectCommand({ Bucket: BUCKET_NAMES.FILES, - Key: `files/${fileId}${node.attributes.extension}`, + Key: upload.path, }); const presignedUrl = await getSignedUrl(filesStorage, command, { expiresIn: 60 * 60 * 4, // 4 hours }); - res.status(200).json({ url: presignedUrl }); + const output: CreateDownloadOutput = { + url: presignedUrl, + }; + + res.status(200).json(output); } ); filesRouter.post( - '/:workspaceId/:fileId', + '/:workspaceId', async (req: ColanodeRequest, res: ColanodeResponse) => { const workspaceId = req.params.workspaceId as string; - const fileId = req.params.fileId as string; + const input = req.body as CreateUploadInput; if (!req.account) { return res.status(401).json({ @@ -131,18 +165,10 @@ filesRouter.post( }); } - const role = await fetchNodeRole(fileId, workspaceUser.id); - if (role === null || !hasViewerAccess(role)) { - return res.status(403).json({ - code: ApiError.Forbidden, - message: 'Forbidden.', - }); - } - const node = await database .selectFrom('nodes') .selectAll() - .where('id', '=', fileId) + .where('id', '=', input.fileId) .executeTakeFirst(); if (!node) { @@ -159,18 +185,214 @@ filesRouter.post( }); } + if (node.created_by !== workspaceUser.id) { + return res.status(403).json({ + code: ApiError.Forbidden, + message: 'Forbidden.', + }); + } + //generate presigned url for upload + const path = `files/${workspaceId}/${input.fileId}_${input.uploadId}${node.attributes.extension}`; const command = new PutObjectCommand({ Bucket: BUCKET_NAMES.FILES, - Key: `files/${fileId}${node.attributes.extension}`, + Key: path, ContentLength: node.attributes.size, ContentType: node.attributes.mimeType, }); + const expiresIn = 60 * 60 * 4; // 4 hours const presignedUrl = await getSignedUrl(filesStorage, command, { - expiresIn: 60 * 60 * 4, // 4 hours + expiresIn, }); - res.status(200).json({ url: presignedUrl }); + const data: UploadMetadata = { + fileId: input.fileId, + path, + mimeType: node.attributes.mimeType, + size: node.attributes.size, + uploadId: input.uploadId, + createdAt: new Date().toISOString(), + }; + + await redis.set(input.uploadId, JSON.stringify(data), { + EX: expiresIn, + }); + + const output: CreateUploadOutput = { + uploadId: input.uploadId, + url: presignedUrl, + }; + + res.status(200).json(output); + } +); + +filesRouter.put( + '/:workspaceId/:uploadId', + async (req: ColanodeRequest, res: ColanodeResponse) => { + const workspaceId = req.params.workspaceId as string; + const uploadId = req.params.uploadId 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 metadataJson = await redis.get(uploadId); + if (!metadataJson) { + return res.status(404).json({ + code: ApiError.ResourceNotFound, + message: 'Upload not found.', + }); + } + + const metadata: UploadMetadata = JSON.parse(metadataJson); + const file = await database + .selectFrom('nodes') + .selectAll() + .where('id', '=', metadata.fileId) + .executeTakeFirst(); + + if (!file) { + return res.status(404).json({ + code: ApiError.ResourceNotFound, + message: 'File not found.', + }); + } + + if (file.attributes.type !== 'file') { + return res.status(400).json({ + code: ApiError.BadRequest, + message: 'File not found.', + }); + } + + if (file.attributes.size !== metadata.size) { + return res.status(400).json({ + code: ApiError.BadRequest, + message: 'Size mismatch.', + }); + } + + const path = metadata.path; + // check if the file exists in the bucket + const command = new HeadObjectCommand({ + Bucket: BUCKET_NAMES.FILES, + Key: path, + }); + + try { + const headObject = await filesStorage.send(command); + + // Verify file size matches expected size + if (headObject.ContentLength !== metadata.size) { + return res.status(400).json({ + code: ApiError.BadRequest, + message: 'Uploaded file size does not match expected size', + }); + } + + // Verify mime type matches expected type + if (headObject.ContentType !== metadata.mimeType) { + return res.status(400).json({ + code: ApiError.BadRequest, + message: 'Uploaded file type does not match expected type', + }); + } + } catch (error) { + return res.status(400).json({ + code: ApiError.BadRequest, + message: 'File upload verification failed', + }); + } + + const ydoc = new YDoc(file.id, file.state); + ydoc.updateAttributes({ + ...file.attributes, + uploadStatus: 'completed', + uploadId: metadata.uploadId, + }); + + const attributes = ydoc.getAttributes(); + const state = ydoc.getState(); + const fileVersionId = generateId(IdType.Version); + const updatedAt = new Date(); + + await database.transaction().execute(async (tx) => { + await database + .insertInto('uploads') + .values({ + node_id: file.id, + upload_id: uploadId, + workspace_id: workspace.id, + path: metadata.path, + mime_type: metadata.mimeType, + size: metadata.size, + type: extractFileType(metadata.mimeType), + created_by: workspaceUser.id, + created_at: new Date(metadata.createdAt), + completed_at: updatedAt, + }) + .execute(); + + await tx + .updateTable('nodes') + .set({ + attributes: JSON.stringify(attributes), + state: state, + updated_at: updatedAt, + updated_by: workspaceUser.id, + version_id: fileVersionId, + server_updated_at: updatedAt, + }) + .where('id', '=', file.id) + .execute(); + }); + + await enqueueEvent({ + type: 'node_updated', + id: file.id, + workspaceId: workspace.id, + beforeAttributes: file.attributes, + afterAttributes: attributes, + updatedBy: workspaceUser.id, + updatedAt: updatedAt.toISOString(), + serverUpdatedAt: updatedAt.toISOString(), + versionId: fileVersionId, + }); + + await redis.del(uploadId); + + res.status(200).json({ success: true }); } ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f0b5320..a7571ac3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/constants'; export * from './lib/id'; export * from './lib/nodes'; +export * from './lib/files'; export * from './registry/block'; export * from './registry/channel'; export * from './registry/chat'; @@ -23,3 +24,4 @@ export * from './types/sync'; export * from './types/accounts'; export * from './types/messages'; export * from './types/servers'; +export * from './types/files'; diff --git a/packages/core/src/lib/files.ts b/packages/core/src/lib/files.ts new file mode 100644 index 00000000..186a66a0 --- /dev/null +++ b/packages/core/src/lib/files.ts @@ -0,0 +1,21 @@ +import { FileType } from '../types/files'; + +export const extractFileType = (mimeType: string): FileType => { + if (mimeType.startsWith('image/')) { + return 'image'; + } + + if (mimeType.startsWith('video/')) { + return 'video'; + } + + if (mimeType.startsWith('audio/')) { + return 'audio'; + } + + if (mimeType.startsWith('application/pdf')) { + return 'document'; + } + + return 'other'; +}; diff --git a/packages/core/src/lib/id.ts b/packages/core/src/lib/id.ts index 9033bc0f..98c73144 100644 --- a/packages/core/src/lib/id.ts +++ b/packages/core/src/lib/id.ts @@ -44,6 +44,7 @@ export enum IdType { File = 'fi', FilePlaceholder = 'fp', Device = 'dv', + Upload = 'up', } export const generateId = (type: IdType): string => { diff --git a/packages/core/src/registry/file.ts b/packages/core/src/registry/file.ts index aee6b7fd..3c0054fb 100644 --- a/packages/core/src/registry/file.ts +++ b/packages/core/src/registry/file.ts @@ -3,12 +3,17 @@ import { NodeModel } from './core'; export const fileAttributesSchema = z.object({ type: z.literal('file'), + subtype: z.enum(['image', 'video', 'audio', 'document', 'other']), name: z.string(), parentId: z.string(), mimeType: z.string(), size: z.number(), extension: z.string(), fileName: z.string(), + uploadStatus: z + .enum(['pending', 'completed', 'failed', 'no_space']) + .default('pending'), + uploadId: z.string(), }); export type FileAttributes = z.infer; diff --git a/packages/core/src/types/files.ts b/packages/core/src/types/files.ts new file mode 100644 index 00000000..bc883472 --- /dev/null +++ b/packages/core/src/types/files.ts @@ -0,0 +1,28 @@ +export type CreateUploadInput = { + fileId: string; + uploadId: string; +}; + +export type UploadMetadata = { + fileId: string; + uploadId: string; + path: string; + mimeType: string; + size: number; + createdAt: string; +}; + +export type CreateUploadOutput = { + uploadId: string; + url: string; +}; + +export type CreateDownloadOutput = { + url: string; +}; + +export type CompleteUploadOutput = { + success: boolean; +}; + +export type FileType = 'image' | 'video' | 'audio' | 'document' | 'other';