File upload/download improvements

This commit is contained in:
Hakan Shehu
2024-11-21 20:03:35 +01:00
parent 7f797c4149
commit de1d708c3a
39 changed files with 821 additions and 257 deletions

View File

@@ -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);

View File

@@ -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))

View File

@@ -58,6 +58,7 @@ export type UpdateChange = Updateable<ChangeTable>;
interface UploadTable {
node_id: ColumnType<string, string, never>;
upload_id: ColumnType<string, string, never>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
progress: ColumnType<number, number, number>;
@@ -70,8 +71,10 @@ export type UpdateUpload = Updateable<UploadTable>;
interface DownloadTable {
node_id: ColumnType<string, string, never>;
upload_id: ColumnType<string, string, never>;
created_at: ColumnType<string, string, never>;
updated_at: ColumnType<string | null, string | null, string | null>;
completed_at: ColumnType<string | null, string | null, string | null>;
progress: ColumnType<number, number, number>;
retry_count: ColumnType<number, number, number>;
}

View File

@@ -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,
};
}
}

View File

@@ -5,6 +5,7 @@ import {
FileDownloadMutationInput,
FileDownloadMutationOutput,
} from '@/shared/mutations/file-download';
import { mapNode } from '@/main/utils';
export class FileDownloadMutationHandler
implements MutationHandler<FileDownloadMutationInput>
@@ -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,

View File

@@ -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,

View File

@@ -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<DownloadGetQueryInput>
{
public async handleQuery(
input: DownloadGetQueryInput
): Promise<Download | null> {
const row = await this.fetchDownload(input);
return row ? mapDownload(row) : null;
}
public async checkForChanges(
event: Event,
input: DownloadGetQueryInput,
_: Download | null
): Promise<ChangeCheckResult<DownloadGetQueryInput>> {
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<SelectDownload | undefined> {
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
input.userId
);
const row = await workspaceDatabase
.selectFrom('downloads')
.selectAll()
.where('node_id', '=', input.nodeId)
.executeTakeFirst();
return row;
}
}

View File

@@ -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<FileListQueryInput> {
public async handleQuery(input: FileListQueryInput): Promise<FileNode[]> {
@@ -56,18 +47,19 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
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<FileListQueryInput> {
) {
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<FileListQueryInput> {
};
}
private async fetchFiles(input: FileListQueryInput): Promise<FileRow[]> {
private async fetchFiles(input: FileListQueryInput): Promise<SelectNode[]> {
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
input.userId
);
@@ -118,18 +93,7 @@ export class FileListQueryHandler implements QueryHandler<FileListQueryInput> {
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<FileListQueryInput> {
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));

View File

@@ -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<QueryMap[K]['input']>;
@@ -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(),
};

View File

@@ -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) {

View File

@@ -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<string, WorkspaceFileState> = 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<Response> {
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<void> {
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<void> {
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<void> {
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<void> {
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<ServerFileUploadResponse>(
`/v1/files/${credentials.workspaceId}/${upload.node_id}`,
{},
const { data } = await httpClient.post<CreateUploadOutput>(
`/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<CompleteUploadOutput>(
`/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<void> {
const workspaceDatabase = await databaseService.getWorkspaceDatabase(
credentials.userId
);
public async downloadWorkspaceFiles(userId: string): Promise<void> {
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<ServerFileDownloadResponse>(
`/v1/files/${credentials.workspaceId}/${download.node_id}`,
const { data } = await httpClient.get<CreateDownloadOutput>(
`/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,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,
};
};

View File

@@ -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 (
<div
className="flex h-72 max-h-72 w-full cursor-pointer overflow-hidden rounded-md p-2 hover:bg-gray-100"
@@ -30,15 +26,7 @@ export const FileBlock = ({ id }: FileBlockProps) => {
workspace.openInModal(id);
}}
>
{downloadProgress !== 100 ? (
<FileDownload id={data.id} downloadProgress={downloadProgress} />
) : (
<FilePreview
url={url}
name={data.attributes.name}
mimeType={data.attributes.mimeType}
/>
)}
<FilePreview file={data} />
</div>
);
};

View File

@@ -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 (
<div className="flex h-full max-h-full w-full flex-row items-center gap-2">
<div className="flex h-full max-h-full w-full max-w-full flex-grow flex-col items-center justify-center overflow-hidden">
@@ -35,20 +31,12 @@ export const FileBody = ({ file }: FileBodyProps) => {
<SquareArrowOutUpRight className="mr-1 size-4" /> Open
</Button>
</div>
<div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden">
{downloadProgress !== 100 ? (
<FileDownload id={file.id} downloadProgress={downloadProgress} />
) : (
<FilePreview
url={url}
name={file.attributes.name}
mimeType={file.attributes.mimeType}
/>
)}
<div className="flex w-full max-w-full flex-grow items-center justify-center overflow-hidden p-10">
<FilePreview file={file} />
</div>
</div>
<div className="h-full w-72 min-w-72 overflow-hidden border-l border-gray-100 p-2 pl-3">
<FileSidebar file={file} downloadProgress={downloadProgress} />
<FileSidebar file={file} />
</div>
</div>
);

View File

@@ -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 (
<div
className="flex cursor-pointer flex-col items-center gap-3 text-muted-foreground hover:text-primary"

View File

@@ -19,7 +19,7 @@ export const FileHeader = ({ nodes, file, role }: FileHeaderProps) => {
<Header>
<div className="flex w-full items-center gap-2 px-4">
<div className="flex-grow">
<NodeBreadcrumb nodes={nodes} />
{container.mode === 'main' && <NodeBreadcrumb nodes={nodes} />}
{container.mode === 'modal' && (
<NodeFullscreenButton nodeId={file.id} />
)}

View File

@@ -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', () => <FilePreviewImage url={url} name={name} />)
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 <FileDownload id={file.id} downloadProgress={data?.progress} />;
}
const url = getFileUrl(
workspace.userId,
file.id,
file.attributes.uploadId,
file.attributes.extension
);
return match(file.attributes.subtype)
.with('image', () => (
<FilePreviewImage url={url} name={file.attributes.name} />
))
.with('video', () => <FilePreviewVideo url={url} />)
.with('other', () => <FilePreviewOther mimeType={mimeType} />)
.with('other', () => (
<FilePreviewOther mimeType={file.attributes.mimeType} />
))
.otherwise(() => null);
};

View File

@@ -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) => {
<React.Fragment>
<div className="flex items-center gap-x-4 p-2">
<FileThumbnail
id={file.id}
name={file.attributes.name}
mimeType={file.attributes.mimeType}
extension={file.attributes.extension}
downloadProgress={downloadProgress}
file={file}
className="h-12 w-9 min-w-[36px] overflow-hidden rounded object-contain"
/>
<div

View File

@@ -1,37 +1,43 @@
import { getFileUrl } from '@/shared/lib/files';
import { useWorkspace } from '@/renderer/contexts/workspace';
import { cn } from '@/shared/lib/utils';
import { FileIcon } from '@/renderer/components/files/file-icon';
import { cn } from '@/shared/lib/utils';
import { FileNode } from '@colanode/core';
import { useQuery } from '@/renderer/hooks/use-query';
interface FileThumbnailProps {
id: string;
name: string;
mimeType: string;
extension: string;
downloadProgress?: number | null;
file: FileNode;
className?: string;
}
export const FileThumbnail = ({
id,
name,
mimeType,
extension,
downloadProgress,
className,
}: FileThumbnailProps) => {
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 (
<img
src={url}
alt={name}
alt={file.attributes.name}
className={cn('object-contain object-center', className)}
/>
);
}
return <FileIcon mimeType={mimeType} className="size-10" />;
return <FileIcon mimeType={file.attributes.mimeType} className="size-10" />;
};

View File

@@ -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) => {
<FileContextMenu id={file.id}>
<GridItem id={file.id}>
<div className="flex w-full justify-center">
<FileThumbnail
id={file.id}
mimeType={file.mimeType}
extension={file.extension}
downloadProgress={file.downloadProgress}
name={file.name}
className="h-14 w-14"
/>
<FileThumbnail file={file} className="h-14 w-14" />
</div>
<p
className="line-clamp-2 w-full break-words text-center text-xs text-foreground/80"
title={file.name}
title={file.attributes.name}
>
{file.name}
{file.attributes.name}
</p>
</GridItem>
</FileContextMenu>

View File

@@ -1,4 +1,4 @@
import { FileNode } from '@/shared/types/files';
import { FileNode } from '@colanode/core';
import { createContext, useContext } from 'react';
interface FolderContext {

View File

@@ -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();

View File

@@ -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 = ({
>
<X className="size-4" />
</button>
<FilePreview url={url} name={name} mimeType={mimeType} />
{match(type)
.with('image', () => <FilePreviewImage url={url} name={name} />)
.with('video', () => <FilePreviewVideo url={url} />)
.with('other', () => <FilePreviewOther mimeType={mimeType} />)
.otherwise(() => null)}
</div>
</NodeViewWrapper>
);

View File

@@ -74,14 +74,7 @@ const Root = () => {
return;
}
const existingData = queryClient.getQueryData<any>([queryId]);
if (!existingData) {
window.colanode.unsubscribeQuery(queryId);
return;
} else {
queryClient.setQueryData([queryId], result);
}
queryClient.setQueryData([queryId], result);
}
});

View File

@@ -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) => {

View File

@@ -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;
};
}
}

View File

@@ -1,4 +1,4 @@
import { FileNode } from '@/shared/types/files';
import { FileNode } from '@colanode/core';
export type FileListQueryInput = {
type: 'file_list';

View File

@@ -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;
};

View File

@@ -20,6 +20,7 @@ export type UserNode = {
export type Download = {
nodeId: string;
uploadId: string;
createdAt: string;
updatedAt: string | null;
progress: number;

View File

@@ -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<string, Migration> = {
'00001_create_accounts_table': createAccountsTable,
'00002_create_devices_table': createDevicesTable,
@@ -262,4 +285,5 @@ export const databaseMigrations: Record<string, Migration> = {
'00006_create_node_paths_table': createNodePathsTable,
'00007_create_user_nodes_table': createUserNodesTable,
'00008_create_device_nodes_table': createDeviceNodesTable,
'00009_create_uploads_table': createUploadsTable,
};

View File

@@ -146,6 +146,19 @@ export type SelectDeviceNode = Selectable<DeviceNodeTable>;
export type CreateDeviceNode = Insertable<DeviceNodeTable>;
export type UpdateDeviceNode = Updateable<DeviceNodeTable>;
interface UploadTable {
node_id: ColumnType<string, string, never>;
upload_id: ColumnType<string, string, never>;
workspace_id: ColumnType<string, string, never>;
path: ColumnType<string, string, string>;
size: ColumnType<number, number, number>;
mime_type: ColumnType<string, string, string>;
type: ColumnType<string, string, string>;
created_by: ColumnType<string, string, never>;
created_at: ColumnType<Date, Date, never>;
completed_at: ColumnType<Date, Date, never>;
}
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;
}

View File

@@ -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 });
}
);

View File

@@ -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';

View File

@@ -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';
};

View File

@@ -44,6 +44,7 @@ export enum IdType {
File = 'fi',
FilePlaceholder = 'fp',
Device = 'dv',
Upload = 'up',
}
export const generateId = (type: IdType): string => {

View File

@@ -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<typeof fileAttributesSchema>;

View File

@@ -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';