diff --git a/package.json b/package.json index ebd003e86..03a7ce82c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never", "publish": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish always", "postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts && opencollective-postinstall", - "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:sync-engine && npm run start:renderer && npm run start:backups", + "start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:sync-engine && npm run start:renderer && npm run start:backups", "start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/apps/main/main.ts", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", "start:sync": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.sync.ts", diff --git a/src/apps/backups/Backups.ts b/src/apps/backups/Backups.ts index d6454973f..931e56b1d 100644 --- a/src/apps/backups/Backups.ts +++ b/src/apps/backups/Backups.ts @@ -1,237 +1,245 @@ -// import { Service } from 'diod'; -// import Logger from 'electron-log'; -// import { FileBatchUpdater } from '../../context/local/localFile/application/update/FileBatchUpdater'; -// import { FileBatchUploader } from '../../context/local/localFile/application/upload/FileBatchUploader'; -// import { LocalFile } from '../../context/local/localFile/domain/LocalFile'; -// import { AbsolutePath } from '../../context/local/localFile/infrastructure/AbsolutePath'; -// import LocalTreeBuilder from '../../context/local/localTree/application/LocalTreeBuilder'; -// import { LocalTree } from '../../context/local/localTree/domain/LocalTree'; -// import { FileDeleter } from '../../context/virtual-drive/files/application/delete/FileDeleter'; -// import { File } from '../../context/virtual-drive/files/domain/File'; -// import { SimpleFolderCreator } from '../../context/virtual-drive/folders/application/create/SimpleFolderCreator'; -// import { RemoteTreeBuilder } from '../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder'; -// import { RemoteTree } from '../../context/virtual-drive/remoteTree/domain/RemoteTree'; -// import { BackupInfo } from './BackupInfo'; -// import { BackupsIPCRenderer } from './BackupsIPCRenderer'; -// import { AddedFilesBatchCreator } from './batches/AddedFilesBatchCreator'; -// import { ModifiedFilesBatchCreator } from './batches/ModifiedFilesBatchCreator'; -// import { DiffFilesCalculator, FilesDiff } from './diff/DiffFilesCalculator'; -// import { -// FoldersDiff, -// FoldersDiffCalculator, -// } from './diff/FoldersDiffCalculator'; -// import { relative } from './utils/relative'; -// import { DriveDesktopError } from '../../context/shared/domain/errors/DriveDesktopError'; -// import { UserAvaliableSpaceValidator } from '../../context/user/usage/application/UserAvaliableSpaceValidator'; - -// @Service() -// export class Backup { -// constructor( -// private readonly localTreeBuilder: LocalTreeBuilder, -// private readonly remoteTreeBuilder: RemoteTreeBuilder, -// private readonly fileBatchUploader: FileBatchUploader, -// private readonly fileBatchUpdater: FileBatchUpdater, -// private readonly remoteFileDeleter: FileDeleter, -// private readonly simpleFolderCreator: SimpleFolderCreator, -// private readonly userAvaliableSpaceValidator: UserAvaliableSpaceValidator -// ) {} - -// private backed = 0; - -// async run( -// info: BackupInfo, -// abortController: AbortController -// ): Promise { -// Logger.info('[BACKUPS] Backing:', info.pathname); - -// Logger.info('[BACKUPS] Generating local tree'); -// const localTreeEither = await this.localTreeBuilder.run( -// info.pathname as AbsolutePath -// ); - -// if (localTreeEither.isLeft()) { -// return localTreeEither.getLeft(); -// } - -// const local = localTreeEither.getRight(); - -// Logger.info('[BACKUPS] Generating remote tree'); -// const remote = await this.remoteTreeBuilder.run(info.folderId); - -// const foldersDiff = FoldersDiffCalculator.calculate(local, remote); - -// const filesDiff = DiffFilesCalculator.calculate(local, remote); - -// await this.isThereEnoughSpace(filesDiff); - -// const alreadyBacked = -// filesDiff.unmodified.length + foldersDiff.unmodified.length; - -// this.backed = alreadyBacked; - -// BackupsIPCRenderer.send( -// 'backups.total-items-calculated', -// filesDiff.total + foldersDiff.total, -// alreadyBacked -// ); - -// await this.backupFolders(foldersDiff, local, remote); - -// await this.backupFiles(filesDiff, local, remote, abortController); - -// return undefined; -// } - -// private async isThereEnoughSpace(filesDiff: FilesDiff): Promise { -// const bytesToUpload = filesDiff.added.reduce((acc, file) => { -// acc += file.size; - -// return acc; -// }, 0); - -// const bytesToUpdate = Array.from(filesDiff.modified.entries()).reduce( -// (acc, [local, remote]) => { -// acc += local.size - remote.size; - -// return acc; -// }, -// 0 -// ); - -// const total = bytesToUpdate + bytesToUpload; - -// const thereIsEnoughSpace = await this.userAvaliableSpaceValidator.run( -// total -// ); - -// if (!thereIsEnoughSpace) { -// throw new DriveDesktopError( -// 'NOT_ENOUGH_SPACE', -// 'The size of the files to upload is greater than the avaliable space' -// ); -// } -// } - -// private async backupFolders( -// diff: FoldersDiff, -// local: LocalTree, -// remote: RemoteTree -// ) { -// Logger.info('[BACKUPS] Backing folders'); - -// Logger.info('[BACKUPS] Folders added', diff.added.length); - -// for (const localFolder of diff.added) { -// const remoteParentPath = relative(local.root.path, localFolder.basedir()); - -// const parentExists = remote.has(remoteParentPath); - -// if (!parentExists) { -// continue; -// } - -// const parent = remote.getParent( -// relative(local.root.path, localFolder.path) -// ); - -// // eslint-disable-next-line no-await-in-loop -// const folder = await this.simpleFolderCreator.run( -// relative(local.root.path, localFolder.path), -// parent.id -// ); - -// remote.addFolder(parent, folder); - -// this.backed++; -// BackupsIPCRenderer.send('backups.progress-update', this.backed); -// } -// } - -// private async backupFiles( -// filesDiff: FilesDiff, -// local: LocalTree, -// remote: RemoteTree, -// abortController: AbortController -// ) { -// Logger.info('[BACKUPS] Backing files'); - -// const { added, modified, deleted } = filesDiff; - -// Logger.info('[BACKUPS] Files added', added.length); -// await this.uploadAndCreate(local.root.path, added, remote, abortController); - -// Logger.info('[BACKUPS] Files modified', modified.size); -// await this.uploadAndUpdate(modified, local, remote, abortController); - -// Logger.info('[BACKUPS] Files deleted', deleted.length); -// await this.deleteRemoteFiles(deleted, abortController); -// } - -// private async uploadAndCreate( -// localRootPath: string, -// added: Array, -// tree: RemoteTree, -// abortController: AbortController -// ): Promise { -// const batches = AddedFilesBatchCreator.run(added); - -// for (const batch of batches) { -// if (abortController.signal.aborted) { -// return; -// } -// // eslint-disable-next-line no-await-in-loop -// await this.fileBatchUploader.run( -// localRootPath, -// tree, -// batch, -// abortController.signal -// ); - -// this.backed += batch.length; -// BackupsIPCRenderer.send('backups.progress-update', this.backed); -// } -// } - -// private async uploadAndUpdate( -// modified: Map, -// localTree: LocalTree, -// remoteTree: RemoteTree, -// abortController: AbortController -// ): Promise { -// const batches = ModifiedFilesBatchCreator.run(modified); - -// for (const batch of batches) { -// Logger.debug('Signal aborted', abortController.signal.aborted); -// if (abortController.signal.aborted) { -// return; -// } -// // eslint-disable-next-line no-await-in-loop -// await this.fileBatchUpdater.run( -// localTree.root, -// remoteTree, -// Array.from(batch.keys()), -// abortController.signal -// ); - -// this.backed += batch.size; -// BackupsIPCRenderer.send('backups.progress-update', this.backed); -// } -// } - -// private async deleteRemoteFiles( -// deleted: Array, -// abortController: AbortController -// ) { -// for (const file of deleted) { -// if (abortController.signal.aborted) { -// return; -// } - -// // eslint-disable-next-line no-await-in-loop -// await this.remoteFileDeleter.run(file); -// } - -// this.backed += deleted.length; -// BackupsIPCRenderer.send('backups.progress-update', this.backed); -// } -// } +import { Service } from 'diod'; +import Logger from 'electron-log'; +import { FileBatchUpdater } from '../../context/local/localFile/application/update/FileBatchUpdater'; +import { FileBatchUploader } from '../../context/local/localFile/application/upload/FileBatchUploader'; +import { LocalFile } from '../../context/local/localFile/domain/LocalFile'; +import { AbsolutePath } from '../../context/local/localFile/infrastructure/AbsolutePath'; +import LocalTreeBuilder from '../../context/local/localTree/application/LocalTreeBuilder'; +import { LocalTree } from '../../context/local/localTree/domain/LocalTree'; +import { File } from '../../context/virtual-drive/files/domain/File'; +import { SimpleFolderCreator } from '../../context/virtual-drive/folders/application/create/SimpleFolderCreator'; +import { BackupInfo } from './BackupInfo'; +import { BackupsIPCRenderer } from './BackupsIPCRenderer'; +import { AddedFilesBatchCreator } from './batches/AddedFilesBatchCreator'; +import { ModifiedFilesBatchCreator } from './batches/ModifiedFilesBatchCreator'; +import { DiffFilesCalculator, FilesDiff } from './diff/DiffFilesCalculator'; +import { + FoldersDiff, + FoldersDiffCalculator, +} from './diff/FoldersDiffCalculator'; +import { relative } from './utils/relative'; +import { DriveDesktopError } from '../../context/shared/domain/errors/DriveDesktopError'; +import { UserAvaliableSpaceValidator } from '../../context/user/usage/application/UserAvaliableSpaceValidator'; +import { FileDeleter } from '../../context/virtual-drive/files/application/delete/FileDeleter'; +import { RemoteTreeBuilder } from '../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder'; +import { RemoteTree } from '../../context/virtual-drive/remoteTree/domain/RemoteTree'; + +@Service() +export class Backup { + constructor( + private readonly localTreeBuilder: LocalTreeBuilder, + private readonly remoteTreeBuilder: RemoteTreeBuilder, + private readonly fileBatchUploader: FileBatchUploader, + private readonly fileBatchUpdater: FileBatchUpdater, + private readonly remoteFileDeleter: FileDeleter, + private readonly simpleFolderCreator: SimpleFolderCreator, + private readonly userAvaliableSpaceValidator: UserAvaliableSpaceValidator + ) {} + + private backed = 0; + + async run( + info: BackupInfo, + abortController: AbortController + ): Promise { + Logger.info('[BACKUPS] Backing:', info); + + Logger.info('[BACKUPS] Generating local tree'); + const localTreeEither = await this.localTreeBuilder.run( + info.pathname as AbsolutePath + ); + + if (localTreeEither.isLeft()) { + return localTreeEither.getLeft(); + } + + const local = localTreeEither.getRight(); + + Logger.info('[BACKUPS] Generating remote tree'); + const remote = await this.remoteTreeBuilder.run(info.folderId); + + const foldersDiff = FoldersDiffCalculator.calculate(local, remote); + + const filesDiff = DiffFilesCalculator.calculate(local, remote); + + await this.isThereEnoughSpace(filesDiff); + + const alreadyBacked = + filesDiff.unmodified.length + foldersDiff.unmodified.length; + + this.backed = alreadyBacked; + + BackupsIPCRenderer.send( + 'backups.total-items-calculated', + filesDiff.total + foldersDiff.total, + alreadyBacked + ); + + await this.backupFolders(foldersDiff, local, remote); + + await this.backupFiles(filesDiff, local, remote, abortController); + + return undefined; + } + + private async isThereEnoughSpace(filesDiff: FilesDiff): Promise { + const bytesToUpload = filesDiff.added.reduce((acc, file) => { + acc += file.size; + + return acc; + }, 0); + + const bytesToUpdate = Array.from(filesDiff.modified.entries()).reduce( + (acc, [local, remote]) => { + acc += local.size - remote.size; + + return acc; + }, + 0 + ); + + const total = bytesToUpdate + bytesToUpload; + + const thereIsEnoughSpace = await this.userAvaliableSpaceValidator.run( + total + ); + + if (!thereIsEnoughSpace) { + throw new DriveDesktopError( + 'NOT_ENOUGH_SPACE', + 'The size of the files to upload is greater than the avaliable space' + ); + } + } + + private async backupFolders( + diff: FoldersDiff, + local: LocalTree, + remote: RemoteTree + ) { + Logger.info('[BACKUPS] Backing folders'); + + Logger.info('[BACKUPS] Folders added', diff.added.length); + Logger.info('[BACKUPS] Folders added', diff.added.length); + + for (const localFolder of diff.added) { + Logger.debug('[backupFolders] Remote parent path', localFolder); + + const remoteParentPath = relative(local.root.path, localFolder.basedir()); + + Logger.debug('[backupFolders] Remote parent path', remoteParentPath); + const parentExists = remote.has(remoteParentPath); + + if (!parentExists) { + continue; + } + + Logger.debug( + '[backupFolders] get Parent', + relative(local.root.path, localFolder.path) + ); + const parent = remote.getParent( + relative(local.root.path, localFolder.path) + ); + + // eslint-disable-next-line no-await-in-loop + const folder = await this.simpleFolderCreator.run( + relative(local.root.path, localFolder.path), + parent.id + ); + + remote.addFolder(parent, folder); + + this.backed++; + BackupsIPCRenderer.send('backups.progress-update', this.backed); + } + } + + private async backupFiles( + filesDiff: FilesDiff, + local: LocalTree, + remote: RemoteTree, + abortController: AbortController + ) { + Logger.info('[BACKUPS] Backing files'); + + const { added, modified, deleted } = filesDiff; + + Logger.info('[BACKUPS] Files added', added.length); + await this.uploadAndCreate(local.root.path, added, remote, abortController); + + Logger.info('[BACKUPS] Files modified', modified.size); + await this.uploadAndUpdate(modified, local, remote, abortController); + + Logger.info('[BACKUPS] Files deleted', deleted.length); + await this.deleteRemoteFiles(deleted, abortController); + } + + private async uploadAndCreate( + localRootPath: string, + added: Array, + tree: RemoteTree, + abortController: AbortController + ): Promise { + const batches = AddedFilesBatchCreator.run(added); + + for (const batch of batches) { + if (abortController.signal.aborted) { + return; + } + // eslint-disable-next-line no-await-in-loop + await this.fileBatchUploader.run( + localRootPath, + tree, + batch, + abortController.signal + ); + + this.backed += batch.length; + BackupsIPCRenderer.send('backups.progress-update', this.backed); + } + } + + private async uploadAndUpdate( + modified: Map, + localTree: LocalTree, + remoteTree: RemoteTree, + abortController: AbortController + ): Promise { + const batches = ModifiedFilesBatchCreator.run(modified); + + for (const batch of batches) { + Logger.debug('Signal aborted', abortController.signal.aborted); + if (abortController.signal.aborted) { + return; + } + // eslint-disable-next-line no-await-in-loop + await this.fileBatchUpdater.run( + localTree.root, + remoteTree, + Array.from(batch.keys()), + abortController.signal + ); + + this.backed += batch.size; + BackupsIPCRenderer.send('backups.progress-update', this.backed); + } + } + + private async deleteRemoteFiles( + deleted: Array, + abortController: AbortController + ) { + for (const file of deleted) { + if (abortController.signal.aborted) { + return; + } + + // eslint-disable-next-line no-await-in-loop + await this.remoteFileDeleter.run(file); + } + + this.backed += deleted.length; + BackupsIPCRenderer.send('backups.progress-update', this.backed); + } +} diff --git a/src/apps/backups/batches/AddedFilesBatchCreator.ts b/src/apps/backups/batches/AddedFilesBatchCreator.ts new file mode 100644 index 000000000..e2ec454e4 --- /dev/null +++ b/src/apps/backups/batches/AddedFilesBatchCreator.ts @@ -0,0 +1,17 @@ +import { GroupFilesBySize } from './GroupFilesBySize'; +import { GroupFilesInChunksBySize } from './GroupFilesInChunksBySize'; +import { LocalFile } from '../../../context/local/localFile/domain/LocalFile'; + +export class AddedFilesBatchCreator { + private static readonly sizes = ['small', 'medium', 'big'] as const; + + static run(files: Array): Array> { + const batches = AddedFilesBatchCreator.sizes.flatMap((size) => { + const groupedBySize = GroupFilesBySize[size](files); + + return GroupFilesInChunksBySize[size](groupedBySize); + }); + + return batches; + } +} diff --git a/src/apps/backups/batches/GroupFilesBySize.ts b/src/apps/backups/batches/GroupFilesBySize.ts new file mode 100644 index 000000000..92e35f244 --- /dev/null +++ b/src/apps/backups/batches/GroupFilesBySize.ts @@ -0,0 +1,15 @@ +import { LocalFile } from '../../../context/local/localFile/domain/LocalFile'; + +export class GroupFilesBySize { + static small(files: Array) { + return files.filter((file) => file.isSmall()); + } + + static medium(files: Array) { + return files.filter((file) => file.isMedium()); + } + + static big(files: Array) { + return files.filter((file) => file.isBig()); + } +} diff --git a/src/apps/backups/batches/GroupFilesInChunksBySize.ts b/src/apps/backups/batches/GroupFilesInChunksBySize.ts new file mode 100644 index 000000000..8bbdb5c30 --- /dev/null +++ b/src/apps/backups/batches/GroupFilesInChunksBySize.ts @@ -0,0 +1,37 @@ +import _ from 'lodash'; +import { LocalFile } from '../../../context/local/localFile/domain/LocalFile'; + +export type Chucks = Array>; + +const NUMBER_OF_PARALLEL_QUEUES_FOR_SMALL_FILES = 16; + +const NUMBER_OF_PARALLEL_QUEUES_FOR_MEDIUM_FILES = 6; + +const NUMBER_OF_PARALLEL_QUEUES_FOR_BIG_FILES = 2; + +export class GroupFilesInChunksBySize { + static small(all: Array): Chucks { + return GroupFilesInChunksBySize.chunk( + all, + NUMBER_OF_PARALLEL_QUEUES_FOR_SMALL_FILES + ); + } + + static medium(all: Array): Chucks { + return GroupFilesInChunksBySize.chunk( + all, + NUMBER_OF_PARALLEL_QUEUES_FOR_MEDIUM_FILES + ); + } + + static big(all: Array): Chucks { + return GroupFilesInChunksBySize.chunk( + all, + NUMBER_OF_PARALLEL_QUEUES_FOR_BIG_FILES + ); + } + + private static chunk(files: Array, size: number) { + return _.chunk(files, size); + } +} diff --git a/src/apps/backups/batches/ModifiedFilesBatchCreator.ts b/src/apps/backups/batches/ModifiedFilesBatchCreator.ts new file mode 100644 index 000000000..d855a61dd --- /dev/null +++ b/src/apps/backups/batches/ModifiedFilesBatchCreator.ts @@ -0,0 +1,28 @@ +import { GroupFilesBySize } from './GroupFilesBySize'; +import { GroupFilesInChunksBySize } from './GroupFilesInChunksBySize'; +import { LocalFile } from '../../../context/local/localFile/domain/LocalFile'; +import { File } from '../../../context/virtual-drive/files/domain/File'; + +export class ModifiedFilesBatchCreator { + private static readonly sizes = ['small', 'medium', 'big'] as const; + + static run(files: Map): Array> { + const batches = ModifiedFilesBatchCreator.sizes.flatMap((size) => { + const localFiles = Array.from(files.keys()); + + const groupedBySize = GroupFilesBySize[size](localFiles); + + return GroupFilesInChunksBySize[size](groupedBySize); + }); + + return batches.map((batch) => + batch.reduce((map, local) => { + const file = files.get(local); + + map.set(local, file); + + return map; + }, new Map()) + ); + } +} diff --git a/src/apps/backups/dependency-injection/BackupsDependencyContainerFactory.ts b/src/apps/backups/dependency-injection/BackupsDependencyContainerFactory.ts new file mode 100644 index 000000000..f1ffb644f --- /dev/null +++ b/src/apps/backups/dependency-injection/BackupsDependencyContainerFactory.ts @@ -0,0 +1,73 @@ +import { Container } from 'diod'; +import Logger from 'electron-log'; +import { backgroundProcessSharedInfraBuilder } from '../../shared/dependency-injection/background/backgroundProcessSharedInfraBuilder'; +import { registerFilesServices } from './virtual-drive/registerFilesServices'; +import { registerFolderServices } from './virtual-drive/registerFolderServices'; +import { registerLocalFileServices } from './local/registerLocalFileServices'; +import { Backup } from '../Backups'; +import { registerLocalTreeServices } from './local/registerLocalTreeServices'; +import { registerRemoteTreeServices } from './virtual-drive/registerRemoteTreeServices'; +import { registerUserUsageServices } from './user/registerUsageServices'; + +export class BackupsDependencyContainerFactory { + static async build(): Promise { + Logger.info( + '[BackupsDependencyContainerFactory] Starting to build the container.' + ); + + const builder = await backgroundProcessSharedInfraBuilder(); + Logger.info( + '[BackupsDependencyContainerFactory] Shared infrastructure builder created.' + ); + + try { + Logger.info( + '[BackupsDependencyContainerFactory] Registering file services.' + ); + await registerFilesServices(builder); + + Logger.info( + '[BackupsDependencyContainerFactory] Registering folder services.' + ); + await registerFolderServices(builder); + + Logger.info( + '[BackupsDependencyContainerFactory] Registering remote tree services.' + ); + await registerRemoteTreeServices(builder); + + Logger.info( + '[BackupsDependencyContainerFactory] Registering local file services.' + ); + await registerLocalFileServices(builder); + + Logger.info( + '[BackupsDependencyContainerFactory] Registering local tree services.' + ); + await registerLocalTreeServices(builder); + + Logger.info( + '[BackupsDependencyContainerFactory] Registering user usage services.' + ); + await registerUserUsageServices(builder); + + Logger.info( + '[BackupsDependencyContainerFactory] Registering Backup service.' + ); + await builder.registerAndUse(Backup); + const container = builder.build(); + Logger.info( + '[BackupsDependencyContainerFactory] Container built successfully.' + ); + + return container; + } catch (error) { + Logger.error( + '[BackupsDependencyContainerFactory] Error during service registration:', + error + ); + Logger.error(error); + throw error; // Rethrow the error after logging it + } + } +} diff --git a/src/apps/backups/dependency-injection/local/registerLocalFileServices.ts b/src/apps/backups/dependency-injection/local/registerLocalFileServices.ts new file mode 100644 index 000000000..567d61ebf --- /dev/null +++ b/src/apps/backups/dependency-injection/local/registerLocalFileServices.ts @@ -0,0 +1,50 @@ +import { ContainerBuilder } from 'diod'; +import { FileBatchUpdater } from '../../../../context/local/localFile/application/update/FileBatchUpdater'; +import { LocalFileHandler } from '../../../../context/local/localFile/domain/LocalFileUploader'; +import { FileBatchUploader } from '../../../../context/local/localFile/application/upload/FileBatchUploader'; +import { EnvironmentLocalFileUploader } from '../../../../context/local/localFile/infrastructure/EnvironmentLocalFileUploader'; +import { DependencyInjectionUserProvider } from '../../../shared/dependency-injection/DependencyInjectionUserProvider'; +import { Environment } from '@internxt/inxt-js'; +import { DependencyInjectionMnemonicProvider } from '../../../shared/dependency-injection/DependencyInjectionMnemonicProvider'; +import { AuthorizedClients } from '../../../shared/HttpClient/Clients'; +import { LocalFileMessenger } from '../../../../context/local/localFile/domain/LocalFileMessenger'; +import { RendererIpcLocalFileMessenger } from '../../../../context/local/localFile/infrastructure/RendererIpcLocalFileMessenger'; + +export async function registerLocalFileServices(builder: ContainerBuilder) { + //Infra + const user = DependencyInjectionUserProvider.get(); + + const mnemonic = DependencyInjectionMnemonicProvider.get(); + + const environment = new Environment({ + bridgeUrl: process.env.BRIDGE_URL, + bridgeUser: user.bridgeUser, + bridgePass: user.userId, + encryptionKey: mnemonic, + }); + + builder.register(Environment).useInstance(environment).private(); + + builder + .register(LocalFileHandler) + .useFactory( + (c) => + new EnvironmentLocalFileUploader( + c.get(Environment), + user.backupsBucket, + //@ts-ignore + c.get(AuthorizedClients).drive + ) + ) + .private(); + + builder + .register(LocalFileMessenger) + .useClass(RendererIpcLocalFileMessenger) + .private() + .asSingleton(); + + // Services + builder.registerAndUse(FileBatchUpdater); + builder.registerAndUse(FileBatchUploader); +} diff --git a/src/apps/backups/dependency-injection/local/registerLocalTreeServices.ts b/src/apps/backups/dependency-injection/local/registerLocalTreeServices.ts new file mode 100644 index 000000000..3648ec59c --- /dev/null +++ b/src/apps/backups/dependency-injection/local/registerLocalTreeServices.ts @@ -0,0 +1,12 @@ +import { ContainerBuilder } from 'diod'; +import LocalTreeBuilder from '../../../../context/local/localTree/application/LocalTreeBuilder'; +import { LocalItemsGenerator } from '../../../../context/local/localTree/domain/LocalItemsGenerator'; +import { CLSFsLocalItemsGenerator } from '../../../../context/local/localTree/infrastructure/FsLocalItemsGenerator'; + +export async function registerLocalTreeServices(builder: ContainerBuilder) { + //infra + builder.register(LocalItemsGenerator).use(CLSFsLocalItemsGenerator).private(); + + // services + builder.registerAndUse(LocalTreeBuilder); +} diff --git a/src/apps/backups/dependency-injection/user/registerUsageServices.ts b/src/apps/backups/dependency-injection/user/registerUsageServices.ts new file mode 100644 index 000000000..1332f4bc0 --- /dev/null +++ b/src/apps/backups/dependency-injection/user/registerUsageServices.ts @@ -0,0 +1,15 @@ +import { ContainerBuilder } from 'diod'; +import { UserAvaliableSpaceValidator } from '../../../../context/user/usage/application/UserAvaliableSpaceValidator'; +import { IpcUserUsageRepository } from '../../../../context/user/usage/infrastrucutre/IpcUserUsageRepository'; +import { UserUsageRepository } from '../../../../context/user/usage/domain/UserUsageRepository'; + +export async function registerUserUsageServices(builder: ContainerBuilder) { + // Infra + builder + .register(UserUsageRepository) + .useClass(IpcUserUsageRepository) + .private(); + + // Services + builder.registerAndUse(UserAvaliableSpaceValidator); +} diff --git a/src/apps/backups/dependency-injection/virtual-drive/registerFilesServices.ts b/src/apps/backups/dependency-injection/virtual-drive/registerFilesServices.ts new file mode 100644 index 000000000..bdb8d5224 --- /dev/null +++ b/src/apps/backups/dependency-injection/virtual-drive/registerFilesServices.ts @@ -0,0 +1,34 @@ +import { ContainerBuilder } from 'diod'; +import { SimpleFileOverrider } from '../../../../context/virtual-drive/files/application/override/SimpleFileOverrider'; +import { RemoteFileSystem } from '../../../../context/virtual-drive/files/domain/file-systems/RemoteFileSystem'; +import crypt from '../../../../context/shared/infrastructure/crypt'; +import { SDKRemoteFileSystem } from '../../../../context/virtual-drive/files/infrastructure/SDKRemoteFileSystem'; +import { AuthorizedClients } from '../../../shared/HttpClient/Clients'; +import { DependencyInjectionUserProvider } from '../../../shared/dependency-injection/DependencyInjectionUserProvider'; +import { Storage } from '@internxt/sdk/dist/drive/storage'; +import { FileDeleter } from '../../../../context/virtual-drive/files/application/delete/FileDeleter'; +import { SimpleFileCreator } from '../../../../context/virtual-drive/files/application/create/SimpleFileCreator'; + +export async function registerFilesServices(builder: ContainerBuilder) { + // Infra + const user = DependencyInjectionUserProvider.get(); + + builder + .register(RemoteFileSystem) + .useFactory( + (c) => + new SDKRemoteFileSystem( + c.get(Storage), + c.get(AuthorizedClients), + crypt, + user.backupsBucket + ) + ) + .private(); + + // Services + + builder.registerAndUse(SimpleFileCreator); + builder.registerAndUse(SimpleFileOverrider); + builder.registerAndUse(FileDeleter); +} diff --git a/src/apps/backups/dependency-injection/virtual-drive/registerFolderServices.ts b/src/apps/backups/dependency-injection/virtual-drive/registerFolderServices.ts new file mode 100644 index 000000000..98b091989 --- /dev/null +++ b/src/apps/backups/dependency-injection/virtual-drive/registerFolderServices.ts @@ -0,0 +1,21 @@ +import { ContainerBuilder } from 'diod'; +import { SimpleFolderCreator } from '../../../../context/virtual-drive/folders/application/create/SimpleFolderCreator'; +import { RemoteFileSystem } from '../../../../context/virtual-drive/folders/domain/file-systems/RemoteFileSystem'; +import { HttpRemoteFileSystem } from '../../../../context/virtual-drive/folders/infrastructure/HttpRemoteFileSystem'; +import { AuthorizedClients } from '../../../shared/HttpClient/Clients'; + +export async function registerFolderServices(builder: ContainerBuilder) { + builder + .register(RemoteFileSystem) + .useFactory((c) => { + const clients = c.get(AuthorizedClients); + return new HttpRemoteFileSystem( + // @ts-ignore + clients.drive, + clients.newDrive + ); + }) + .private(); + + builder.registerAndUse(SimpleFolderCreator); +} diff --git a/src/apps/backups/dependency-injection/virtual-drive/registerRemoteTreeServices.ts b/src/apps/backups/dependency-injection/virtual-drive/registerRemoteTreeServices.ts new file mode 100644 index 000000000..a8d2f7de2 --- /dev/null +++ b/src/apps/backups/dependency-injection/virtual-drive/registerRemoteTreeServices.ts @@ -0,0 +1,26 @@ +import { ContainerBuilder } from 'diod'; +import { RemoteTreeBuilder } from '../../../../context/virtual-drive/remoteTree/application/RemoteTreeBuilder'; +import { Traverser } from '../../../../context/virtual-drive/remoteTree/application/Traverser'; +import { RemoteItemsGenerator } from '../../../../context/virtual-drive/remoteTree/domain/RemoteItemsGenerator'; +import { IpcRemoteItemsGenerator } from '../../../../context/virtual-drive/remoteTree/infrastructure/IpcRemoteItemsGenerator'; +import crypt from '../../../../context/shared/infrastructure/crypt'; +import { ipcRendererSyncEngine } from '../../../sync-engine/ipcRendererSyncEngine'; + +export async function registerRemoteTreeServices(builder: ContainerBuilder) { + // Infra + builder + .register(RemoteItemsGenerator) + .useFactory(() => new IpcRemoteItemsGenerator(ipcRendererSyncEngine)) + .private(); + + builder + .register(Traverser) + .useFactory(() => { + return Traverser.existingItems(crypt); + }) + .asSingleton() + .private(); + + // Services + builder.registerAndUse(RemoteTreeBuilder); +} diff --git a/src/apps/backups/diff/DiffFilesCalculator.ts b/src/apps/backups/diff/DiffFilesCalculator.ts new file mode 100644 index 000000000..fd3344713 --- /dev/null +++ b/src/apps/backups/diff/DiffFilesCalculator.ts @@ -0,0 +1,71 @@ +import path from 'path'; +import { LocalFile } from '../../../context/local/localFile/domain/LocalFile'; +import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath'; +import { LocalTree } from '../../../context/local/localTree/domain/LocalTree'; +import { File } from '../../../context/virtual-drive/files/domain/File'; +import { RemoteTree } from '../../../context/virtual-drive/remoteTree/domain/RemoteTree'; +import { relative } from '../utils/relative'; +import Logger from 'electron-log'; + +export type FilesDiff = { + added: Array; + deleted: Array; + modified: Map; + unmodified: Array; + total: number; +}; + +export class DiffFilesCalculator { + static calculate(local: LocalTree, remote: RemoteTree): FilesDiff { + const added: Array = []; + const modified: Map = new Map(); + const unmodified: Array = []; + + const rootPath = local.root.path; + + local.files.forEach((local) => { + const remotePath = relative(rootPath, local.path); + + const remoteExists = remote.has(remotePath); + + if (!remoteExists) { + added.push(local); + return; + } + + const remoteNode = remote.get(remotePath); + + if (remoteNode.isFolder()) { + Logger.debug('Folder should be a file', remoteNode.name); + return; + } + + const remoteModificationTime = Math.trunc( + remoteNode.updatedAt.getTime() / 1000 + ); + const localModificationTime = Math.trunc(local.modificationTime / 1000); + + if (remoteModificationTime < localModificationTime) { + modified.set(local, remoteNode); + return; + } + + unmodified.push(local); + }); + + const deleted = remote.files.filter( + (file) => !local.has(path.join(rootPath, file.path) as AbsolutePath) + ); + + const total = + added.length + modified.size + deleted.length + unmodified.length; + + return { + added, + modified, + deleted, + unmodified, + total, + }; + } +} diff --git a/src/apps/backups/diff/FoldersDiffCalculator.ts b/src/apps/backups/diff/FoldersDiffCalculator.ts new file mode 100644 index 000000000..be426a797 --- /dev/null +++ b/src/apps/backups/diff/FoldersDiffCalculator.ts @@ -0,0 +1,42 @@ +import path from 'path'; +import { AbsolutePath } from '../../../context/local/localFile/infrastructure/AbsolutePath'; +import { LocalFolder } from '../../../context/local/localFolder/domain/LocalFolder'; +import { LocalTree } from '../../../context/local/localTree/domain/LocalTree'; +import { Folder } from '../../../context/virtual-drive/folders/domain/Folder'; +import { RemoteTree } from '../../../context/virtual-drive/remoteTree/domain/RemoteTree'; +import { relative } from '../utils/relative'; + +export type FoldersDiff = { + added: Array; + deleted: Array; + unmodified: Array; + total: number; +}; + +export class FoldersDiffCalculator { + static calculate(local: LocalTree, remote: RemoteTree): FoldersDiff { + const rootPath = local.root.path; + + const added: Array = []; + const unmodified: Array = []; + + local.folders.forEach((folder) => { + const remotePath = relative(rootPath, folder.path); + + if (remote.has(remotePath)) { + unmodified.push(folder); + return; + } + + added.push(folder); + }); + + const deleted = remote.foldersWithOutRoot.filter( + (folder) => !local.has(path.join(rootPath, folder.path) as AbsolutePath) + ); + + const total = added.length + unmodified.length; + + return { added, deleted, unmodified, total }; + } +} diff --git a/src/apps/backups/index.ts b/src/apps/backups/index.ts index 7915e654a..4d8bab6de 100644 --- a/src/apps/backups/index.ts +++ b/src/apps/backups/index.ts @@ -1,7 +1,7 @@ import Logger from 'electron-log'; -// import { Backup } from './Backup'; +import { Backup } from './Backups'; import { BackupInfo } from './BackupInfo'; -// import { BackupsDependencyContainerFactory } from './dependency-injection/BackupsDependencyContainerFactory'; +import { BackupsDependencyContainerFactory } from './dependency-injection/BackupsDependencyContainerFactory'; import { DriveDesktopError } from '../../context/shared/domain/errors/DriveDesktopError'; import { BackupsIPCRenderer } from './BackupsIPCRenderer'; @@ -25,9 +25,11 @@ async function backupFolder() { const data = await obtainBackup(); try { - // const container = await BackupsDependencyContainerFactory.build(); + Logger.info('[BACKUPS] building container'); + const container = await BackupsDependencyContainerFactory.build(); const abortController = new AbortController(); + Logger.info('[BACKUPS] STEP 1'); window.addEventListener('offline', () => { Logger.log('[BACKUPS] Internet connection lost'); @@ -47,21 +49,21 @@ async function backupFolder() { BackupsIPCRenderer.send('backups.stopped'); }); - // const backup = container.get(Backup); + const backup = container.get(Backup); - // const error = await backup.run(data, abortController); + const error = await backup.run(data, abortController); - // if (error) { - // Logger.info('[BACKUPS] failed'); - // BackupsIPCRenderer.send( - // 'backups.backup-failed', - // data.folderId, - // error.cause - // ); - // } else { - // Logger.info('[BACKUPS] done'); - // BackupsIPCRenderer.send('backups.backup-completed', data.folderId); - // } + if (error) { + Logger.info('[BACKUPS] failed'); + BackupsIPCRenderer.send( + 'backups.backup-failed', + data.folderId, + error.cause + ); + } else { + Logger.info('[BACKUPS] done'); + BackupsIPCRenderer.send('backups.backup-completed', data.folderId); + } } catch (error: any) { Logger.error('[BACKUPS] ', error); if (error instanceof DriveDesktopError) { diff --git a/src/apps/main/background-processes/backups/BackupFatalErrors/BackupFatalErrors.ts b/src/apps/main/background-processes/backups/BackupFatalErrors/BackupFatalErrors.ts index 5f40b22b1..e6a60fb2f 100644 --- a/src/apps/main/background-processes/backups/BackupFatalErrors/BackupFatalErrors.ts +++ b/src/apps/main/background-processes/backups/BackupFatalErrors/BackupFatalErrors.ts @@ -1,4 +1,4 @@ -import { SyncError } from '../../../../../shared/issues/SyncErrorCause'; +import { SyncError } from '../../../../shared/issues/SyncErrorCause'; export type ProcessFatalErrorName = SyncError; diff --git a/src/apps/main/background-processes/backups/launchBackupProcesses.ts b/src/apps/main/background-processes/backups/launchBackupProcesses.ts index f13220961..4e8f7f609 100644 --- a/src/apps/main/background-processes/backups/launchBackupProcesses.ts +++ b/src/apps/main/background-processes/backups/launchBackupProcesses.ts @@ -31,6 +31,10 @@ export async function launchBackupProcesses( const backups = await backupsConfig.obtainBackupsInfo(); + Logger.debug(`[BACKUPS] Launching ${backups?.length} backups`); + Logger.debug(`[BACKUPS] Scheduled: ${scheduled}`); + Logger.debug(backups); + // clearBackupsIssues(); errors.clear(); diff --git a/src/apps/main/device/handlers.ts b/src/apps/main/device/handlers.ts index 0ca48b5fd..76b5bd3fb 100644 --- a/src/apps/main/device/handlers.ts +++ b/src/apps/main/device/handlers.ts @@ -26,6 +26,7 @@ ipcMain.handle('get-backups-from-device', (_, d, c?) => ); ipcMain.handle('add-backup', addBackup); + ipcMain.handle('add-multiple-backups', (_, folderPaths) => createBackupsFromLocalPaths(folderPaths) ); diff --git a/src/apps/main/device/service.ts b/src/apps/main/device/service.ts index c82a6947e..334bc2494 100644 --- a/src/apps/main/device/service.ts +++ b/src/apps/main/device/service.ts @@ -218,12 +218,19 @@ async function postBackup(name: string): Promise { */ async function createBackup(pathname: string): Promise { const { base } = path.parse(pathname); + + logger.debug(`[BACKUPS] Creating backup for ${base}`); + const newBackup = await postBackup(base); + logger.debug(`[BACKUPS] Created backup with id ${newBackup.id}`); + const backupList = configStore.get('backupList'); backupList[pathname] = { enabled: true, folderId: newBackup.id }; + logger.debug(`[BACKUPS] Backup list: ${JSON.stringify(backupList)}`); + configStore.set('backupList', backupList); } @@ -241,6 +248,8 @@ export async function addBackup(): Promise { const existingBackup = backupList[chosenPath]; + logger.debug(`[BACKUPS] Existing backup: ${existingBackup}`); + if (!existingBackup) { return createBackup(chosenPath); } @@ -276,6 +285,7 @@ async function fetchFolder(folderId: number) { if (res.ok) { return res.json(); } + logger.error(res); throw new Error('Unsuccesful request to fetch folder'); } diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index a93b67457..a68d848ed 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -105,6 +105,16 @@ declare interface Window { setBackupsInterval(value: number): Promise; + getBackupsStatus(): Promise< + import('./background-processes/backups/BackupsProcessStatus/BackupsStatus').BackupsStatus + >; + + onBackupsStatusChanged( + func: ( + value: import('./background-processes/backups/BackupsProcessStatus/BackupsStatus').BackupsStatus + ) => void + ): () => void; + startBackupsProcess(): void; stopBackupsProcess(): void; diff --git a/src/apps/main/usage/Usage.ts b/src/apps/main/usage/Usage.ts index 967cb71f5..e5e85a6f5 100644 --- a/src/apps/main/usage/Usage.ts +++ b/src/apps/main/usage/Usage.ts @@ -4,3 +4,8 @@ export type Usage = { isInfinite: boolean; offerUpgrade: boolean; }; +export type RawUsage = { + driveUsage: number; + photosUsage: number; + limitInBytes: number; +}; diff --git a/src/apps/main/usage/handlers.ts b/src/apps/main/usage/handlers.ts index 629909795..79c3ab836 100644 --- a/src/apps/main/usage/handlers.ts +++ b/src/apps/main/usage/handlers.ts @@ -1,12 +1,36 @@ import { ipcMain } from 'electron'; import eventBus from '../event-bus'; import { buildUsageService } from './serviceBuilder'; +import { UserUsageService } from './service'; +import { AccountIpcMain } from '../../shared/IPC/events/account/AccountIpcMain'; -function regiterUsageHandlers() { +let service: UserUsageService | null = null; + +function registerUsageHandlers() { ipcMain.handle('get-usage', () => { - const service = buildUsageService(); + if (!service) { + service = buildUsageService(); + } return service.calculateUsage(); }); + + AccountIpcMain.handle('account.get-usage', async () => { + if (!service) { + service = buildUsageService(); + } + + const raw = await service.raw(); + + return raw; + }); } -eventBus.on('APP_IS_READY', regiterUsageHandlers); +eventBus.on('APP_IS_READY', registerUsageHandlers); + +export function getUsageService() { + if (!service) { + service = buildUsageService(); + } + + return service; +} diff --git a/src/apps/main/usage/service.ts b/src/apps/main/usage/service.ts index 4fd6a2fef..2e5d1b53d 100644 --- a/src/apps/main/usage/service.ts +++ b/src/apps/main/usage/service.ts @@ -1,6 +1,6 @@ import { Storage } from '@internxt/sdk/dist/drive'; import PhotosSubmodule from '@internxt/sdk/dist/photos/photos'; -import { Usage } from './Usage'; +import { Usage, RawUsage } from './Usage'; const INFINITE_SPACE_TRHESHOLD = 108851651149824 as const; const OFFER_UPGRADE_TRHESHOLD = 2199023255552 as const; @@ -28,7 +28,7 @@ export class UserUsageService { } async calculateUsage(): Promise { - const [driveUsage, photosUsage, limitInBytes] = await Promise.all([ + const [driveUsage, photosUsage, limitInBytes] = await Promise.all([ this.getDriveUsage(), this.getPhotosUsage(), this.getLimit(), @@ -41,4 +41,17 @@ export class UserUsageService { offerUpgrade: limitInBytes < OFFER_UPGRADE_TRHESHOLD, }; } + async raw(): Promise { + const [driveUsage, photosUsage, limitInBytes] = await Promise.all([ + this.getDriveUsage(), + this.getPhotosUsage(), + this.getLimit(), + ]); + + return { + driveUsage, + photosUsage, + limitInBytes, + }; + } } diff --git a/src/apps/main/windows/settings.ts b/src/apps/main/windows/settings.ts index 524d07224..5bc779e20 100644 --- a/src/apps/main/windows/settings.ts +++ b/src/apps/main/windows/settings.ts @@ -18,7 +18,7 @@ async function openSettingsWindow(section?: string) { settingsWindow = new BrowserWindow({ width: 750, - height: 575, + height: 565, show: false, webPreferences: { preload: preloadPath, diff --git a/src/apps/shared/HttpClient/Clients.ts b/src/apps/shared/HttpClient/Clients.ts index f0cba5dca..a2bf52c83 100644 --- a/src/apps/shared/HttpClient/Clients.ts +++ b/src/apps/shared/HttpClient/Clients.ts @@ -1,6 +1,6 @@ import { Axios } from 'axios'; -export type AuthorizedClients = { - drive: Axios; - newDrive: Axios; -}; +export abstract class AuthorizedClients { + abstract drive: Axios; + abstract newDrive: Axios; +} diff --git a/src/apps/shared/IPC/events/account/AccountIpcMain.ts b/src/apps/shared/IPC/events/account/AccountIpcMain.ts new file mode 100644 index 000000000..fac42fda7 --- /dev/null +++ b/src/apps/shared/IPC/events/account/AccountIpcMain.ts @@ -0,0 +1,11 @@ +import { ipcMain } from 'electron'; +import { TypedIPC } from '../../TypedIPC'; +import { BackgroundProcessAccountMessages } from './BackgroundProcessAccountMessages'; +import { MainProcessAccountMessages } from './MainProcessAccountMessages'; + +export type AccountIpcMain = TypedIPC< + BackgroundProcessAccountMessages, + MainProcessAccountMessages +>; + +export const AccountIpcMain = ipcMain as unknown as AccountIpcMain; diff --git a/src/apps/shared/IPC/events/account/AccountIpcRenderer.ts b/src/apps/shared/IPC/events/account/AccountIpcRenderer.ts new file mode 100644 index 000000000..8e11236ac --- /dev/null +++ b/src/apps/shared/IPC/events/account/AccountIpcRenderer.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron'; +import { TypedIPC } from '../../TypedIPC'; +import { BackgroundProcessAccountMessages } from './BackgroundProcessAccountMessages'; +import { MainProcessAccountMessages } from './MainProcessAccountMessages'; + +export type AccountIpcRenderer = TypedIPC< + MainProcessAccountMessages, + BackgroundProcessAccountMessages +>; + +export const AccountIpcRenderer = ipcRenderer as unknown as AccountIpcRenderer; diff --git a/src/apps/shared/IPC/events/account/BackgroundProcessAccountMessages.ts b/src/apps/shared/IPC/events/account/BackgroundProcessAccountMessages.ts new file mode 100644 index 000000000..13dde7c2b --- /dev/null +++ b/src/apps/shared/IPC/events/account/BackgroundProcessAccountMessages.ts @@ -0,0 +1,3 @@ +export type BackgroundProcessAccountMessages = { + placeholder: () => void; +}; diff --git a/src/apps/shared/IPC/events/account/MainProcessAccountMessages.ts b/src/apps/shared/IPC/events/account/MainProcessAccountMessages.ts new file mode 100644 index 000000000..74d6d9f7e --- /dev/null +++ b/src/apps/shared/IPC/events/account/MainProcessAccountMessages.ts @@ -0,0 +1,5 @@ +import { RawUsage } from '../../../../main/usage/Usage'; + +export type MainProcessAccountMessages = { + 'account.get-usage': () => Promise; +}; diff --git a/src/apps/shared/dependency-injection/DependencyInjectionMnemonicProvider.ts b/src/apps/shared/dependency-injection/DependencyInjectionMnemonicProvider.ts new file mode 100644 index 000000000..7ab66a307 --- /dev/null +++ b/src/apps/shared/dependency-injection/DependencyInjectionMnemonicProvider.ts @@ -0,0 +1,20 @@ +import configStore from '../../main/config'; + +export class DependencyInjectionMnemonicProvider { + private static _: string; + + static get() { + if (DependencyInjectionMnemonicProvider._) { + return DependencyInjectionMnemonicProvider._; + } + + const mnemonic = configStore.get('mnemonic'); + + if (mnemonic) { + DependencyInjectionMnemonicProvider._ = mnemonic; + return mnemonic; + } + + throw new Error('Could not get mnemonic in dependency injection'); + } +} diff --git a/src/apps/shared/dependency-injection/DependencyInjectionUserProvider.ts b/src/apps/shared/dependency-injection/DependencyInjectionUserProvider.ts new file mode 100644 index 000000000..b9ac5eed7 --- /dev/null +++ b/src/apps/shared/dependency-injection/DependencyInjectionUserProvider.ts @@ -0,0 +1,26 @@ +import { User } from '../../main/types'; +import ConfigStore from '../../main/config'; + +function getUser(): User | null { + const user = ConfigStore.get('userData'); + + return user && Object.keys(user).length ? user : null; +} + +export class DependencyInjectionUserProvider { + private static _user: User; + + static get() { + if (DependencyInjectionUserProvider._user) + return DependencyInjectionUserProvider._user; + + const user = getUser(); + + if (user) { + DependencyInjectionUserProvider._user = user; + return user; + } + + throw new Error('Could not get user in dependency injection'); + } +} diff --git a/src/apps/shared/dependency-injection/background/DependencyInjectionBackgoundProcessStorageSdk.ts b/src/apps/shared/dependency-injection/background/DependencyInjectionBackgoundProcessStorageSdk.ts new file mode 100644 index 000000000..da47fadfa --- /dev/null +++ b/src/apps/shared/dependency-injection/background/DependencyInjectionBackgoundProcessStorageSdk.ts @@ -0,0 +1,35 @@ +import { Storage } from '@internxt/sdk/dist/drive/storage'; +import { ipcRenderer } from 'electron'; +import packageJson from '../../../../../package.json'; +import { onUserUnauthorized } from '../../HttpClient/background-process-clients'; + +export class DependencyInjectionBackgroundProcessStorageSdk { + private static sdk: Storage; + + static async get(): Promise { + if (DependencyInjectionBackgroundProcessStorageSdk.sdk) { + return DependencyInjectionBackgroundProcessStorageSdk.sdk; + } + + const url = `${process.env.API_URL}`; + const { name: clientName, version: clientVersion } = packageJson; + + const token = await ipcRenderer.invoke('get-token'); + + const sdk = Storage.client( + url, + { + clientName, + clientVersion, + }, + { + token, + unauthorizedCallback: onUserUnauthorized, + } + ); + + DependencyInjectionBackgroundProcessStorageSdk.sdk = sdk; + + return DependencyInjectionBackgroundProcessStorageSdk.sdk; + } +} diff --git a/src/apps/shared/dependency-injection/background/backgroundProcessSharedInfraBuilder.ts b/src/apps/shared/dependency-injection/background/backgroundProcessSharedInfraBuilder.ts new file mode 100644 index 000000000..fca40e1d4 --- /dev/null +++ b/src/apps/shared/dependency-injection/background/backgroundProcessSharedInfraBuilder.ts @@ -0,0 +1,42 @@ +import { ContainerBuilder } from 'diod'; +import { AuthorizedClients } from '../../HttpClient/Clients'; +import { BackgroundProcessAuthorizedClients } from '../../../../context/shared/infrastructure/BackgroundProcess/BackgroundProcessAuthorizedClients'; +import { onUserUnauthorized } from '../../HttpClient/background-process-clients'; +import packageJson from '../../../../../package.json'; +import { Storage } from '@internxt/sdk/dist/drive/storage'; +import { ipcRenderer } from 'electron'; + +export async function backgroundProcessSharedInfraBuilder(): Promise { + const builder = new ContainerBuilder(); + + const token = await ipcRenderer.invoke('get-token'); + + builder + .register(AuthorizedClients) + .useClass(BackgroundProcessAuthorizedClients) + .asSingleton() + .private(); + + builder + .register(Storage) + .useFactory(() => { + const { name: clientName, version: clientVersion } = packageJson; + const storage = Storage.client( + `${process.env.API_URL}`, + { + clientName, + clientVersion, + }, + { + token, + unauthorizedCallback: onUserUnauthorized, + } + ); + + return storage; + }) + .asSingleton() + .private(); + + return builder; +} diff --git a/src/apps/shared/dependency-injection/baseInfra.ts b/src/apps/shared/dependency-injection/baseInfra.ts new file mode 100644 index 000000000..223798271 --- /dev/null +++ b/src/apps/shared/dependency-injection/baseInfra.ts @@ -0,0 +1,55 @@ +import { ContainerBuilder } from 'diod'; +import { DependencyInjectionUserProvider } from './DependencyInjectionUserProvider'; +import { Environment } from '@internxt/inxt-js'; +import { EventBus } from '../../../context/virtual-drive/shared/domain/EventBus'; +import { EventRepository } from '../../../context/virtual-drive/shared/domain/EventRepository'; +import { EventRecorder } from '../../../context/virtual-drive/shared/infrastructure/EventRecorder'; +import { NodeJsEventBus } from '../../../context/virtual-drive/shared/infrastructure/NodeJsEventBus'; +import { Traverser } from '../../../context/virtual-drive/remoteTree/application/Traverser'; +import crypt from '../../../context/shared/infrastructure/crypt'; +import { DependencyInjectionMnemonicProvider } from './DependencyInjectionMnemonicProvider'; +import { InMemoryEventRepository } from '../../../context/virtual-drive/shared/infrastructure/InMemoryEventHistory'; +import { SubscribeDomainEventsHandlerToTheirEvents } from '../../../context/shared/infrastructure/domain-events/SubscribeDomainEventsHandlerToTheirEvents'; + +export function baseInfra(): ContainerBuilder { + const builder = new ContainerBuilder(); + + const user = DependencyInjectionUserProvider.get(); + const mnemonic = DependencyInjectionMnemonicProvider.get(); + + const environment = new Environment({ + bridgeUrl: process.env.BRIDGE_URL, + bridgeUser: user.bridgeUser, + bridgePass: user.userId, + encryptionKey: mnemonic, + }); + + builder.register(Environment).useInstance(environment).private(); + + builder + .register(EventRepository) + .use(InMemoryEventRepository) + .asSingleton() + .private(); + + builder + .register(EventBus) + .useFactory((c) => { + const bus = new NodeJsEventBus(); + return new EventRecorder(c.get(EventRepository), bus); + }) + .asSingleton() + .private(); + + builder.registerAndUse(SubscribeDomainEventsHandlerToTheirEvents).public(); + + builder + .register(Traverser) + .useFactory(() => { + return Traverser.existingItems(crypt); + }) + .asSingleton() + .private(); + + return builder; +} diff --git a/src/apps/shared/dependency-injection/main/DependencyInjectionMainProcessPhotosProviderPhotos.ts b/src/apps/shared/dependency-injection/main/DependencyInjectionMainProcessPhotosProviderPhotos.ts new file mode 100644 index 000000000..c6c70a122 --- /dev/null +++ b/src/apps/shared/dependency-injection/main/DependencyInjectionMainProcessPhotosProviderPhotos.ts @@ -0,0 +1,26 @@ +import PhotosSubmodule from '@internxt/sdk/dist/photos/photos'; +import { obtainToken } from '../../../main/auth/service'; + +export class DependencyInjectionMainProcessPhotosProviderPhotos { + private static _photos: PhotosSubmodule; + + static get photos() { + if (DependencyInjectionMainProcessPhotosProviderPhotos._photos) { + return DependencyInjectionMainProcessPhotosProviderPhotos._photos; + } + + const photosUrl = process.env.PHOTOS_URL; + + const newToken = obtainToken('newToken'); + + const photosSubmodule = new PhotosSubmodule({ + baseUrl: photosUrl, + accessToken: newToken, + }); + + DependencyInjectionMainProcessPhotosProviderPhotos._photos = + photosSubmodule; + + return photosSubmodule; + } +} diff --git a/src/apps/shared/dependency-injection/main/DependencyInjectionMainProcessStorageSdk.ts b/src/apps/shared/dependency-injection/main/DependencyInjectionMainProcessStorageSdk.ts new file mode 100644 index 000000000..c52d66464 --- /dev/null +++ b/src/apps/shared/dependency-injection/main/DependencyInjectionMainProcessStorageSdk.ts @@ -0,0 +1,35 @@ +import { Storage } from '@internxt/sdk/dist/drive/storage'; +import { onUserUnauthorized } from '../../HttpClient/background-process-clients'; +import packageJson from '../../../../../package.json'; +import { obtainToken } from '../../../main/auth/service'; + +export class DependencyInjectionMainProcessStorageSdk { + private static sdk: Storage; + + static async get(): Promise { + if (DependencyInjectionMainProcessStorageSdk.sdk) { + return DependencyInjectionMainProcessStorageSdk.sdk; + } + + const url = `${process.env.API_URL}`; + const { name: clientName, version: clientVersion } = packageJson; + + const token = obtainToken('bearerToken'); + + const sdk = Storage.client( + url, + { + clientName, + clientVersion, + }, + { + token, + unauthorizedCallback: onUserUnauthorized, + } + ); + + DependencyInjectionMainProcessStorageSdk.sdk = sdk; + + return DependencyInjectionMainProcessStorageSdk.sdk; + } +} diff --git a/src/apps/shared/dependency-injection/main/mainProcessSharedInfraContainer.ts b/src/apps/shared/dependency-injection/main/mainProcessSharedInfraContainer.ts new file mode 100644 index 000000000..3cdff8fb0 --- /dev/null +++ b/src/apps/shared/dependency-injection/main/mainProcessSharedInfraContainer.ts @@ -0,0 +1,46 @@ +import { ContainerBuilder } from 'diod'; +import { MainProcessAuthorizedClients } from '../../../../context/shared/infrastructure/MainProcess/MainProcessAuthorizedClients'; +import { AuthorizedClients } from '../../HttpClient/Clients'; +import { MainProcessDownloadProgressTracker } from '../../../../context/shared/infrastructure/MainProcess/MainProcessDownloadProgressTracker'; +import { DownloadProgressTracker } from '../../../../context/shared/domain/DownloadProgressTracker'; +import { baseInfra } from '../baseInfra'; +import PhotosSubmodule from '@internxt/sdk/dist/photos/photos'; +import { DependencyInjectionMainProcessPhotosProviderPhotos } from './DependencyInjectionMainProcessPhotosProviderPhotos'; +import { UploadProgressTracker } from '../../../../context/shared/domain/UploadProgressTracker'; +import { MainProcessUploadProgressTracker } from '../../../../context/shared/infrastructure/MainProcess/MainProcessUploadProgressTracker'; +import { RemoteItemsGenerator } from '../../../../context/virtual-drive/remoteTree/domain/RemoteItemsGenerator'; +import { SQLiteRemoteItemsGenerator } from '../../../../context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator'; + +export async function mainProcessSharedInfraBuilder(): Promise { + const builder = baseInfra(); + + builder + .register(AuthorizedClients) + .useClass(MainProcessAuthorizedClients) + .asSingleton() + .private() + .addTag('shared'); + + builder + .register(DownloadProgressTracker) + .use(MainProcessDownloadProgressTracker) + .private() + .addTag('shared'); + + builder + .register(UploadProgressTracker) + .use(MainProcessUploadProgressTracker) + .private(); + + builder + .register(PhotosSubmodule) + .useInstance(DependencyInjectionMainProcessPhotosProviderPhotos.photos) + .private(); + + builder + .register(RemoteItemsGenerator) + .use(SQLiteRemoteItemsGenerator) + .private(); + + return builder; +} diff --git a/src/apps/shared/dependency-injection/virtual-drive/files/InMemoryFileRepositorySingleton.ts b/src/apps/shared/dependency-injection/virtual-drive/files/InMemoryFileRepositorySingleton.ts new file mode 100644 index 000000000..728962560 --- /dev/null +++ b/src/apps/shared/dependency-injection/virtual-drive/files/InMemoryFileRepositorySingleton.ts @@ -0,0 +1,13 @@ +import { InMemoryFileRepository } from '../../../../../context/virtual-drive/files/infrastructure/InMemoryFileRepository'; + +export class InMemoryFileRepositorySingleton { + private static _repo: InMemoryFileRepository | null = null; + + static get instance(): InMemoryFileRepository { + if (!InMemoryFileRepositorySingleton._repo) { + InMemoryFileRepositorySingleton._repo = new InMemoryFileRepository(); + } + + return InMemoryFileRepositorySingleton._repo; + } +} diff --git a/src/apps/shared/dependency-injection/virtual-drive/folders/InMemoryFolderRepositorySingleton.ts b/src/apps/shared/dependency-injection/virtual-drive/folders/InMemoryFolderRepositorySingleton.ts new file mode 100644 index 000000000..0b5d73205 --- /dev/null +++ b/src/apps/shared/dependency-injection/virtual-drive/folders/InMemoryFolderRepositorySingleton.ts @@ -0,0 +1,13 @@ +import { InMemoryFolderRepository } from '../../../../../context/virtual-drive/folders/infrastructure/InMemoryFolderRepository'; + +export class InMemoryFolderRepositorySingleton { + private static _repo: InMemoryFolderRepository | null = null; + + static get instance(): InMemoryFolderRepository { + if (!InMemoryFolderRepositorySingleton._repo) { + InMemoryFolderRepositorySingleton._repo = new InMemoryFolderRepository(); + } + + return InMemoryFolderRepositorySingleton._repo; + } +} diff --git a/src/apps/shared/issues/VirtualDriveError.ts b/src/apps/shared/issues/VirtualDriveError.ts new file mode 100644 index 000000000..b40b48310 --- /dev/null +++ b/src/apps/shared/issues/VirtualDriveError.ts @@ -0,0 +1,35 @@ +const virtualDriveFileErrors = [ + 'UPLOAD_ERROR', + 'DOWNLOAD_ERROR', + 'RENAME_ERROR', + 'DELETE_ERROR', + 'METADATA_READ_ERROR', + 'GENERATE_TREE', +] as const; + +const virtualDriveFolderErrors = [ + 'FOLDER_RENAME_ERROR', + 'FOLDER_CREATE_ERROR', + 'FOLDER_TRASH_ERROR', +] as const; + +export type VirtualDriveFileError = (typeof virtualDriveFileErrors)[number]; +export type VirtualDriveFolderError = (typeof virtualDriveFolderErrors)[number]; +export type VirtualDriveError = VirtualDriveFileError | VirtualDriveFolderError; + +function is(set: readonly T[]) { + return (maybe: string): maybe is T => set.includes(maybe as T); +} + +export const isVirtualDriveFileError = is( + virtualDriveFileErrors +); + +export const isVirtualDriveFolderError = is( + virtualDriveFolderErrors +); + +export const isVirtualDriveError = is([ + ...virtualDriveFileErrors, + ...virtualDriveFolderErrors, +]); diff --git a/src/apps/shared/issues/VirtualDriveIssue.ts b/src/apps/shared/issues/VirtualDriveIssue.ts new file mode 100644 index 000000000..6a816acd0 --- /dev/null +++ b/src/apps/shared/issues/VirtualDriveIssue.ts @@ -0,0 +1,23 @@ +import { SyncError } from './SyncErrorCause'; +import { + VirtualDriveError, + VirtualDriveFileError, + VirtualDriveFolderError, +} from './VirtualDriveError'; + +export type VirtualDriveFolderIssue = { + error: VirtualDriveFolderError; + cause: SyncError; + name: string; +}; +export type VirtualDriveFileIssue = { + error: VirtualDriveFileError; + cause: SyncError; + name: string; +}; + +export type VirtualDriveIssue = { + error: VirtualDriveError; + cause: SyncError; + name: string; +}; diff --git a/src/context/local/localFile/application/update/FileBatchUpdater.ts b/src/context/local/localFile/application/update/FileBatchUpdater.ts new file mode 100644 index 000000000..07334da7a --- /dev/null +++ b/src/context/local/localFile/application/update/FileBatchUpdater.ts @@ -0,0 +1,48 @@ +import { Service } from 'diod'; +import { LocalFile } from '../../domain/LocalFile'; +import { LocalFileHandler } from '../../domain/LocalFileUploader'; +import { RemoteTree } from '../../../../virtual-drive/remoteTree/domain/RemoteTree'; +import { SimpleFileOverrider } from '../../../../virtual-drive/files/application/override/SimpleFileOverrider'; +import { LocalFolder } from '../../../localFolder/domain/LocalFolder'; +import { relative } from '../../../../../apps/backups/utils/relative'; + +@Service() +export class FileBatchUpdater { + constructor( + private readonly uploader: LocalFileHandler, + private readonly simpleFileOverrider: SimpleFileOverrider + ) {} + + async run( + localRoot: LocalFolder, + remoteTree: RemoteTree, + batch: Array, + signal: AbortSignal + ): Promise { + for (const localFile of batch) { + // eslint-disable-next-line no-await-in-loop + const upload = await this.uploader.upload( + localFile.path, + localFile.size, + signal + ); + + if (upload.isLeft()) { + throw upload.getLeft(); + } + + const contentsId = upload.getRight(); + + const remotePath = relative(localRoot.path, localFile.path); + + const file = remoteTree.get(remotePath); + + if (file.isFolder()) { + throw new Error(`Expected file, found folder on ${file.path}`); + } + + // eslint-disable-next-line no-await-in-loop + await this.simpleFileOverrider.run(file, contentsId, localFile.size); + } + } +} diff --git a/src/context/local/localFile/application/upload/FileBatchUploader.ts b/src/context/local/localFile/application/upload/FileBatchUploader.ts new file mode 100644 index 000000000..5077144ea --- /dev/null +++ b/src/context/local/localFile/application/upload/FileBatchUploader.ts @@ -0,0 +1,84 @@ +import { Service } from 'diod'; +import { LocalFile } from '../../domain/LocalFile'; +import { LocalFileHandler } from '../../domain/LocalFileUploader'; +import { SimpleFileCreator } from '../../../../virtual-drive/files/application/create/SimpleFileCreator'; +import { RemoteTree } from '../../../../virtual-drive/remoteTree/domain/RemoteTree'; +import { relative } from '../../../../../apps/backups/utils/relative'; +import { LocalFileMessenger } from '../../domain/LocalFileMessenger'; +import { isFatalError } from '../../../../../apps/shared/issues/SyncErrorCause'; +import Logger from 'electron-log'; + +@Service() +export class FileBatchUploader { + constructor( + private readonly localHandler: LocalFileHandler, + private readonly creator: SimpleFileCreator, + protected readonly messenger: LocalFileMessenger + ) {} + + async run( + localRootPath: string, + remoteTree: RemoteTree, + batch: Array, + signal: AbortSignal + ): Promise { + for (const localFile of batch) { + // eslint-disable-next-line no-await-in-loop + const uploadEither = await this.localHandler.upload( + localFile.path, + localFile.size, + signal + ); + + if (uploadEither.isLeft()) { + const error = uploadEither.getLeft(); + + if (isFatalError(error.cause)) { + throw error; + } + + // eslint-disable-next-line no-await-in-loop + await this.messenger.creationFailed(localFile, error); + continue; + } + + const contentsId = uploadEither.getRight(); + + const remotePath = relative(localRootPath, localFile.path); + + const parent = remoteTree.getParent(remotePath); + + Logger.info('Uploading file', localFile.path, 'to', parent.path); + + // eslint-disable-next-line no-await-in-loop + const either = await this.creator.run( + contentsId, + localFile.path, + localFile.size, + parent.id + ); + + if (either.isLeft()) { + // eslint-disable-next-line no-await-in-loop + await this.localHandler.delete(contentsId); + const error = either.getLeft(); + + if (error.cause === 'FILE_ALREADY_EXISTS') { + continue; + } + + if (error.cause === 'BAD_RESPONSE') { + // eslint-disable-next-line no-await-in-loop + await this.messenger.creationFailed(localFile, error); + continue; + } + + throw error; + } + + const file = either.getRight(); + + remoteTree.addFile(parent, file); + } + } +} diff --git a/src/context/local/localFile/domain/LocalFile.ts b/src/context/local/localFile/domain/LocalFile.ts new file mode 100644 index 000000000..abca089b5 --- /dev/null +++ b/src/context/local/localFile/domain/LocalFile.ts @@ -0,0 +1,79 @@ +import path from 'path'; +import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; +import { AbsolutePath } from '../infrastructure/AbsolutePath'; +import { LocalFileSize } from './LocalFileSize'; + +export type LocalFileAttributes = { + path: AbsolutePath; + modificationTime: number; + size: number; +}; + +export class LocalFile extends AggregateRoot { + private constructor( + private _path: AbsolutePath, + private _modificationTime: number, + private _size: LocalFileSize + ) { + super(); + } + + get path(): AbsolutePath { + return this._path; + } + + get modificationTime(): number { + return this._modificationTime; + } + + get size(): number { + return this._size.value; + } + + holdsSubpath(otherPath: string): boolean { + return this._path.endsWith(otherPath); + } + + isSmall(): boolean { + return this._size.isSmall(); + } + + isMedium(): boolean { + return this._size.isMedium(); + } + + isBig(): boolean { + return this._size.isBig(); + } + + basedir(): string { + const dirname = path.posix.dirname(this._path); + if (dirname === '.') { + return path.posix.sep; + } + + return dirname; + } + + nameWithExtension() { + const basename = path.posix.basename(this._path); + const { base } = path.posix.parse(basename); + return base; + } + + static from(attributes: LocalFileAttributes): LocalFile { + return new LocalFile( + attributes.path, + attributes.modificationTime, + new LocalFileSize(attributes.size) + ); + } + + attributes(): LocalFileAttributes { + return { + path: this.path, + modificationTime: this.modificationTime, + size: this.size, + }; + } +} diff --git a/src/context/local/localFile/domain/LocalFileMessenger.ts b/src/context/local/localFile/domain/LocalFileMessenger.ts new file mode 100644 index 000000000..c15789777 --- /dev/null +++ b/src/context/local/localFile/domain/LocalFileMessenger.ts @@ -0,0 +1,9 @@ +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; +import { LocalFile } from './LocalFile'; + +export abstract class LocalFileMessenger { + abstract creationFailed( + file: LocalFile, + error: DriveDesktopError + ): Promise; +} diff --git a/src/context/local/localFile/domain/LocalFileRepository.ts b/src/context/local/localFile/domain/LocalFileRepository.ts new file mode 100644 index 000000000..72ef7c288 --- /dev/null +++ b/src/context/local/localFile/domain/LocalFileRepository.ts @@ -0,0 +1,7 @@ +import { AbsolutePath } from '../infrastructure/AbsolutePath'; +import { LocalFile } from './LocalFile'; + +export abstract class LocalFileRepository { + abstract files(absolutePath: AbsolutePath): Promise>; + abstract folders(absolutePath: AbsolutePath): Promise>; +} diff --git a/src/context/local/localFile/domain/LocalFileSize.ts b/src/context/local/localFile/domain/LocalFileSize.ts new file mode 100644 index 000000000..d24952c39 --- /dev/null +++ b/src/context/local/localFile/domain/LocalFileSize.ts @@ -0,0 +1,33 @@ +import { ValueObject } from '../../../shared/domain/value-objects/ValueObject'; + +export class LocalFileSize extends ValueObject { + static readonly MAX_SMALL_FILE_SIZE = 1024 * 1024; + static readonly MAX_MEDIUM_FILE_SIZE = 20 * 1024 * 1024; + + constructor(value: number) { + super(value); + + this.validate(value); + } + + private validate(value: number) { + if (value <= 0) { + throw new Error(`A remote file size cannot have value ${value}`); + } + } + + isSmall(): boolean { + return this.value <= LocalFileSize.MAX_SMALL_FILE_SIZE; + } + + isMedium(): boolean { + return ( + this.value > LocalFileSize.MAX_SMALL_FILE_SIZE && + this.value <= LocalFileSize.MAX_MEDIUM_FILE_SIZE + ); + } + + isBig(): boolean { + return this.value > LocalFileSize.MAX_MEDIUM_FILE_SIZE; + } +} diff --git a/src/context/local/localFile/domain/LocalFileUploader.ts b/src/context/local/localFile/domain/LocalFileUploader.ts new file mode 100644 index 000000000..d869cd119 --- /dev/null +++ b/src/context/local/localFile/domain/LocalFileUploader.ts @@ -0,0 +1,13 @@ +import { Either } from '../../../shared/domain/Either'; +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; +import { AbsolutePath } from '../infrastructure/AbsolutePath'; + +export abstract class LocalFileHandler { + abstract upload( + path: AbsolutePath, + size: number, + abortSignal: AbortSignal + ): Promise>; + + abstract delete(contentsId: string): Promise; +} diff --git a/src/context/local/localFile/infrastructure/AbsolutePath.ts b/src/context/local/localFile/infrastructure/AbsolutePath.ts new file mode 100644 index 000000000..1bb8a6d96 --- /dev/null +++ b/src/context/local/localFile/infrastructure/AbsolutePath.ts @@ -0,0 +1,3 @@ +import { Brand } from '../../../shared/domain/Brand'; + +export type AbsolutePath = Brand; diff --git a/src/context/local/localFile/infrastructure/EnvironmentLocalFileUploader.ts b/src/context/local/localFile/infrastructure/EnvironmentLocalFileUploader.ts new file mode 100644 index 000000000..13d64d7f3 --- /dev/null +++ b/src/context/local/localFile/infrastructure/EnvironmentLocalFileUploader.ts @@ -0,0 +1,77 @@ +import { UploadStrategyFunction } from '@internxt/inxt-js/build/lib/core'; +import { Service } from 'diod'; +import { createReadStream } from 'fs'; +import { Stopwatch } from '../../../../apps/shared/types/Stopwatch'; +import { AbsolutePath } from './AbsolutePath'; +import { LocalFileHandler } from '../domain/LocalFileUploader'; +import { Environment } from '@internxt/inxt-js'; +import { Axios } from 'axios'; +import Logger from 'electron-log'; +import { Either, left, right } from '../../../shared/domain/Either'; +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; + +@Service() +export class EnvironmentLocalFileUploader implements LocalFileHandler { + private static MULTIPART_UPLOAD_SIZE_THRESHOLD = 5 * 1024 * 1024 * 1024; + + constructor( + private readonly environment: Environment, + private readonly bucket: string, + private readonly httpClient: Axios + ) {} + + upload( + path: AbsolutePath, + size: number, + abortSignal: AbortSignal + ): Promise> { + const fn: UploadStrategyFunction = + size > EnvironmentLocalFileUploader.MULTIPART_UPLOAD_SIZE_THRESHOLD + ? this.environment.uploadMultipartFile + : this.environment.upload; + + const readable = createReadStream(path); + + const stopwatch = new Stopwatch(); + + stopwatch.start(); + + return new Promise>((resolve) => { + const state = fn(this.bucket, { + source: readable, + fileSize: size, + finishedCallback: (err: Error | null, contentsId: string) => { + stopwatch.finish(); + + if (err) { + if (err.message === 'Max space used') { + return resolve(left(new DriveDesktopError('NOT_ENOUGH_SPACE'))); + } + return resolve(left(new DriveDesktopError('UNKNOWN'))); + } + + resolve(right(contentsId)); + }, + progressCallback: (progress: number) => { + Logger.debug(progress); + }, + }); + + abortSignal.addEventListener('abort', () => { + state.stop(); + readable.destroy(); + }); + }); + } + + async delete(contentsId: string): Promise { + try { + await this.httpClient.delete( + `${process.env.API_URL}/storage/bucket/${this.bucket}/file/${contentsId}` + ); + } catch (error) { + // Not being able to delete from the bucket is not critical + Logger.error(`Could not delete the file ${contentsId} from the bucket`); + } + } +} diff --git a/src/context/local/localFile/infrastructure/FsLocalFileRepository.ts b/src/context/local/localFile/infrastructure/FsLocalFileRepository.ts new file mode 100644 index 000000000..703cf0214 --- /dev/null +++ b/src/context/local/localFile/infrastructure/FsLocalFileRepository.ts @@ -0,0 +1,44 @@ +import { Service } from 'diod'; +import { LocalFileRepository } from '../domain/LocalFileRepository'; +import { LocalFile } from '../domain/LocalFile'; +import { AbsolutePath } from './AbsolutePath'; +import fs from 'fs/promises'; +import path from 'path'; + +@Service() +export class FsLocalFileRepository implements LocalFileRepository { + async files(absolutePath: AbsolutePath): Promise { + const dirents = await fs.readdir(absolutePath, { + withFileTypes: true, + }); + + const conversion = dirents + .filter((dirent) => dirent.isFile()) + .map(async (file) => { + const fileAbsolutePath = path.join( + absolutePath, + file.name + ) as AbsolutePath; + + const { mtime, size } = await fs.stat(fileAbsolutePath); + + return LocalFile.from({ + size: size, + path: fileAbsolutePath, + modificationTime: mtime.getTime(), + }); + }); + + const files = await Promise.all(conversion); + + return files; + } + + async folders(absolutePath: AbsolutePath): Promise { + const dirents = await fs.readdir(absolutePath, { withFileTypes: true }); + + return dirents + .filter((dirent) => dirent.isDirectory()) + .map((folder) => path.join(absolutePath, folder.name) as AbsolutePath); + } +} diff --git a/src/context/local/localFile/infrastructure/RendererIpcLocalFileMessenger.ts b/src/context/local/localFile/infrastructure/RendererIpcLocalFileMessenger.ts new file mode 100644 index 000000000..8888b8b91 --- /dev/null +++ b/src/context/local/localFile/infrastructure/RendererIpcLocalFileMessenger.ts @@ -0,0 +1,19 @@ +import { Service } from 'diod'; +import { BackupsIPCRenderer } from '../../../../apps/backups/BackupsIPCRenderer'; +import { LocalFile } from '../domain/LocalFile'; +import { LocalFileMessenger } from '../domain/LocalFileMessenger'; +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; + +@Service() +export class RendererIpcLocalFileMessenger implements LocalFileMessenger { + async creationFailed( + file: LocalFile, + error: DriveDesktopError + ): Promise { + BackupsIPCRenderer.send( + 'backups.file-issue', + file.nameWithExtension(), + error.cause + ); + } +} diff --git a/src/context/local/localFolder/domain/LocalFolder.ts b/src/context/local/localFolder/domain/LocalFolder.ts new file mode 100644 index 000000000..b78bd2bd2 --- /dev/null +++ b/src/context/local/localFolder/domain/LocalFolder.ts @@ -0,0 +1,45 @@ +import path from 'path'; +import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; +import { AbsolutePath } from '../../localFile/infrastructure/AbsolutePath'; + +export type LocalFolderAttributes = { + path: AbsolutePath; + modificationTime: number; +}; + +export class LocalFolder extends AggregateRoot { + private constructor( + private _path: AbsolutePath, + private _modificationTime: number + ) { + super(); + } + + get path(): AbsolutePath { + return this._path; + } + + get modificationTime(): number { + return this._modificationTime; + } + + basedir(): string { + const dirname = path.posix.dirname(this._path); + if (dirname === '.') { + return path.posix.sep; + } + + return dirname; + } + + static from(attributes: LocalFolderAttributes): LocalFolder { + return new LocalFolder(attributes.path, attributes.modificationTime); + } + + attributes(): LocalFolderAttributes { + return { + path: this._path, + modificationTime: this._modificationTime, + }; + } +} diff --git a/src/context/local/localTree/application/LocalTreeBuilder.ts b/src/context/local/localTree/application/LocalTreeBuilder.ts new file mode 100644 index 000000000..e11b68318 --- /dev/null +++ b/src/context/local/localTree/application/LocalTreeBuilder.ts @@ -0,0 +1,56 @@ +import { Service } from 'diod'; +import { LocalFile } from '../../localFile/domain/LocalFile'; +import { AbsolutePath } from '../../localFile/infrastructure/AbsolutePath'; +import { LocalItemsGenerator } from '../domain/LocalItemsGenerator'; +import { LocalTree } from '../domain/LocalTree'; +import { LocalFolder } from '../../localFolder/domain/LocalFolder'; +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; +import { Either, left, right } from '../../../shared/domain/Either'; + +@Service() +export default class LocalTreeBuilder { + constructor(private readonly generator: LocalItemsGenerator) {} + + private async traverse( + tree: LocalTree, + currentFolder: LocalFolder + ): Promise { + const { files, folders } = await this.generator.getAll(currentFolder.path); + + files.forEach((fileAttributes) => { + const file = LocalFile.from(fileAttributes); + tree.addFile(currentFolder, file); + }); + + for (const folderAttributes of folders) { + const folder = LocalFolder.from(folderAttributes); + + tree.addFolder(currentFolder, folder); + + // eslint-disable-next-line no-await-in-loop + await this.traverse(tree, folder); + } + + return tree; + } + + async run( + folder: AbsolutePath + ): Promise> { + const rootEither = await this.generator.root(folder); + + if (rootEither.isLeft()) { + return left(rootEither.getLeft()); + } + + const root = rootEither.getRight(); + + const rootFolder = LocalFolder.from(root); + + const tree = new LocalTree(rootFolder); + + await this.traverse(tree, rootFolder); + + return right(tree); + } +} diff --git a/src/context/local/localTree/domain/LocalFileNode.ts b/src/context/local/localTree/domain/LocalFileNode.ts new file mode 100644 index 000000000..72c67a851 --- /dev/null +++ b/src/context/local/localTree/domain/LocalFileNode.ts @@ -0,0 +1,22 @@ +import { LocalFile } from '../../localFile/domain/LocalFile'; +import { LocalFolderNode } from './LocalFolderNode'; + +export class LocalFileNode { + private constructor(public readonly file: LocalFile) {} + + static from(file: LocalFile): LocalFileNode { + return new LocalFileNode(file); + } + + public get id(): string { + return this.file.path; + } + + public isFile(): this is LocalFileNode { + return true; + } + + public isFolder(): this is LocalFolderNode { + return false; + } +} diff --git a/src/context/local/localTree/domain/LocalFolderNode.ts b/src/context/local/localTree/domain/LocalFolderNode.ts new file mode 100644 index 000000000..47c49d2ed --- /dev/null +++ b/src/context/local/localTree/domain/LocalFolderNode.ts @@ -0,0 +1,34 @@ +import { LocalFolder } from '../../localFolder/domain/LocalFolder'; +import { LocalFileNode } from './LocalFileNode'; +import { Node } from './Node'; + +export class LocalFolderNode { + private constructor( + public readonly folder: LocalFolder, + private children: Map + ) {} + + static from(folder: LocalFolder): LocalFolderNode { + return new LocalFolderNode(folder, new Map()); + } + + public get id(): string { + return this.folder.path; + } + + addChild(node: Node): void { + if (this.children.has(node.id)) { + throw new Error(`Duplicated node detected: ${node.id}`); + } + + this.children.set(node.id, node); + } + + public isFile(): this is LocalFileNode { + return false; + } + + public isFolder(): this is LocalFolderNode { + return true; + } +} diff --git a/src/context/local/localTree/domain/LocalItemsGenerator.ts b/src/context/local/localTree/domain/LocalItemsGenerator.ts new file mode 100644 index 000000000..18ab92d63 --- /dev/null +++ b/src/context/local/localTree/domain/LocalItemsGenerator.ts @@ -0,0 +1,14 @@ +import { Either } from '../../../shared/domain/Either'; +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; +import { LocalFileDTO } from '../infrastructure/LocalFileDTO'; +import { LocalFolderDTO } from '../infrastructure/LocalFolderDTO'; + +export abstract class LocalItemsGenerator { + abstract getAll(from: string): Promise<{ + files: Array; + folders: Array; + }>; + abstract root( + dir: string + ): Promise>; +} diff --git a/src/context/local/localTree/domain/LocalTree.ts b/src/context/local/localTree/domain/LocalTree.ts new file mode 100644 index 000000000..080577780 --- /dev/null +++ b/src/context/local/localTree/domain/LocalTree.ts @@ -0,0 +1,105 @@ +import { LocalFile } from '../../localFile/domain/LocalFile'; +import { LocalFolder } from '../../localFolder/domain/LocalFolder'; +import { LocalFileNode } from './LocalFileNode'; +import { LocalFolderNode } from './LocalFolderNode'; +import { Node } from './Node'; + +export class LocalTree { + private tree: Map; + public readonly root: LocalFolder; + + constructor(rootFolder: LocalFolder) { + const clone = LocalFolder.from(rootFolder.attributes()); + const node = LocalFolderNode.from(clone); + this.root = clone; + + this.tree = new Map(); + this.tree.set(clone.path, node); + } + + public get files(): Array { + const files: Array = []; + + this.tree.forEach((node) => { + if (node.isFile()) { + files.push(node.file); + } + }); + + return files; + } + + public get filePaths(): Array { + return this.files.map((f) => f.path); + } + + public get folders(): Array { + const folders: Array = []; + + this.tree.forEach((node) => { + if (node.isFolder()) { + folders.push(node.folder); + } + }); + + return folders; + } + + public get folderPaths(): Array { + return this.folders.map((f) => f.path); + } + + private addNode(node: Node): void { + this.tree.set(node.id, node); + } + + addFile(parentNode: LocalFolder, file: LocalFile) { + const parent = this.tree.get(parentNode.path) as LocalFolderNode; + + if (!parent) { + throw new Error( + `Parent node not found for ${JSON.stringify( + file.attributes(), + null, + 2 + )}` + ); + } + + const node = LocalFileNode.from(file); + + parent.addChild(node); + this.addNode(node); + } + + addFolder(parentNode: LocalFolder, folder: LocalFolder) { + const parent = this.tree.get(parentNode.path) as LocalFolderNode; + + if (!parent) { + throw new Error('Parent node not found'); + } + + const node = LocalFolderNode.from(folder); + + parent.addChild(node); + this.addNode(node); + } + + has(id: string): boolean { + return this.tree.has(id); + } + + get(id: string): LocalFile | LocalFolder { + const node = this.tree.get(id); + + if (!node) { + throw new Error(`Could not get the node ${id}`); + } + + if (node.isFile()) { + return node.file; + } + + return node.folder; + } +} diff --git a/src/context/local/localTree/domain/Node.ts b/src/context/local/localTree/domain/Node.ts new file mode 100644 index 000000000..53777ea1d --- /dev/null +++ b/src/context/local/localTree/domain/Node.ts @@ -0,0 +1,4 @@ +import { LocalFileNode } from './LocalFileNode'; +import { LocalFolderNode } from './LocalFolderNode'; + +export type Node = LocalFileNode | LocalFolderNode; diff --git a/src/context/local/localTree/infrastructure/FsLocalItemsGenerator.ts b/src/context/local/localTree/infrastructure/FsLocalItemsGenerator.ts new file mode 100644 index 000000000..dc7f3f52a --- /dev/null +++ b/src/context/local/localTree/infrastructure/FsLocalItemsGenerator.ts @@ -0,0 +1,90 @@ +import { Service } from 'diod'; +import fs from 'fs/promises'; +import path from 'path'; +import { Either, left, right } from '../../../shared/domain/Either'; +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; +import { AbsolutePath } from '../../localFile/infrastructure/AbsolutePath'; +import { LocalItemsGenerator } from '../domain/LocalItemsGenerator'; +import { LocalFileDTO } from './LocalFileDTO'; +import { LocalFolderDTO } from './LocalFolderDTO'; + +@Service() +export class CLSFsLocalItemsGenerator implements LocalItemsGenerator { + async root(dir: string): Promise> { + try { + const stat = await fs.stat(dir); + + if (stat.isFile()) { + throw new Error('A file cannot be the root of a tree'); + } + + return right({ + path: dir as AbsolutePath, + modificationTime: stat.mtime.getTime(), + }); + } catch (err) { + const { code } = err as { code?: string }; + + if (code === 'ENOENT') { + return left( + new DriveDesktopError( + 'BASE_DIRECTORY_DOES_NOT_EXIST', + `${dir} does not exist` + ) + ); + } + if (code === 'EACCES') { + return left( + new DriveDesktopError( + 'INSUFFICIENT_PERMISSION', + `Cannot read stats of ${dir}` + ) + ); + } + + return left( + new DriveDesktopError( + 'UNKNOWN', + `An unknown error with code ${code} happened when reading ${dir}` + ) + ); + } + } + + async getAll( + dir: string + ): Promise<{ files: LocalFileDTO[]; folders: LocalFolderDTO[] }> { + const accumulator = Promise.resolve({ + files: [] as LocalFileDTO[], + folders: [] as LocalFolderDTO[], + }); + + const dirents = await fs.readdir(dir, { + withFileTypes: true, + }); + + return dirents.reduce(async (promise, dirent) => { + const acc = await promise; + + const absolutePath = path.join(dir, dirent.name) as AbsolutePath; + const stat = await fs.stat(absolutePath); + + if (dirent.isFile()) { + acc.files.push({ + path: absolutePath, + modificationTime: stat.mtime.getTime(), + size: stat.size, + }); + } + + if (dirent.isDirectory()) { + acc.folders.push({ + path: absolutePath, + modificationTime: stat.mtime.getTime(), + }); + } + + return acc; + }, accumulator); + } +} diff --git a/src/context/local/localTree/infrastructure/LocalFileDTO.ts b/src/context/local/localTree/infrastructure/LocalFileDTO.ts new file mode 100644 index 000000000..9b26dc823 --- /dev/null +++ b/src/context/local/localTree/infrastructure/LocalFileDTO.ts @@ -0,0 +1,7 @@ +import { AbsolutePath } from '../../localFile/infrastructure/AbsolutePath'; + +export type LocalFileDTO = { + path: AbsolutePath; + modificationTime: number; + size: number; +}; diff --git a/src/context/local/localTree/infrastructure/LocalFolderDTO.ts b/src/context/local/localTree/infrastructure/LocalFolderDTO.ts new file mode 100644 index 000000000..7e44b9973 --- /dev/null +++ b/src/context/local/localTree/infrastructure/LocalFolderDTO.ts @@ -0,0 +1,6 @@ +import { AbsolutePath } from '../../localFile/infrastructure/AbsolutePath'; + +export type LocalFolderDTO = { + path: AbsolutePath; + modificationTime: number; +}; diff --git a/src/context/local/shared/application/FilesIndexedByPath.ts b/src/context/local/shared/application/FilesIndexedByPath.ts new file mode 100644 index 000000000..f0d5a9009 --- /dev/null +++ b/src/context/local/shared/application/FilesIndexedByPath.ts @@ -0,0 +1,3 @@ +import { AbsolutePath } from '../../localFile/infrastructure/AbsolutePath'; + +export type FilesIndexedByPath = Map; diff --git a/src/context/shared/domain/AggregateRoot.ts b/src/context/shared/domain/AggregateRoot.ts index 5293e60bf..066bea31f 100644 --- a/src/context/shared/domain/AggregateRoot.ts +++ b/src/context/shared/domain/AggregateRoot.ts @@ -1,4 +1,4 @@ -import { Primitives } from './ValueObject'; +import { Primitives } from './value-objects/ValueObject'; import { DomainEvent } from './DomainEvent'; export abstract class AggregateRoot { @@ -20,4 +20,8 @@ export abstract class AggregateRoot { } abstract attributes(): Record; + + toString(): string { + return JSON.stringify(this.attributes(), null, 2); + } } diff --git a/src/context/shared/domain/TokenProvider.ts b/src/context/shared/domain/TokenProvider.ts new file mode 100644 index 000000000..0b01f1d05 --- /dev/null +++ b/src/context/shared/domain/TokenProvider.ts @@ -0,0 +1,4 @@ +export abstract class TokenProvider { + abstract getToken(): Promise; + abstract getNewToken(): Promise; +} diff --git a/src/context/shared/domain/issues/Issue.ts b/src/context/shared/domain/issues/Issue.ts new file mode 100644 index 000000000..e0c691783 --- /dev/null +++ b/src/context/shared/domain/issues/Issue.ts @@ -0,0 +1,4 @@ +export type Issue = { + nodeName: string; + issue: string; +}; diff --git a/src/context/shared/domain/value-objects/BucketEntry.ts b/src/context/shared/domain/value-objects/BucketEntry.ts new file mode 100644 index 000000000..2ba6206de --- /dev/null +++ b/src/context/shared/domain/value-objects/BucketEntry.ts @@ -0,0 +1,24 @@ +import { ValueObject } from './ValueObject'; + +export class BucketEntry extends ValueObject { + public static MAX_SIZE = 20 * 1024 * 1024 * 1024; + + constructor(value: number) { + super(value); + this.ensureIsValid(value); + } + + private ensureIsValid(value: number) { + if (value > BucketEntry.MAX_SIZE) { + throw new Error('File size to big'); + } + + if (value < 0) { + throw new Error('File size cannot be negative'); + } + + // if (value === 0) { + // throw new Error('File size cannot be zero'); + // } + } +} diff --git a/src/context/shared/domain/value-objects/DateValueObject.ts b/src/context/shared/domain/value-objects/DateValueObject.ts new file mode 100644 index 000000000..704e95f4d --- /dev/null +++ b/src/context/shared/domain/value-objects/DateValueObject.ts @@ -0,0 +1,38 @@ +import { ValueObject } from './ValueObject'; + +export class DateValueObject extends ValueObject { + static fromString(value: string): DateValueObject { + const date = new Date(value); + + return new DateValueObject(date); + } + + static now(): DateValueObject { + const date = new Date(); + + return new DateValueObject(date); + } + + isPrevious(than: Date): boolean { + return this.value < than; + } + + isAfter(than: Date): boolean { + return this.value > than; + } + + same(other: Date): boolean { + return this.value.getTime() === other.getTime(); + } + + equals(other: DateValueObject): boolean { + return ( + other.constructor.name === this.constructor.name && + other.value.getTime() === this.value.getTime() + ); + } + + toISOString(): string { + return this.value.toISOString(); + } +} diff --git a/src/context/shared/domain/value-objects/EnumValueObject.ts b/src/context/shared/domain/value-objects/EnumValueObject.ts new file mode 100644 index 000000000..adcd5c691 --- /dev/null +++ b/src/context/shared/domain/value-objects/EnumValueObject.ts @@ -0,0 +1,16 @@ +export abstract class EnumValueObject { + readonly value: T; + + constructor(value: T, public readonly validValues: T[]) { + this.value = value; + this.checkValueIsValid(value); + } + + public checkValueIsValid(value: T): void { + if (!this.validValues.includes(value)) { + this.throwErrorForInvalidValue(value); + } + } + + protected abstract throwErrorForInvalidValue(value: T): void; +} diff --git a/src/context/shared/domain/value-objects/NumericId.ts b/src/context/shared/domain/value-objects/NumericId.ts new file mode 100644 index 000000000..dae0d792d --- /dev/null +++ b/src/context/shared/domain/value-objects/NumericId.ts @@ -0,0 +1,17 @@ +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; +import { ValueObject } from './ValueObject'; + +export class NumericId extends ValueObject { + constructor(value: number) { + super(value); + this.ensureIsValid(value); + } + + private ensureIsValid(value: number) { + if (value <= 0) { + throw new InvalidArgumentError( + `A numeric id cannot be negative, value: ${value}` + ); + } + } +} diff --git a/src/context/shared/domain/value-objects/Path.ts b/src/context/shared/domain/value-objects/Path.ts new file mode 100644 index 000000000..8ede3aab9 --- /dev/null +++ b/src/context/shared/domain/value-objects/Path.ts @@ -0,0 +1,68 @@ +import path from 'path'; +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; +import { ValueObject } from './ValueObject'; + +const isWindowsRootDirectory = /^[a-zA-Z]:[\\/]/; +const containsNullCharacter = /\0/g; + +export abstract class Path extends ValueObject { + private static readonly maliciousPathValidations = [ + (name: string) => name.includes('../'), + (name: string) => name.startsWith('..'), + (name: string) => isWindowsRootDirectory.test(name), + (name: string) => containsNullCharacter.test(name), + ]; + + constructor(value: string) { + const normalized = path.normalize(value); + + super(normalized); + + this.ensurePathIsPosix(normalized); + this.ensurePathIsNotMalicious(normalized); + } + + private ensurePathIsNotMalicious(value: string) { + const isMalicious = Path.maliciousPathValidations.some((validation) => + validation(value) + ); + + if (isMalicious) { + throw new InvalidArgumentError(`Path ${value} might be malicious.`); + } + } + + private ensurePathIsPosix(value: string) { + const isPosix = value.indexOf('/') !== -1; + + if (!isPosix) { + throw new InvalidArgumentError(`Paths have to be posix, path: ${value}`); + } + } + + name(): string { + const base = path.posix.basename(this.value); + const { name } = path.posix.parse(base); + return name; + } + + basename(): string { + return path.posix.basename(this.value); + } + + dirname(): string { + const dirname = path.posix.dirname(this.value); + if (dirname === '.') { + return path.posix.sep; + } + + return dirname; + } + + hasSameName(other: Path) { + const name = this.name(); + const otherName = other.name(); + + return name === otherName; + } +} diff --git a/src/context/shared/domain/value-objects/Uuid.ts b/src/context/shared/domain/value-objects/Uuid.ts new file mode 100644 index 000000000..887b88e7a --- /dev/null +++ b/src/context/shared/domain/value-objects/Uuid.ts @@ -0,0 +1,20 @@ +import * as uuid from 'uuid'; +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; +import { ValueObject } from './ValueObject'; + +export class Uuid extends ValueObject { + constructor(value: string) { + super(value); + this.ensureIsValid(value); + } + + private ensureIsValid(value: string) { + if (!uuid.validate(value)) { + throw new InvalidArgumentError(`Value: ${value} is not a valid uuid`); + } + } + + static random(): Uuid { + return new Uuid(uuid.v4()); + } +} diff --git a/src/context/shared/domain/value-objects/ValueObject.ts b/src/context/shared/domain/value-objects/ValueObject.ts new file mode 100644 index 000000000..9c5f5ffe7 --- /dev/null +++ b/src/context/shared/domain/value-objects/ValueObject.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; + +export type Primitives = string | number | boolean | Date | undefined; + +export abstract class ValueObject { + readonly value: T; + + constructor(value: T) { + this.value = value; + this.ensureValueIsDefined(value); + } + + private ensureValueIsDefined(value: T): void { + if (value === null || value === undefined) { + throw new InvalidArgumentError('Value must be defined'); + } + } + + equals(other: ValueObject): boolean { + return ( + other.constructor.name === this.constructor.name && + other.value === this.value + ); + } +} diff --git a/src/context/shared/infrastructure/BackgroundProcess/BackgroundProcessAuthorizedClients.ts b/src/context/shared/infrastructure/BackgroundProcess/BackgroundProcessAuthorizedClients.ts new file mode 100644 index 000000000..a51789bb7 --- /dev/null +++ b/src/context/shared/infrastructure/BackgroundProcess/BackgroundProcessAuthorizedClients.ts @@ -0,0 +1,14 @@ +import { AuthorizedClients } from '../../../../apps/shared/HttpClient/Clients'; +import { getClients } from '../../../../apps/shared/HttpClient/background-process-clients'; + +export class BackgroundProcessAuthorizedClients implements AuthorizedClients { + public drive: AuthorizedClients['drive']; + public newDrive: AuthorizedClients['drive']; + + constructor() { + const { drive, newDrive } = getClients(); + + this.drive = drive as AuthorizedClients['drive']; + this.newDrive = newDrive as AuthorizedClients['drive']; + } +} diff --git a/src/context/shared/infrastructure/BackgroundProcess/BackgroundProcessTokenProvider.ts b/src/context/shared/infrastructure/BackgroundProcess/BackgroundProcessTokenProvider.ts new file mode 100644 index 000000000..458908c9c --- /dev/null +++ b/src/context/shared/infrastructure/BackgroundProcess/BackgroundProcessTokenProvider.ts @@ -0,0 +1,14 @@ +import { TokenProvider } from '../../domain/TokenProvider'; +import { ipcRenderer } from 'electron'; +import { Service } from 'diod'; + +@Service() +export class BackgroundProcessTokenProvider implements TokenProvider { + getToken(): Promise { + return ipcRenderer.invoke('get-token'); + } + + getNewToken(): Promise { + return ipcRenderer.invoke('get-new-token'); + } +} diff --git a/src/context/user/usage/application/BytesInBinaryToInternationalSystem.ts b/src/context/user/usage/application/BytesInBinaryToInternationalSystem.ts new file mode 100644 index 000000000..49014a654 --- /dev/null +++ b/src/context/user/usage/application/BytesInBinaryToInternationalSystem.ts @@ -0,0 +1,30 @@ +export class BytesInBinaryToInternationalSystem { + private static BINARY_CONVERSION_FACTOR = 1024; + private static SI_CONVERSION_FACTOR = 1000; + + static run(bytes: number) { + if (bytes === 0) { + return 0; + } + + const exponent = Math.floor( + Math.log(bytes) / + Math.log(BytesInBinaryToInternationalSystem.BINARY_CONVERSION_FACTOR) + ); + + const bin = + bytes / + Math.pow( + BytesInBinaryToInternationalSystem.BINARY_CONVERSION_FACTOR, + exponent + ); + + return ( + bin * + Math.pow( + BytesInBinaryToInternationalSystem.SI_CONVERSION_FACTOR, + exponent + ) + ); + } +} diff --git a/src/context/user/usage/application/DecrementDriveUsageOnFileDeleted.ts b/src/context/user/usage/application/DecrementDriveUsageOnFileDeleted.ts new file mode 100644 index 000000000..e96690fe9 --- /dev/null +++ b/src/context/user/usage/application/DecrementDriveUsageOnFileDeleted.ts @@ -0,0 +1,19 @@ +import { DomainEventClass } from '../../../shared/domain/DomainEvent'; +import { DomainEventSubscriber } from '../../../shared/domain/DomainEventSubscriber'; +import { FileCreatedDomainEvent } from '../../../virtual-drive/files/domain/events/FileCreatedDomainEvent'; +import { FileDeletedDomainEvent } from '../../../virtual-drive/files/domain/events/FileDeletedDomainEvent'; +import { UserUsageDecrementor } from './UserUsageDecrementor'; + +export class DecrementDriveUsageOnFileDeleted + implements DomainEventSubscriber +{ + constructor(private readonly decrementor: UserUsageDecrementor) {} + + subscribedTo(): DomainEventClass[] { + return [FileDeletedDomainEvent]; + } + + on(domainEvent: FileDeletedDomainEvent): Promise { + return this.decrementor.run(domainEvent.size); + } +} diff --git a/src/context/user/usage/application/FreeSpacePerEnvironmentCalculator.ts b/src/context/user/usage/application/FreeSpacePerEnvironmentCalculator.ts new file mode 100644 index 000000000..a7bd8e42e --- /dev/null +++ b/src/context/user/usage/application/FreeSpacePerEnvironmentCalculator.ts @@ -0,0 +1,24 @@ +import { UserUsageRepository } from '../domain/UserUsageRepository'; +import { BytesInBinaryToInternationalSystem } from './BytesInBinaryToInternationalSystem'; + +export class FreeSpacePerEnvironmentCalculator { + constructor(private readonly repository: UserUsageRepository) {} + + async run(): Promise { + const usage = await this.repository.getUsage(); + + if (usage.isInfinite()) { + return -1; + } + + const freeSpace = usage.free(); + + if (process.platform === 'linux') + return BytesInBinaryToInternationalSystem.run(freeSpace); + + if (process.platform === 'darwin') + return BytesInBinaryToInternationalSystem.run(freeSpace); + + return freeSpace; + } +} diff --git a/src/context/user/usage/application/IncrementDriveUsageOnFileCreated.ts b/src/context/user/usage/application/IncrementDriveUsageOnFileCreated.ts new file mode 100644 index 000000000..1bbe5339a --- /dev/null +++ b/src/context/user/usage/application/IncrementDriveUsageOnFileCreated.ts @@ -0,0 +1,18 @@ +import { DomainEventClass } from '../../../shared/domain/DomainEvent'; +import { DomainEventSubscriber } from '../../../shared/domain/DomainEventSubscriber'; +import { FileCreatedDomainEvent } from '../../../virtual-drive/files/domain/events/FileCreatedDomainEvent'; +import { UserUsageIncrementor } from './UserUsageIncrementor'; + +export class IncrementDriveUsageOnFileCreated + implements DomainEventSubscriber +{ + constructor(private readonly incrementor: UserUsageIncrementor) {} + + subscribedTo(): DomainEventClass[] { + return [FileCreatedDomainEvent]; + } + + on(domainEvent: FileCreatedDomainEvent): Promise { + return this.incrementor.run(domainEvent.size); + } +} diff --git a/src/context/user/usage/application/UsedSpaceCalculator.ts b/src/context/user/usage/application/UsedSpaceCalculator.ts new file mode 100644 index 000000000..ba8375486 --- /dev/null +++ b/src/context/user/usage/application/UsedSpaceCalculator.ts @@ -0,0 +1,20 @@ +import { UserUsageRepository } from '../domain/UserUsageRepository'; +import { BytesInBinaryToInternationalSystem } from './BytesInBinaryToInternationalSystem'; + +export class UsedSpaceCalculator { + constructor(private readonly repository: UserUsageRepository) {} + + async run(): Promise { + const usage = await this.repository.getUsage(); + + const used = usage.totalInUse(); + + if (process.platform === 'linux') + return BytesInBinaryToInternationalSystem.run(used); + + if (process.platform === 'darwin') + return BytesInBinaryToInternationalSystem.run(used); + + return used; + } +} diff --git a/src/context/user/usage/application/UserAvaliableSpaceValidator.ts b/src/context/user/usage/application/UserAvaliableSpaceValidator.ts new file mode 100644 index 000000000..4dc8b2731 --- /dev/null +++ b/src/context/user/usage/application/UserAvaliableSpaceValidator.ts @@ -0,0 +1,13 @@ +import { Service } from 'diod'; +import { UserUsageRepository } from '../domain/UserUsageRepository'; + +@Service() +export class UserAvaliableSpaceValidator { + constructor(private readonly repository: UserUsageRepository) {} + + async run(desiredSpaceToUse: number): Promise { + const usage = await this.repository.getUsage(); + + return desiredSpaceToUse < usage.free(); + } +} diff --git a/src/context/user/usage/application/UserUsageDecrementor.ts b/src/context/user/usage/application/UserUsageDecrementor.ts new file mode 100644 index 000000000..e1a5dbd8f --- /dev/null +++ b/src/context/user/usage/application/UserUsageDecrementor.ts @@ -0,0 +1,15 @@ +import { UserUsageRepository } from '../domain/UserUsageRepository'; + +export class UserUsageDecrementor { + constructor(private readonly repository: UserUsageRepository) {} + + async run(weight: number) { + const usage = await this.repository.getUsage(); + + if (usage.drive >= weight) { + usage.incrementDriveUsage(-weight); + + // await this.repository.save(usage); + } + } +} diff --git a/src/context/user/usage/application/UserUsageIncrementor.ts b/src/context/user/usage/application/UserUsageIncrementor.ts new file mode 100644 index 000000000..49c16c48e --- /dev/null +++ b/src/context/user/usage/application/UserUsageIncrementor.ts @@ -0,0 +1,15 @@ +import { UserUsageRepository } from '../domain/UserUsageRepository'; + +export class UserUsageIncrementor { + constructor(private readonly repository: UserUsageRepository) {} + + async run(weight: number) { + const usage = await this.repository.getUsage(); + + if (usage.free() >= weight) { + usage.incrementDriveUsage(weight); + + // await this.repository.save(usage); + } + } +} diff --git a/src/context/user/usage/domain/UserUsage.ts b/src/context/user/usage/domain/UserUsage.ts new file mode 100644 index 000000000..032d1db41 --- /dev/null +++ b/src/context/user/usage/domain/UserUsage.ts @@ -0,0 +1,37 @@ +export class UserUsage { + private static MAX_USAGE_LIMIT = 108851651149824; + + private constructor( + private _drive: number, + public readonly photos: number, + public readonly limit: number + ) {} + + public get drive(): number { + return this._drive; + } + + static from(atributes: { + drive: number; + photos: number; + limit: number; + }): UserUsage { + return new UserUsage(atributes.drive, atributes.photos, atributes.limit); + } + + incrementDriveUsage(usage: number) { + this._drive += usage; + } + + totalInUse(): number { + return this._drive + this.photos; + } + + free(): number { + return this.limit - this.totalInUse(); + } + + isInfinite(): boolean { + return this.limit >= UserUsage.MAX_USAGE_LIMIT; + } +} diff --git a/src/context/user/usage/domain/UserUsageRepository.ts b/src/context/user/usage/domain/UserUsageRepository.ts new file mode 100644 index 000000000..685567350 --- /dev/null +++ b/src/context/user/usage/domain/UserUsageRepository.ts @@ -0,0 +1,5 @@ +import { UserUsage } from './UserUsage'; + +export abstract class UserUsageRepository { + abstract getUsage(): Promise; +} diff --git a/src/context/user/usage/infrastrucutre/CachedHttpUserUsageRepository.ts b/src/context/user/usage/infrastrucutre/CachedHttpUserUsageRepository.ts new file mode 100644 index 000000000..e51251229 --- /dev/null +++ b/src/context/user/usage/infrastrucutre/CachedHttpUserUsageRepository.ts @@ -0,0 +1,60 @@ +import PhotosSubmodule from '@internxt/sdk/dist/photos/photos'; +import { Axios } from 'axios'; +import { UserUsage } from '../domain/UserUsage'; +import { UserUsageRepository } from '../domain/UserUsageRepository'; +import { UserUsageLimitDTO } from './dtos/UserUsageLimitDTO'; +import { Service } from 'diod'; + +@Service() +export class CachedHttpUserUsageRepository implements UserUsageRepository { + private cachedUserUsage: UserUsage | undefined; + + constructor( + private readonly driveClient: Axios, + private readonly photosSubmodule: PhotosSubmodule + ) {} + + private async getDriveUsage(): Promise { + const response = await this.driveClient.get(`${process.env.API_URL}/usage`); + + if (response.status !== 200) { + throw new Error('Error retrieving drive usage'); + } + + return response.data.total; + } + + private async getLimit(): Promise { + const response = await this.driveClient.get( + `${process.env.API_URL}/limit` + ); + + if (response.status !== 200) { + throw new Error('Error getting users usage limit'); + } + + return response.data.maxSpaceBytes as number; + } + + async getUsage(): Promise { + if (this.cachedUserUsage) return this.cachedUserUsage; + + const drive = await this.getDriveUsage(); + const { usage: photos } = await this.photosSubmodule.getUsage(); + const limit = await this.getLimit(); + + const usage = UserUsage.from({ + drive, + photos, + limit, + }); + + this.cachedUserUsage = usage; + + return usage; + } + + async save(usage: UserUsage) { + this.cachedUserUsage = usage; + } +} diff --git a/src/context/user/usage/infrastrucutre/IpcUserUsageRepository.ts b/src/context/user/usage/infrastrucutre/IpcUserUsageRepository.ts new file mode 100644 index 000000000..44d5c66fc --- /dev/null +++ b/src/context/user/usage/infrastrucutre/IpcUserUsageRepository.ts @@ -0,0 +1,17 @@ +import { Service } from 'diod'; +import { UserUsageRepository } from '../domain/UserUsageRepository'; +import { UserUsage } from '../domain/UserUsage'; +import { AccountIpcRenderer } from '../../../../apps/shared/IPC/events/account/AccountIpcRenderer'; + +@Service() +export class IpcUserUsageRepository implements UserUsageRepository { + async getUsage(): Promise { + const usage = await AccountIpcRenderer.invoke('account.get-usage'); + + return UserUsage.from({ + drive: usage.driveUsage, + photos: usage.photosUsage, + limit: usage.limitInBytes, + }); + } +} diff --git a/src/context/user/usage/infrastrucutre/dtos/UserUsageLimitDTO.ts b/src/context/user/usage/infrastrucutre/dtos/UserUsageLimitDTO.ts new file mode 100644 index 000000000..39ec7ac97 --- /dev/null +++ b/src/context/user/usage/infrastrucutre/dtos/UserUsageLimitDTO.ts @@ -0,0 +1,3 @@ +export interface UserUsageLimitDTO { + maxSpaceBytes: number; +} diff --git a/src/context/virtual-drive/files/application/FileCreator.ts b/src/context/virtual-drive/files/application/FileCreator.ts index 341d5693d..739cb99d9 100644 --- a/src/context/virtual-drive/files/application/FileCreator.ts +++ b/src/context/virtual-drive/files/application/FileCreator.ts @@ -51,10 +51,6 @@ export class FileCreator { Logger.debug('[DEBUG IN FILECREATOR STEEP 3]' + filePath.value); - const existingPersistedFile = await this.remote.checkStatusFile(contents.id); - - Logger.debug('[DEBUG IN FILECREATOR STEEP 3.1]' + existingPersistedFile); - const persistedAttributes = await this.remote.persist(offline); Logger.debug('[DEBUG IN FILECREATOR STEEP 4]' + filePath.value); diff --git a/src/context/virtual-drive/files/application/FileCreatorFromServerFile.ts b/src/context/virtual-drive/files/application/FileCreatorFromServerFile.ts index dad957b45..12a4cbdc0 100644 --- a/src/context/virtual-drive/files/application/FileCreatorFromServerFile.ts +++ b/src/context/virtual-drive/files/application/FileCreatorFromServerFile.ts @@ -1,11 +1,14 @@ import { ServerFile } from '../../../shared/domain/ServerFile'; -import { File } from '../domain/File'; +import { File } from '../../files/domain/File'; +import { FileStatuses } from '../../files/domain/FileStatus'; export function createFileFromServerFile( server: ServerFile, relativePath: string ): File { return File.from({ + id: server.id, + uuid: server.uuid, folderId: server.folderId, contentsId: server.fileId, modificationTime: server.modificationTime, @@ -13,7 +16,6 @@ export function createFileFromServerFile( createdAt: server.createdAt, updatedAt: server.updatedAt, path: relativePath, - status: server.status, - uuid: server.uuid, + status: FileStatuses[server.status as 'EXISTS' | 'TRASHED' | 'DELETED'], }); } diff --git a/src/context/virtual-drive/files/application/FileDeleter.ts b/src/context/virtual-drive/files/application/FileDeleter.ts index 185232c63..7cd7b6df2 100644 --- a/src/context/virtual-drive/files/application/FileDeleter.ts +++ b/src/context/virtual-drive/files/application/FileDeleter.ts @@ -6,7 +6,9 @@ import { FileRepository } from '../domain/FileRepository'; import { RemoteFileSystem } from '../domain/file-systems/RemoteFileSystem'; import { LocalFileSystem } from '../domain/file-systems/LocalFileSystem'; import { SyncEngineIpc } from '../../../../apps/sync-engine/ipcRendererSyncEngine'; +import { Service } from 'diod'; +@Service() export class FileDeleter { constructor( private readonly remote: RemoteFileSystem, @@ -29,7 +31,7 @@ export class FileDeleter { } const allParentsExists = this.allParentFoldersStatusIsExists.run( - file.folderId + file.folderId.value ); if (!allParentsExists) { diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts new file mode 100644 index 000000000..bbfe2603a --- /dev/null +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -0,0 +1,42 @@ +import { Service } from 'diod'; +import Logger from 'electron-log'; +import { TemporalFileUploadedDomainEvent } from '../../../../storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent'; +import { DomainEventClass } from '../../../../shared/domain/DomainEvent'; +import { DomainEventSubscriber } from '../../../../shared/domain/DomainEventSubscriber'; +import { FileCreator } from './FileCreator'; +import { FileOverrider } from '../override/FileOverrider'; + +@Service() +export class CreateFileOnTemporalFileUploaded + implements DomainEventSubscriber +{ + constructor( + private readonly creator: FileCreator, + private readonly fileOverrider: FileOverrider + ) {} + + subscribedTo(): DomainEventClass[] { + return [TemporalFileUploadedDomainEvent]; + } + + private async create(event: TemporalFileUploadedDomainEvent): Promise { + if (event.replaces) { + await this.fileOverrider.run( + event.replaces, + event.aggregateId, + event.size + ); + return; + } + + await this.creator.run(event.path, event.aggregateId, event.size); + } + + async on(event: TemporalFileUploadedDomainEvent): Promise { + try { + this.create(event); + } catch (err) { + Logger.error('[CreateFileOnOfflineFileUploaded]:', err); + } + } +} diff --git a/src/context/virtual-drive/files/application/create/FileCreator.ts b/src/context/virtual-drive/files/application/create/FileCreator.ts new file mode 100644 index 000000000..4e6cdb340 --- /dev/null +++ b/src/context/virtual-drive/files/application/create/FileCreator.ts @@ -0,0 +1,99 @@ +import { Service } from 'diod'; +import Logger from 'electron-log'; +import { basename } from 'path'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; +import { ParentFolderFinder } from '../../../folders/application/ParentFolderFinder'; +import { PlatformPathConverter } from '../../../shared/application/PlatformPathConverter'; +import { EventBus } from '../../../shared/domain/EventBus'; +import { File } from '../../domain/File'; +import { FilePath } from '../../domain/FilePath'; +import { FileRepository } from '../../domain/FileRepository'; +import { FileSize } from '../../domain/FileSize'; +import { FileStatuses } from '../../domain/FileStatus'; +import { SyncFileMessenger } from '../../domain/SyncFileMessenger'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; +import { FileTrasher } from '../trash/FileTrasher'; +import { FileContentsId } from '../../domain/FileContentsId'; +import { FileFolderId } from '../../domain/FileFolderId'; + +@Service() +export class FileCreator { + constructor( + private readonly remote: RemoteFileSystem, + private readonly repository: FileRepository, + private readonly parentFolderFinder: ParentFolderFinder, + private readonly fileDeleter: FileTrasher, + private readonly eventBus: EventBus, + private readonly notifier: SyncFileMessenger + ) {} + + async run(path: string, contentsId: string, size: number): Promise { + try { + const existingFiles = this.repository.matchingPartial({ + path: PlatformPathConverter.winToPosix(path), + status: FileStatuses.EXISTS, + }); + + if (existingFiles) { + await Promise.all( + existingFiles.map((existingFile) => { + return this.fileDeleter.run(existingFile.contentsId); + }) + ); + } + + const fileSize = new FileSize(size); + const fileContentsId = new FileContentsId(contentsId); + const filePath = new FilePath(path); + + const folder = await this.parentFolderFinder.run(filePath); + const fileFolderId = new FileFolderId(folder.id); + + const either = await this.remote.persistv2({ + contentsId: fileContentsId, + path: filePath, + size: fileSize, + folderId: fileFolderId, + }); + + if (either.isLeft()) { + throw either.getLeft(); + } + + const { modificationTime, id, uuid, createdAt } = either.getRight(); + + const file = File.createv2({ + id, + uuid, + contentsId: fileContentsId.value, + folderId: fileFolderId.value, + createdAt, + modificationTime, + path: filePath.value, + size: fileSize.value, + updatedAt: modificationTime, + }); + + await this.repository.upsert(file); + await this.eventBus.publish(file.pullDomainEvents()); + await this.notifier.created(file.name, file.type); + + return file; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'unknown error'; + + Logger.error(`[File Creator] ${path}`, message); + + const cause = + error instanceof DriveDesktopError ? error.cause : 'UNKNOWN'; + + await this.notifier.issues({ + error: 'UPLOAD_ERROR', + cause, + name: basename(path), + }); + + throw error; + } + } +} diff --git a/src/context/virtual-drive/files/application/create/SimpleFileCreator.ts b/src/context/virtual-drive/files/application/create/SimpleFileCreator.ts new file mode 100644 index 000000000..ca1c06d4e --- /dev/null +++ b/src/context/virtual-drive/files/application/create/SimpleFileCreator.ts @@ -0,0 +1,54 @@ +import { Service } from 'diod'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; +import { FilePath } from '../../domain/FilePath'; +import { FileSize } from '../../domain/FileSize'; +import { FileContentsId } from '../../domain/FileContentsId'; +import { FileFolderId } from '../../domain/FileFolderId'; +import { File } from '../../domain/File'; +import { Either, left, right } from '../../../../shared/domain/Either'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; + +@Service() +export class SimpleFileCreator { + constructor(private readonly remote: RemoteFileSystem) {} + + async run( + contentsId: string, + path: string, + size: number, + folderId: number + ): Promise> { + const fileSize = new FileSize(size); + const fileContentsId = new FileContentsId(contentsId); + const filePath = new FilePath(path); + + const fileFolderId = new FileFolderId(folderId); + + const either = await this.remote.persistv2({ + contentsId: fileContentsId, + path: filePath, + size: fileSize, + folderId: fileFolderId, + }); + + if (either.isLeft()) { + return left(either.getLeft()); + } + + const dto = either.getRight(); + + const file = File.createv2({ + id: dto.id, + uuid: dto.uuid, + contentsId: fileContentsId.value, + folderId: fileFolderId.value, + createdAt: dto.createdAt, + modificationTime: dto.modificationTime, + path: filePath.value, + size: fileSize.value, + updatedAt: dto.modificationTime, + }); + + return right(file); + } +} diff --git a/src/context/virtual-drive/files/application/delete/FileDeleter.ts b/src/context/virtual-drive/files/application/delete/FileDeleter.ts new file mode 100644 index 000000000..677582dce --- /dev/null +++ b/src/context/virtual-drive/files/application/delete/FileDeleter.ts @@ -0,0 +1,12 @@ +import { Service } from 'diod'; +import { File } from '../../domain/File'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; + +@Service() +export class FileDeleter { + constructor(private readonly fs: RemoteFileSystem) {} + + async run(file: File) { + await this.fs.delete(file); + } +} diff --git a/src/context/virtual-drive/files/application/override/FileOverrider.ts b/src/context/virtual-drive/files/application/override/FileOverrider.ts new file mode 100644 index 000000000..4c1f1d6c8 --- /dev/null +++ b/src/context/virtual-drive/files/application/override/FileOverrider.ts @@ -0,0 +1,40 @@ +import { Service } from 'diod'; +import { EventBus } from '../../../shared/domain/EventBus'; +import { File } from '../../domain/File'; +import { FileRepository } from '../../domain/FileRepository'; +import { FileSize } from '../../domain/FileSize'; +import { FileNotFoundError } from '../../domain/errors/FileNotFoundError'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; +import { FileContentsId } from '../../domain/FileContentsId'; + +@Service() +export class FileOverrider { + constructor( + private readonly rfs: RemoteFileSystem, + private readonly repository: FileRepository, + private readonly eventBus: EventBus + ) {} + + async run( + oldContentsId: File['contentsId'], + newContentsId: File['contentsId'], + newSize: File['size'] + ): Promise { + const file = await this.repository.searchByContentsId(oldContentsId); + + if (!file) { + throw new FileNotFoundError(oldContentsId); + } + + file.changeContents( + new FileContentsId(newContentsId), + new FileSize(newSize) + ); + + await this.rfs.override(file); + + await this.repository.update(file); + + this.eventBus.publish(file.pullDomainEvents()); + } +} diff --git a/src/context/virtual-drive/files/application/override/SimpleFileOverrider.ts b/src/context/virtual-drive/files/application/override/SimpleFileOverrider.ts new file mode 100644 index 000000000..e681431bb --- /dev/null +++ b/src/context/virtual-drive/files/application/override/SimpleFileOverrider.ts @@ -0,0 +1,16 @@ +import { Service } from 'diod'; +import { FileSize } from '../../domain/FileSize'; +import { File } from '../../domain/File'; +import { FileContentsId } from '../../domain/FileContentsId'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; + +@Service() +export class SimpleFileOverrider { + constructor(private readonly rfs: RemoteFileSystem) {} + + async run(file: File, contentsId: string, size: number): Promise { + file.changeContents(new FileContentsId(contentsId), new FileSize(size)); + + await this.rfs.override(file); + } +} diff --git a/src/context/virtual-drive/files/application/trash/FileTrasher.ts b/src/context/virtual-drive/files/application/trash/FileTrasher.ts new file mode 100644 index 000000000..b196af59a --- /dev/null +++ b/src/context/virtual-drive/files/application/trash/FileTrasher.ts @@ -0,0 +1,72 @@ +import { Service } from 'diod'; +import Logger from 'electron-log'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; +import { AllParentFoldersStatusIsExists } from '../../../folders/application/AllParentFoldersStatusIsExists'; +import { File } from '../../domain/File'; +import { FileRepository } from '../../domain/FileRepository'; +import { FileStatuses } from '../../domain/FileStatus'; +import { SyncFileMessenger } from '../../domain/SyncFileMessenger'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; + +@Service() +export class FileTrasher { + constructor( + private readonly remote: RemoteFileSystem, + private readonly repository: FileRepository, + private readonly allParentFoldersStatusIsExists: AllParentFoldersStatusIsExists, + private readonly notifier: SyncFileMessenger + ) {} + + async run(contentsId: File['contentsId']): Promise { + const file = this.repository + .matchingPartial({ + contentsId, + status: FileStatuses.EXISTS, + }) + .at(0); + + if (!file) { + return; + } + + if (file.status.is(FileStatuses.TRASHED)) { + Logger.warn(`File ${file.path} is already trashed. Will ignore...`); + return; + } + + const allParentsExists = await this.allParentFoldersStatusIsExists.run( + Number(file.folderId) + ); + + if (!allParentsExists) { + Logger.warn( + `Skipped file deletion for ${file.path}. A folder in a higher level is already marked as trashed` + ); + return; + } + await this.notifier.trashing(file.name, file.type, file.size); + + try { + file.trash(); + + await this.remote.trash(file.contentsId); + await this.repository.update(file); + await this.notifier.trashed(file.name, file.type, file.size); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + Logger.error('[File Deleter]', message); + + const cause = + error instanceof DriveDesktopError ? error.cause : 'UNKNOWN'; + + await this.notifier.issues({ + error: 'DELETE_ERROR', + cause, + name: file.nameWithExtension, + }); + + throw error; + } + } +} diff --git a/src/context/virtual-drive/files/domain/File.ts b/src/context/virtual-drive/files/domain/File.ts index 0e501a749..c8d2d68a1 100644 --- a/src/context/virtual-drive/files/domain/File.ts +++ b/src/context/virtual-drive/files/domain/File.ts @@ -9,12 +9,16 @@ import { FileNameShouldDifferFromOriginalError } from './errors/FileNameShouldDi import { FileActionCannotModifyExtension } from './errors/FileActionCannotModifyExtension'; import { FileDeletedDomainEvent } from './events/FileDeletedDomainEvent'; import { FileStatus, FileStatuses } from './FileStatus'; -import { ContentsId } from '../../contents/domain/ContentsId'; +import { FileOverriddenDomainEvent } from './events/FileOverriddenDomainEvent'; import { FileMovedDomainEvent } from './events/FileMovedDomainEvent'; import { FileRenamedDomainEvent } from './events/FileRenamedDomainEvent'; import { FilePlaceholderId, createFilePlaceholderId } from './PlaceholderId'; +import { FileContentsId } from './FileContentsId'; +import { FileFolderId } from './FileFolderId'; +import { FileUuid } from './FileUuid'; export type FileAttributes = { + id: number; uuid?: string; contentsId: string; folderId: number; @@ -28,9 +32,10 @@ export type FileAttributes = { export class File extends AggregateRoot { private constructor( - private _uuid: string, - private _contentsId: ContentsId, - private _folderId: number, + private _id: number, + private _uuid: FileUuid, + private _contentsId: FileContentsId, + private _folderId: FileFolderId, private _path: FilePath, private _size: FileSize, public createdAt: Date, @@ -40,8 +45,8 @@ export class File extends AggregateRoot { super(); } - public get uuid(): string { - return this._uuid; + public get uuid(): string | FileUuid { + return this._uuid.toString(); } public get contentsId() { @@ -86,9 +91,10 @@ export class File extends AggregateRoot { static from(attributes: FileAttributes): File { return new File( - attributes.uuid ?? '', - new ContentsId(attributes.contentsId), - attributes.folderId, + attributes.id ?? 0, + new FileUuid(attributes.uuid ?? ''), + new FileContentsId(attributes.contentsId), + new FileFolderId(attributes.folderId), new FilePath(attributes.path), new FileSize(attributes.size), new Date(attributes.createdAt), @@ -104,9 +110,10 @@ export class File extends AggregateRoot { path: FilePath ): File { const file = new File( - '', // we should generate a uuid here - new ContentsId(contentsId), - folder.id, + 0, + new FileUuid(''), + new FileContentsId(contentsId), + new FileFolderId(folder.id), path, size, new Date(), @@ -119,12 +126,56 @@ export class File extends AggregateRoot { aggregateId: contentsId, size: file.size, type: path.extension(), + path: path.value, }) ); return file; } + static createv2(attributes: Omit): File { + const file = new File( + attributes.id, + new FileUuid(attributes.uuid || ''), + new FileContentsId(attributes.contentsId), + new FileFolderId(attributes.folderId), + new FilePath(attributes.path), + new FileSize(attributes.size), + new Date(attributes.createdAt), + new Date(attributes.updatedAt), + FileStatus.Exists + ); + + file.record( + new FileCreatedDomainEvent({ + aggregateId: file.uuid.toString(), + size: file.size, + type: file.type, + path: file.path, + }) + ); + + return file; + } + + changeContents(contentsId: FileContentsId, contentsSize: FileSize) { + const previousContentsId = this.contentsId; + const previousSize = this.size; + + this._contentsId = contentsId; + this._size = contentsSize; + + this.record( + new FileOverriddenDomainEvent({ + aggregateId: this.uuid.toString(), + previousContentsId, + previousSize, + currentContentsId: contentsId.value, + currentSize: contentsSize.value, + }) + ); + } + trash() { this._status = this._status.changeTo(FileStatuses.TRASHED); this.updatedAt = new Date(); @@ -138,11 +189,11 @@ export class File extends AggregateRoot { } moveTo(folder: Folder, trackerId: string): void { - if (this.folderId === folder.id) { + if (Number(this.folderId) === Number(folder.id)) { throw new FileCannotBeMovedToTheOriginalFolderError(this.path); } - this._folderId = folder.id; + this._folderId = new FileFolderId(folder.id); this._path = this._path.changeFolder(folder.path); this.record( @@ -176,7 +227,7 @@ export class File extends AggregateRoot { } hasParent(id: number): boolean { - return this.folderId === id; + return this.folderId === new FileFolderId(id); } isFolder(): this is Folder { @@ -192,7 +243,7 @@ export class File extends AggregateRoot { } replaceContestsAndSize(contentsId: string, size: number) { - this._contentsId = new ContentsId(contentsId); + this._contentsId = new FileContentsId(contentsId); this._size = new FileSize(size); this.updatedAt = new Date(); @@ -201,9 +252,10 @@ export class File extends AggregateRoot { attributes(): FileAttributes { return { - uuid: this._uuid, + id: this._id, + uuid: this._uuid.toString(), contentsId: this.contentsId, - folderId: this.folderId, + folderId: Number(this.folderId), createdAt: this.createdAt.toISOString(), path: this.path, size: this.size, diff --git a/src/context/virtual-drive/files/domain/FileContentsId.ts b/src/context/virtual-drive/files/domain/FileContentsId.ts new file mode 100644 index 000000000..5f4ce850f --- /dev/null +++ b/src/context/virtual-drive/files/domain/FileContentsId.ts @@ -0,0 +1,3 @@ +import { BucketEntryId } from '../../shared/domain/BucketEntryId'; + +export class FileContentsId extends BucketEntryId {} diff --git a/src/context/virtual-drive/files/domain/FileFolderId.ts b/src/context/virtual-drive/files/domain/FileFolderId.ts new file mode 100644 index 000000000..2ad8c242d --- /dev/null +++ b/src/context/virtual-drive/files/domain/FileFolderId.ts @@ -0,0 +1,3 @@ +import { ValueObject } from '../../../shared/domain/value-objects/ValueObject'; + +export class FileFolderId extends ValueObject {} diff --git a/src/context/virtual-drive/files/domain/FileRepository.ts b/src/context/virtual-drive/files/domain/FileRepository.ts index 4dfd48c0a..67bd2f8db 100644 --- a/src/context/virtual-drive/files/domain/FileRepository.ts +++ b/src/context/virtual-drive/files/domain/FileRepository.ts @@ -1,19 +1,17 @@ import { File, FileAttributes } from './File'; -export interface FileRepository { - all(): Promise>; - - searchByPartial(partial: Partial): File | undefined; - - allSearchByPartial(partial: Partial): Promise>; - - delete(id: File['contentsId']): Promise; - - add(file: File): Promise; - - update(file: File): Promise; - - updateContentsAndSize( +export abstract class FileRepository { + abstract all(): Promise>; + abstract searchByPartial(partial: Partial): File | undefined; + abstract allSearchByPartial( + partial: Partial + ): Promise>; + abstract delete(id: File['contentsId']): Promise; + abstract add(file: File): Promise; + abstract update(file: File): Promise; + abstract matchingPartial(partial: Partial): Array; + abstract upsert(file: File): Promise; + abstract updateContentsAndSize( file: File, newContentsId: File['contentsId'], newSize: File['size'] diff --git a/src/context/virtual-drive/files/domain/FileUuid.ts b/src/context/virtual-drive/files/domain/FileUuid.ts new file mode 100644 index 000000000..eb34e13a7 --- /dev/null +++ b/src/context/virtual-drive/files/domain/FileUuid.ts @@ -0,0 +1,3 @@ +import { Uuid } from '../../../shared/domain/value-objects/Uuid'; + +export class FileUuid extends Uuid {} diff --git a/src/context/virtual-drive/files/domain/SyncFileMessenger.ts b/src/context/virtual-drive/files/domain/SyncFileMessenger.ts new file mode 100644 index 000000000..3703500cb --- /dev/null +++ b/src/context/virtual-drive/files/domain/SyncFileMessenger.ts @@ -0,0 +1,18 @@ +import { VirtualDriveFileIssue } from '../../../../apps/shared/issues/VirtualDriveIssue'; + +export abstract class SyncFileMessenger { + abstract created(name: string, extension: string): Promise; + abstract trashing( + name: string, + extension: string, + size: number + ): Promise; + abstract trashed( + name: string, + extension: string, + size: number + ): Promise; + abstract renaming(current: string, desired: string): Promise; + abstract renamed(current: string, desired: string): Promise; + abstract issues(issue: VirtualDriveFileIssue): Promise; +} diff --git a/src/context/virtual-drive/files/domain/events/FileCreatedDomainEvent.ts b/src/context/virtual-drive/files/domain/events/FileCreatedDomainEvent.ts index c52845306..2f01703e5 100644 --- a/src/context/virtual-drive/files/domain/events/FileCreatedDomainEvent.ts +++ b/src/context/virtual-drive/files/domain/events/FileCreatedDomainEvent.ts @@ -3,6 +3,7 @@ import { DomainEvent } from '../../../../shared/domain/DomainEvent'; export type CreatedWebdavFileDomainEventAttributes = { readonly size: number; readonly type: string; + readonly path: string; }; export class FileCreatedDomainEvent extends DomainEvent { @@ -10,17 +11,20 @@ export class FileCreatedDomainEvent extends DomainEvent { readonly size: number; readonly type: string; + readonly path: string; constructor({ aggregateId, eventId, size, type, + path, }: { aggregateId: string; eventId?: string; size: number; type: string; + path: string; }) { super({ eventName: FileCreatedDomainEvent.EVENT_NAME, @@ -29,11 +33,12 @@ export class FileCreatedDomainEvent extends DomainEvent { }); this.size = size; this.type = type; + this.path = path; } toPrimitives(): CreatedWebdavFileDomainEventAttributes { - const { size, type } = this; + const { size, type, path } = this; - return { size, type }; + return { size, type, path }; } } diff --git a/src/context/virtual-drive/files/domain/events/FileOverriddenDomainEvent.ts b/src/context/virtual-drive/files/domain/events/FileOverriddenDomainEvent.ts new file mode 100644 index 000000000..410067e81 --- /dev/null +++ b/src/context/virtual-drive/files/domain/events/FileOverriddenDomainEvent.ts @@ -0,0 +1,46 @@ +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class FileOverriddenDomainEvent extends DomainEvent { + static readonly EVENT_NAME = 'file.overridden'; + + readonly previousContentsId: string; + readonly previousSize: number; + + readonly currentContentsId: string; + readonly currentSize: number; + + constructor({ + aggregateId, + previousContentsId, + previousSize, + currentContentsId, + currentSize, + }: { + aggregateId: string; + previousContentsId: string; + previousSize: number; + currentContentsId: string; + currentSize: number; + }) { + super({ + eventName: FileOverriddenDomainEvent.EVENT_NAME, + aggregateId, + }); + + this.previousContentsId = previousContentsId; + this.previousSize = previousSize; + + this.currentContentsId = currentContentsId; + this.currentSize = currentSize; + } + + toPrimitives() { + return { + aggregateId: this.aggregateId, + previousContentsId: this.previousContentsId, + previousSize: this.previousSize, + currentContentsId: this.currentContentsId, + currentSize: this.currentSize, + }; + } +} diff --git a/src/context/virtual-drive/files/domain/file-systems/LocalFileSystem.ts b/src/context/virtual-drive/files/domain/file-systems/LocalFileSystem.ts index 5090349a2..d28f9f852 100644 --- a/src/context/virtual-drive/files/domain/file-systems/LocalFileSystem.ts +++ b/src/context/virtual-drive/files/domain/file-systems/LocalFileSystem.ts @@ -1,26 +1,20 @@ import { File } from '../File'; import { PlaceholderState } from '../PlaceholderState'; -export interface LocalFileSystem { - createPlaceHolder(file: File): Promise; - - fileExists(filePath: string): Promise; - - getLocalFileId(file: File): Promise<`${string}-${string}`>; - - updateSyncStatus(file: File): Promise; - - getFileIdentity(path: File['path']): Promise; - - deleteFileSyncRoot(path: File['path']): Promise; - - convertToPlaceholder(file: File): Promise; - - getPlaceholderState(file: File): Promise; - - getPlaceholderStateByRelativePath( +export abstract class LocalFileSystem { + abstract createPlaceHolder(file: File): Promise; + abstract fileExists(filePath: string): Promise; + abstract getLocalFileId(file: File): Promise<`${string}-${string}`>; + abstract updateSyncStatus(file: File): Promise; + abstract getFileIdentity(path: File['path']): Promise; + abstract deleteFileSyncRoot(path: File['path']): Promise; + abstract convertToPlaceholder(file: File): Promise; + abstract getPlaceholderState(file: File): Promise; + abstract getPlaceholderStateByRelativePath( relativePath: string ): Promise; - - updateFileIdentity(path: File['path'], newIdentity: string): Promise; + abstract updateFileIdentity( + path: File['path'], + newIdentity: string + ): Promise; } diff --git a/src/context/virtual-drive/files/domain/file-systems/RemoteFileSystem.ts b/src/context/virtual-drive/files/domain/file-systems/RemoteFileSystem.ts index 14f00dbaf..b944a7cae 100644 --- a/src/context/virtual-drive/files/domain/file-systems/RemoteFileSystem.ts +++ b/src/context/virtual-drive/files/domain/file-systems/RemoteFileSystem.ts @@ -1,21 +1,45 @@ +import { Either } from '../../../../shared/domain/Either'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { File, FileAttributes } from '../File'; +import { FileContentsId } from '../FileContentsId'; +import { FileFolderId } from '../FileFolderId'; +import { FilePath } from '../FilePath'; +import { FileSize } from '../FileSize'; import { FileStatuses } from '../FileStatus'; import { OfflineFile } from '../OfflineFile'; -export interface RemoteFileSystem { - persist(offline: OfflineFile): Promise; +export type FileDataToPersist = { + contentsId: FileContentsId; + path: FilePath; + size: FileSize; + folderId: FileFolderId; +}; - trash(contentsId: File['contentsId']): Promise; +export type PersistedFileData = { + modificationTime: string; + id: number; + uuid: string; + createdAt: string; +}; - move(file: File): Promise; +export abstract class RemoteFileSystem { + abstract persist(offline: OfflineFile): Promise; - rename(file: File): Promise; + abstract persistv2( + offline: FileDataToPersist + ): Promise>; - checkStatusFile(uuid: File['uuid']): Promise; + abstract trash(contentsId: File['contentsId']): Promise; - replace( - file: File, - newContentsId: File['contentsId'], - newSize: File['size'] - ): Promise; + abstract move(file: File): Promise; + + abstract rename(file: File): Promise; + + abstract checkStatusFile( + contentsId: File['contentsId'] + ): Promise; + + abstract override(file: File): Promise; + + abstract delete(file: File): Promise; } diff --git a/src/context/virtual-drive/files/infrastructure/InMemoryFileRepository.ts b/src/context/virtual-drive/files/infrastructure/InMemoryFileRepository.ts index 966fcf7c7..80c9ba0e8 100644 --- a/src/context/virtual-drive/files/infrastructure/InMemoryFileRepository.ts +++ b/src/context/virtual-drive/files/infrastructure/InMemoryFileRepository.ts @@ -1,15 +1,22 @@ +import { Service } from 'diod'; import { File, FileAttributes } from '../domain/File'; import { FileRepository } from '../domain/FileRepository'; +@Service() export class InMemoryFileRepository implements FileRepository { private files: Map; + private filesByUuid: Map; + private filesByContentsId: Map; + private get values(): Array { return Array.from(this.files.values()); } constructor() { this.files = new Map(); + this.filesByUuid = new Map(); + this.filesByContentsId = new Map(); } public all(): Promise> { @@ -63,7 +70,45 @@ export class InMemoryFileRepository implements FileRepository { return undefined; } + matchingPartial(partial: Partial): Array { + const keys = Object.keys(partial) as Array>; + + const filesAttributes = this.values.filter((attributes) => { + return keys.every((key: keyof FileAttributes) => { + if (key === 'contentsId') { + return ( + attributes[key].normalize() == (partial[key] as string).normalize() + ); + } + + return attributes[key] == partial[key]; + }); + }); + if (!filesAttributes) { + return []; + } + + return filesAttributes.map((attributes) => File.from(attributes)); + } + + async upsert(file: File): Promise { + const attributes = file.attributes(); + + const isAlreadyStored = + this.filesByUuid.has(file.uuid) || + this.filesByContentsId.has(file.contentsId); + + if (isAlreadyStored) { + this.filesByUuid.delete(file.uuid); + this.filesByContentsId.delete(file.contentsId); + } + + this.filesByUuid.set(file.uuid, attributes); + this.filesByContentsId.set(file.contentsId, attributes); + + return isAlreadyStored; + } async delete(id: File['contentsId']): Promise { const deleted = this.files.delete(id); @@ -99,6 +144,4 @@ export class InMemoryFileRepository implements FileRepository { this.files.set(updatedFile.contentsId, updatedFile.attributes()); return updatedFile; } - - } diff --git a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts index 8aeb450a1..fa1318642 100644 --- a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts +++ b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts @@ -3,12 +3,22 @@ import { EncryptionVersion } from '@internxt/sdk/dist/drive/storage/types'; import { Crypt } from '../../shared/domain/Crypt'; import { File, FileAttributes } from '../domain/File'; import { FileStatuses } from '../domain/FileStatus'; -import { RemoteFileSystem } from '../domain/file-systems/RemoteFileSystem'; +import { + FileDataToPersist, + PersistedFileData, + RemoteFileSystem, +} from '../domain/file-systems/RemoteFileSystem'; import { OfflineFile } from '../domain/OfflineFile'; import * as uuid from 'uuid'; import { AuthorizedClients } from '../../../../apps/shared/HttpClient/Clients'; import Logger from 'electron-log'; +import { Either, left, right } from '../../../shared/domain/Either'; +import { DriveDesktopError } from '../../../shared/domain/errors/DriveDesktopError'; +import { isAxiosError } from 'axios'; +import { CreateFileDTO } from './dtos/CreateFileDTO'; +import { Service } from 'diod'; +@Service() export class SDKRemoteFileSystem implements RemoteFileSystem { constructor( private readonly sdk: Storage, @@ -47,6 +57,98 @@ export class SDKRemoteFileSystem implements RemoteFileSystem { }; } + async persistv2( + dataToPersists: FileDataToPersist + ): Promise> { + const plainName = dataToPersists.path.name(); + + const encryptedName = this.crypt.encryptName( + plainName, + dataToPersists.folderId.value.toString() + ); + + if (!encryptedName) { + return left( + new DriveDesktopError( + 'COULD_NOT_ENCRYPT_NAME', + `Could not encrypt the file name: ${plainName} with salt: ${dataToPersists.folderId.value.toString()}` + ) + ); + } + + const body: CreateFileDTO = { + file: { + fileId: dataToPersists.contentsId.value, + file_id: dataToPersists.contentsId.value, + type: dataToPersists.path.extension(), + size: dataToPersists.size.value, + name: encryptedName, + plain_name: plainName, + bucket: this.bucket, + folder_id: dataToPersists.folderId.value, + encrypt_version: EncryptionVersion.Aes03, + }, + }; + + try { + const { data } = await this.clients.drive.post( + `${process.env.API_URL}/storage/file`, + body + ); + + const result: PersistedFileData = { + modificationTime: data.updatedAt, + id: data.id, + uuid: data.uuid, + createdAt: data.createdAt, + }; + + return right(result); + } catch (err: unknown) { + if (!isAxiosError(err) || !err.response) { + return left( + new DriveDesktopError('UNKNOWN', `Creating file ${plainName}: ${err}`) + ); + } + + const { status } = err.response; + + if (status === 400) { + return left( + new DriveDesktopError( + 'BAD_REQUEST', + `Some data was not valid for ${plainName}: ${body.file}` + ) + ); + } + + if (status === 409) { + return left( + new DriveDesktopError( + 'FILE_ALREADY_EXISTS', + `File with name ${plainName} on ${dataToPersists.folderId.value} already exists` + ) + ); + } + + if (status >= 500) { + return left( + new DriveDesktopError( + 'BAD_RESPONSE', + `The server could not handle the creation of ${plainName}: ${body.file}` + ) + ); + } + + return left( + new DriveDesktopError( + 'UNKNOWN', + `Response with status ${status} not expected` + ) + ); + } + } + async checkStatusFile(uuid: File['uuid']): Promise { const response = await this.clients.newDrive.get( `${process.env.NEW_DRIVE_URL}/drive/files/${uuid}/meta` @@ -87,6 +189,10 @@ export class SDKRemoteFileSystem implements RemoteFileSystem { } } + async delete(file: File): Promise { + await this.trash(file.contentsId); + } + async rename(file: File): Promise { await this.sdk.updateFile({ fileId: file.contentsId, @@ -101,7 +207,7 @@ export class SDKRemoteFileSystem implements RemoteFileSystem { async move(file: File): Promise { await this.sdk.moveFile({ fileId: file.contentsId, - destination: file.folderId, + destination: Number(file.folderId), destinationPath: uuid.v4(), bucketId: this.bucket, }); @@ -120,4 +226,16 @@ export class SDKRemoteFileSystem implements RemoteFileSystem { } ); } + + async override(file: File): Promise { + await this.clients.newDrive.put( + `${process.env.NEW_DRIVE_URL}/drive/files/${file.uuid}`, + { + fileId: file.contentsId, + size: file.size, + } + ); + + Logger.info(`File ${file.path} overridden`); + } } diff --git a/src/context/virtual-drive/files/infrastructure/dtos/CreateFileDTO.ts b/src/context/virtual-drive/files/infrastructure/dtos/CreateFileDTO.ts new file mode 100644 index 000000000..c95ef23be --- /dev/null +++ b/src/context/virtual-drive/files/infrastructure/dtos/CreateFileDTO.ts @@ -0,0 +1,13 @@ +export interface CreateFileDTO { + file: { + bucket: string; + encrypt_version: '03-aes'; + fileId: string; + file_id: string; + folder_id: number; + name: string; + plain_name: string; + size: number; + type: string; + }; +} diff --git a/src/context/virtual-drive/folders/application/AllParentFoldersStatusIsExists.ts b/src/context/virtual-drive/folders/application/AllParentFoldersStatusIsExists.ts index fe5e1229c..74a88f4a8 100644 --- a/src/context/virtual-drive/folders/application/AllParentFoldersStatusIsExists.ts +++ b/src/context/virtual-drive/folders/application/AllParentFoldersStatusIsExists.ts @@ -1,7 +1,9 @@ +import { Service } from 'diod'; import { Folder } from '../domain/Folder'; import { FolderRepository } from '../domain/FolderRepository'; import { FolderStatuses } from '../domain/FolderStatus'; +@Service() export class AllParentFoldersStatusIsExists { constructor(private readonly repository: FolderRepository) {} diff --git a/src/context/virtual-drive/folders/application/FolderDeleter.ts b/src/context/virtual-drive/folders/application/FolderDeleter.ts index b85155e4b..1ac57f6ef 100644 --- a/src/context/virtual-drive/folders/application/FolderDeleter.ts +++ b/src/context/virtual-drive/folders/application/FolderDeleter.ts @@ -4,13 +4,15 @@ import { ActionNotPermittedError } from '../domain/errors/ActionNotPermittedErro import { FolderNotFoundError } from '../domain/errors/FolderNotFoundError'; import { AllParentFoldersStatusIsExists } from './AllParentFoldersStatusIsExists'; import { FolderRepository } from '../domain/FolderRepository'; -import { RemoteFileSystem } from '../domain/file-systems/RemoteFileSystem'; import { LocalFileSystem } from '../domain/file-systems/LocalFileSystem'; +import { HttpRemoteFileSystem } from '../infrastructure/HttpRemoteFileSystem'; +import { Service } from 'diod'; +@Service() export class FolderDeleter { constructor( private readonly repository: FolderRepository, - private readonly remote: RemoteFileSystem, + private readonly remote: HttpRemoteFileSystem, private readonly local: LocalFileSystem, private readonly allParentFoldersStatusIsExists: AllParentFoldersStatusIsExists ) {} @@ -45,7 +47,7 @@ export class FolderDeleter { await this.repository.update(folder); } catch (error: unknown) { Logger.error(`Error deleting the folder ${folder.name}: `, error); - Sentry.captureException(error); + // Sentry.captureException(error); this.local.createPlaceHolder(folder); } } diff --git a/src/context/virtual-drive/folders/application/ParentFolderFinder.ts b/src/context/virtual-drive/folders/application/ParentFolderFinder.ts new file mode 100644 index 000000000..2ca64f2ea --- /dev/null +++ b/src/context/virtual-drive/folders/application/ParentFolderFinder.ts @@ -0,0 +1,28 @@ +import { Service } from 'diod'; +import { Path } from '../../../shared/domain/value-objects/Path'; +import { FolderNotFoundError } from '../domain/errors/FolderNotFoundError'; +import { Folder } from '../domain/Folder'; +import { FolderRepository } from '../domain/FolderRepository'; +import { FolderStatuses } from '../domain/FolderStatus'; + +@Service() +export class ParentFolderFinder { + constructor(private readonly repository: FolderRepository) {} + + async run(path: Path): Promise { + const result = this.repository.matchingPartial({ + path: path.dirname(), + status: FolderStatuses.EXISTS, + }); + + if (!result || result.length === 0) { + throw new FolderNotFoundError(path.dirname()); + } + + if (result.length > 1) { + throw new Error('A file can only have a parent'); + } + + return result[0]; + } +} diff --git a/src/context/virtual-drive/folders/application/create/FolderCreator.ts b/src/context/virtual-drive/folders/application/create/FolderCreator.ts new file mode 100644 index 000000000..26dea6a1d --- /dev/null +++ b/src/context/virtual-drive/folders/application/create/FolderCreator.ts @@ -0,0 +1,71 @@ +import { Service } from 'diod'; +import Logger from 'electron-log'; +import { EventBus } from '../../../shared/domain/EventBus'; +import { Folder } from '../../domain/Folder'; +import { FolderCreatedAt } from '../../domain/FolderCreatedAt'; +import { FolderId } from '../../domain/FolderId'; +import { FolderPath } from '../../domain/FolderPath'; +import { FolderRepository } from '../../domain/FolderRepository'; +import { FolderStatuses } from '../../domain/FolderStatus'; +import { FolderUpdatedAt } from '../../domain/FolderUpdatedAt'; +import { FolderUuid } from '../../domain/FolderUuid'; +import { FolderInPathAlreadyExistsError } from '../../domain/errors/FolderInPathAlreadyExistsError'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; +import { ParentFolderFinder } from '../ParentFolderFinder'; + +@Service() +export class FolderCreator { + constructor( + private readonly repository: FolderRepository, + private readonly parentFolderFinder: ParentFolderFinder, + private readonly remote: RemoteFileSystem, + private readonly eventBus: EventBus + ) {} + + private async ensureItDoesNotExists(path: FolderPath): Promise { + const result = this.repository.matchingPartial({ + path: path.value, + status: FolderStatuses.EXISTS, + }); + + if (result.length > 0) { + throw new FolderInPathAlreadyExistsError(path); + } + } + + private async findParentId(path: FolderPath): Promise { + const parent = await this.parentFolderFinder.run(path); + return new FolderId(parent.id); + } + + async run(path: string): Promise { + const folderPath = new FolderPath(path); + + await this.ensureItDoesNotExists(folderPath); + + const parentId = await this.findParentId(folderPath); + + const response = await this.remote.persistv2(folderPath, parentId); + + if (response.isLeft()) { + Logger.error(response.getLeft()); + return; + } + + const dto = response.getRight(); + + const folder = Folder.create( + new FolderId(dto.id), + new FolderUuid(dto.uuid), + folderPath, + parentId, + FolderCreatedAt.fromString(dto.createdAt), + FolderUpdatedAt.fromString(dto.updatedAt) + ); + + await this.repository.add(folder); + + const events = folder.pullDomainEvents(); + this.eventBus.publish(events); + } +} diff --git a/src/context/virtual-drive/folders/application/create/FolderCreatorFromOfflineFolder.ts b/src/context/virtual-drive/folders/application/create/FolderCreatorFromOfflineFolder.ts new file mode 100644 index 000000000..813569090 --- /dev/null +++ b/src/context/virtual-drive/folders/application/create/FolderCreatorFromOfflineFolder.ts @@ -0,0 +1,56 @@ +import { Service } from 'diod'; +import { EventBus } from '../../../shared/domain/EventBus'; +import { Folder } from '../../domain/Folder'; +import { FolderCreatedAt } from '../../domain/FolderCreatedAt'; +import { FolderId } from '../../domain/FolderId'; +import { FolderPath } from '../../domain/FolderPath'; +import { FolderRepository } from '../../domain/FolderRepository'; +import { FolderUpdatedAt } from '../../domain/FolderUpdatedAt'; +import { FolderUuid } from '../../domain/FolderUuid'; +import { OfflineFolder } from '../../domain/OfflineFolder'; +import { SyncFolderMessenger } from '../../domain/SyncFolderMessenger'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; + +@Service() +export class FolderCreatorFromOfflineFolder { + constructor( + private readonly repository: FolderRepository, + private readonly remote: RemoteFileSystem, + private readonly eventBus: EventBus, + private readonly syncFolderMessenger: SyncFolderMessenger + ) {} + + async run(offlineFolder: OfflineFolder): Promise { + this.syncFolderMessenger.creating(offlineFolder.name); + + const either = await this.remote.persist( + new FolderPath(offlineFolder.path), + new FolderId(offlineFolder.parentId), + new FolderUuid(offlineFolder.uuid) + ); + + if (either.isLeft()) { + return Promise.reject(either.getLeft()); + } + + const dto = either.getRight(); + + const folder = Folder.create( + new FolderId(dto.id), + new FolderUuid(dto.uuid), + new FolderPath(offlineFolder.path), + new FolderId(dto.parentId), + FolderCreatedAt.fromString(dto.createdAt), + FolderUpdatedAt.fromString(dto.updatedAt) + ); + + await this.repository.add(folder); + + const events = folder.pullDomainEvents(); + this.eventBus.publish(events); + + this.syncFolderMessenger.created(offlineFolder.name); + + return folder; + } +} diff --git a/src/context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder.ts b/src/context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder.ts new file mode 100644 index 000000000..c1a1b63a5 --- /dev/null +++ b/src/context/virtual-drive/folders/application/create/FolderCreatorFromServerFolder.ts @@ -0,0 +1,19 @@ +import { ServerFolder } from '../../../../shared/domain/ServerFolder'; +import { Folder } from '../../domain/Folder'; + +export function createFolderFromServerFolder( + server: ServerFolder, + relativePath: string +): Folder { + const path = relativePath.replaceAll('//', '/'); + + return Folder.from({ + id: server.id, + uuid: server.uuid, + parentId: server.parentId as number, + updatedAt: server.updatedAt, + createdAt: server.createdAt, + path: path, + status: server.status, + }); +} diff --git a/src/context/virtual-drive/folders/application/create/SimpleFolderCreator.ts b/src/context/virtual-drive/folders/application/create/SimpleFolderCreator.ts new file mode 100644 index 000000000..a1729387b --- /dev/null +++ b/src/context/virtual-drive/folders/application/create/SimpleFolderCreator.ts @@ -0,0 +1,49 @@ +import { Service } from 'diod'; +import { RemoteFileSystem } from '../../domain/file-systems/RemoteFileSystem'; +import { FolderPath } from '../../domain/FolderPath'; +import { FolderId } from '../../domain/FolderId'; +import { Folder } from '../../domain/Folder'; +import { FolderUuid } from '../../domain/FolderUuid'; +import { FolderCreatedAt } from '../../domain/FolderCreatedAt'; +import { FolderUpdatedAt } from '../../domain/FolderUpdatedAt'; +import Logger from 'electron-log'; + +@Service() +export class SimpleFolderCreator { + constructor(private readonly rfs: RemoteFileSystem) {} + + async run(path: string, parentId: number): Promise { + const folderPath = new FolderPath(path); + const folderParentId = new FolderId(parentId); + + const response = await this.rfs.persistv2(folderPath, folderParentId); + + const folder = await response.fold>( + async (error): Promise => { + Logger.warn('The folder was not been able to create', error); + if (error !== 'ALREADY_EXISTS') { + return; + } + return this.rfs.searchWith(folderParentId, folderPath); + }, + (dto): Promise => { + return Promise.resolve( + Folder.create( + new FolderId(dto.id), + new FolderUuid(dto.uuid), + folderPath, + folderParentId, + FolderCreatedAt.fromString(dto.createdAt), + FolderUpdatedAt.fromString(dto.updatedAt) + ) + ); + } + ); + + if (!folder) { + throw new Error('Could not create folder and was not found either'); + } + + return folder; + } +} diff --git a/src/context/virtual-drive/folders/domain/Folder.ts b/src/context/virtual-drive/folders/domain/Folder.ts index bc916a2cd..3c1aa1f6d 100644 --- a/src/context/virtual-drive/folders/domain/Folder.ts +++ b/src/context/virtual-drive/folders/domain/Folder.ts @@ -5,6 +5,11 @@ import { FolderUuid } from './FolderUuid'; import { FolderCreatedDomainEvent } from './events/FolderCreatedDomainEvent'; import { FolderRenamedDomainEvent } from './events/FolderRenamedDomainEvent'; import { createFolderPlaceholderId } from './FolderPlaceholderId'; +import { FolderId } from './FolderId'; +import { FolderCreatedAt } from './FolderCreatedAt'; +import { FolderUpdatedAt } from './FolderUpdatedAt'; +import { FolderAlreadyTrashed } from './errors/FolderAlreadyTrashed'; +import { FolderMovedDomainEvent } from './events/FolderMovedDomainEvent'; export type FolderAttributes = { id: number; @@ -18,17 +23,21 @@ export type FolderAttributes = { export class Folder extends AggregateRoot { private constructor( - public id: number, + private _id: FolderId, private _uuid: FolderUuid, private _path: FolderPath, - private _parentId: null | number, - public createdAt: Date, - public updatedAt: Date, + private _parentId: null | FolderId, + public _createdAt: FolderCreatedAt, + public _updatedAt: FolderUpdatedAt, private _status: FolderStatus ) { super(); } + public get id(): number { + return this._id.value; + } + public get uuid(): string { return this._uuid.value; } @@ -41,16 +50,16 @@ export class Folder extends AggregateRoot { return this._path.name(); } - public get dirname() { - return this._path.dirname(); + public get dirname(): FolderPath { + return new FolderPath('/' + this._path.dirname()); } - public get parentId() { - return this._parentId; + public get parentId(): number | undefined { + return this._parentId?.value; } - public get status() { - return this._status; + public get status(): string { + return this._status.value; } public get size() { @@ -62,25 +71,33 @@ export class Folder extends AggregateRoot { return createFolderPlaceholderId(this.uuid); } + public get createdAt(): Date { + return this._createdAt.value; + } + + public get updatedAt(): Date { + return this._updatedAt.value; + } + public update(attributes: Partial) { if (attributes.path) { this._path = new FolderPath(attributes.path); } if (attributes.createdAt) { - this.createdAt = new Date(attributes.createdAt); + this._createdAt = FolderCreatedAt.fromString(attributes.createdAt); } if (attributes.updatedAt) { - this.updatedAt = new Date(attributes.updatedAt); + this._updatedAt = FolderUpdatedAt.fromString(attributes.updatedAt); } if (attributes.id) { - this.id = attributes.id; + this._id = new FolderId(attributes.id); } if (attributes.parentId) { - this._parentId = attributes.parentId; + this._parentId = new FolderId(attributes.parentId); } if (attributes.status) { @@ -92,29 +109,36 @@ export class Folder extends AggregateRoot { static from(attributes: FolderAttributes): Folder { return new Folder( - attributes.id, + new FolderId(attributes.id), new FolderUuid(attributes.uuid), new FolderPath(attributes.path), - attributes.parentId, - new Date(attributes.updatedAt), - new Date(attributes.createdAt), + attributes.parentId ? new FolderId(attributes.parentId) : null, + FolderUpdatedAt.fromString(attributes.updatedAt), + FolderCreatedAt.fromString(attributes.createdAt), FolderStatus.fromValue(attributes.status) ); } - static create(attributes: FolderAttributes): Folder { + static create( + id: FolderId, + uuid: FolderUuid, + path: FolderPath, + parentId: FolderId, + createdAt: FolderCreatedAt, + updatedAt: FolderUpdatedAt + ): Folder { const folder = new Folder( - attributes.id, - new FolderUuid(attributes.uuid), - new FolderPath(attributes.path), - attributes.parentId, - new Date(attributes.updatedAt), - new Date(attributes.createdAt), + id, + uuid, + path, + parentId, + createdAt, + updatedAt, FolderStatus.Exists ); const folderCreatedEvent = new FolderCreatedDomainEvent({ - aggregateId: attributes.uuid, + aggregateId: folder.uuid, }); folder.record(folderCreatedEvent); @@ -126,14 +150,22 @@ export class Folder extends AggregateRoot { throw new Error('Root folder cannot be moved'); } - if (this._parentId === folder.id) { + if (this.isIn(folder)) { throw new Error('Cannot move a folder to its current folder'); } - this._path = this._path.changeFolder(folder.path); - this._parentId = folder.id; + const before = this.path; - //TODO: record moved event + this._path = this._path.changeFolder(folder.path); + this._parentId = new FolderId(folder.id); + + this.record( + new FolderMovedDomainEvent({ + aggregateId: this.uuid, + from: before, + to: this.path, + }) + ); } rename(newPath: FolderPath) { @@ -142,7 +174,7 @@ export class Folder extends AggregateRoot { throw new Error('Cannot rename a folder to the same name'); } this._path = this._path.updateName(newPath.name()); - this.updatedAt = new Date(); + this._updatedAt = FolderUpdatedAt.now(); const event = new FolderRenamedDomainEvent({ aggregateId: this.uuid, @@ -154,14 +186,18 @@ export class Folder extends AggregateRoot { } trash() { + if (!this._status.is(FolderStatuses.EXISTS)) { + throw new FolderAlreadyTrashed(this.name); + } + this._status = this._status.changeTo(FolderStatuses.TRASHED); - this.updatedAt = new Date(); + this._updatedAt = FolderUpdatedAt.now(); // TODO: record trashed event } isIn(folder: Folder): boolean { - return this._parentId === folder.id; + return this.parentId === folder.id; } isFolder(): this is Folder { @@ -184,11 +220,11 @@ export class Folder extends AggregateRoot { return { id: this.id, uuid: this.uuid, - parentId: this._parentId || 0, - path: this._path.value, + parentId: this.parentId || 0, + path: this.path, updatedAt: this.updatedAt.toISOString(), createdAt: this.createdAt.toISOString(), - status: this.status.value, + status: this.status, }; } } diff --git a/src/context/virtual-drive/folders/domain/FolderCreatedAt.ts b/src/context/virtual-drive/folders/domain/FolderCreatedAt.ts new file mode 100644 index 000000000..d82221b66 --- /dev/null +++ b/src/context/virtual-drive/folders/domain/FolderCreatedAt.ts @@ -0,0 +1,3 @@ +import { DateValueObject } from '../../../shared/domain/value-objects/DateValueObject'; + +export class FolderCreatedAt extends DateValueObject {} diff --git a/src/context/virtual-drive/folders/domain/FolderId.ts b/src/context/virtual-drive/folders/domain/FolderId.ts new file mode 100644 index 000000000..e0d7d9c31 --- /dev/null +++ b/src/context/virtual-drive/folders/domain/FolderId.ts @@ -0,0 +1,3 @@ +import { NumericId } from '../../../shared/domain/value-objects/NumericId'; + +export class FolderId extends NumericId {} diff --git a/src/context/virtual-drive/folders/domain/FolderRepository.ts b/src/context/virtual-drive/folders/domain/FolderRepository.ts index a13870a3d..4aa92b9a0 100644 --- a/src/context/virtual-drive/folders/domain/FolderRepository.ts +++ b/src/context/virtual-drive/folders/domain/FolderRepository.ts @@ -5,6 +5,8 @@ export interface FolderRepository { searchByPartial(partial: Partial): Folder | undefined; + matchingPartial(partial: Partial): Array; + add(folder: Folder): Promise; delete(id: Folder['id']): Promise; diff --git a/src/context/virtual-drive/folders/domain/FolderUpdatedAt.ts b/src/context/virtual-drive/folders/domain/FolderUpdatedAt.ts new file mode 100644 index 000000000..cadeb857c --- /dev/null +++ b/src/context/virtual-drive/folders/domain/FolderUpdatedAt.ts @@ -0,0 +1,3 @@ +import { DateValueObject } from '../../../shared/domain/value-objects/DateValueObject'; + +export class FolderUpdatedAt extends DateValueObject {} diff --git a/src/context/virtual-drive/folders/domain/errors/FolderAlreadyTrashed.ts b/src/context/virtual-drive/folders/domain/errors/FolderAlreadyTrashed.ts new file mode 100644 index 000000000..37d61b2c4 --- /dev/null +++ b/src/context/virtual-drive/folders/domain/errors/FolderAlreadyTrashed.ts @@ -0,0 +1,7 @@ +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; + +export class FolderAlreadyTrashed extends DriveDesktopError { + constructor(name: string) { + super('ACTION_NOT_PERMITTED', `Folder ${name} is already in the trash`); + } +} diff --git a/src/context/virtual-drive/folders/domain/errors/FolderInPathAlreadyExistsError.ts b/src/context/virtual-drive/folders/domain/errors/FolderInPathAlreadyExistsError.ts new file mode 100644 index 000000000..f482bcb8b --- /dev/null +++ b/src/context/virtual-drive/folders/domain/errors/FolderInPathAlreadyExistsError.ts @@ -0,0 +1,7 @@ +import { FolderPath } from '../FolderPath'; + +export class FolderInPathAlreadyExistsError extends Error { + constructor(path: FolderPath) { + super(`Folder in ${path.value} already exists`); + } +} diff --git a/src/context/virtual-drive/folders/domain/events/FolderMovedDomainEvent.ts b/src/context/virtual-drive/folders/domain/events/FolderMovedDomainEvent.ts new file mode 100644 index 000000000..d77fc71c9 --- /dev/null +++ b/src/context/virtual-drive/folders/domain/events/FolderMovedDomainEvent.ts @@ -0,0 +1,34 @@ +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class FolderMovedDomainEvent extends DomainEvent { + static readonly EVENT_NAME = 'virtual-drive.folder.moved'; + + from: string; + to: string; + + constructor({ + aggregateId, + from, + to, + }: { + aggregateId: string; + from: string; + to: string; + }) { + super({ + eventName: FolderMovedDomainEvent.EVENT_NAME, + aggregateId, + }); + + this.from = from; + this.to = to; + } + + toPrimitives() { + return { + uuid: this.aggregateId, + from: this.from, + to: this.to, + }; + } +} diff --git a/src/context/virtual-drive/folders/domain/file-systems/RemoteFileSystem.ts b/src/context/virtual-drive/folders/domain/file-systems/RemoteFileSystem.ts index 613a1d87e..326440680 100644 --- a/src/context/virtual-drive/folders/domain/file-systems/RemoteFileSystem.ts +++ b/src/context/virtual-drive/folders/domain/file-systems/RemoteFileSystem.ts @@ -1,15 +1,56 @@ +import { Either } from '../../../../shared/domain/Either'; import { Folder, FolderAttributes } from '../Folder'; -import { FolderStatuses } from '../FolderStatus'; +import { FolderId } from '../FolderId'; +import { FolderPath } from '../FolderPath'; +import { FolderUuid } from '../FolderUuid'; import { OfflineFolder } from '../OfflineFolder'; -export interface RemoteFileSystem { - persist(offline: OfflineFolder): Promise; +export type FolderPersistedDto = { + id: number; + uuid: string; + parentId: number; + updatedAt: string; + createdAt: string; +}; - trash(id: Folder['id']): Promise; +export type RemoteFileSystemErrors = + | 'ALREADY_EXISTS' + | 'WRONG_DATA' + | 'UNHANDLED'; - move(folder: Folder): Promise; +export abstract class RemoteFileSystem { + abstract persistv2( + path: FolderPath, + parentId: FolderId, + uuid?: FolderUuid + ): Promise>; - rename(folder: Folder): Promise; + abstract persist(offline: OfflineFolder): Promise; - checkStatusFolder(uuid: Folder['uuid']): Promise; + abstract trash(id: Folder['id']): Promise; + + abstract move(folder: Folder): Promise; + + abstract rename(folder: Folder): Promise; + + abstract searchWith( + parentId: FolderId, + folderPath: FolderPath + ): Promise; } + +// import { Folder, FolderAttributes } from '../Folder'; +// import { FolderStatuses } from '../FolderStatus'; +// import { OfflineFolder } from '../OfflineFolder'; + +// export interface RemoteFileSystem { +// persist(offline: OfflineFolder): Promise; + +// trash(id: Folder['id']): Promise; + +// move(folder: Folder): Promise; + +// rename(folder: Folder): Promise; + +// checkStatusFolder(uuid: Folder['uuid']): Promise; +// } diff --git a/src/context/virtual-drive/folders/infrastructure/HttpRemoteFileSystem.ts b/src/context/virtual-drive/folders/infrastructure/HttpRemoteFileSystem.ts index 6cd732d9d..21061441d 100644 --- a/src/context/virtual-drive/folders/infrastructure/HttpRemoteFileSystem.ts +++ b/src/context/virtual-drive/folders/infrastructure/HttpRemoteFileSystem.ts @@ -1,23 +1,135 @@ import axios, { Axios } from 'axios'; +import { Service } from 'diod'; import Logger from 'electron-log'; import * as uuid from 'uuid'; +import { Either, left, right } from '../../../shared/domain/Either'; +import { ServerFolder } from '../../../shared/domain/ServerFolder'; import { Folder, FolderAttributes } from '../domain/Folder'; -import { FolderStatuses } from '../domain/FolderStatus'; +import { FolderId } from '../domain/FolderId'; +import { FolderPath } from '../domain/FolderPath'; +import { FolderUuid } from '../domain/FolderUuid'; +import { + FolderPersistedDto, + RemoteFileSystem, + RemoteFileSystemErrors, +} from '../domain/file-systems/RemoteFileSystem'; +import { CreateFolderDTO } from './dtos/CreateFolderDTO'; import { UpdateFolderNameDTO } from './dtos/UpdateFolderNameDTO'; -import { RemoteFileSystem } from '../domain/file-systems/RemoteFileSystem'; +import { FolderStatuses } from '../domain/FolderStatus'; import { OfflineFolder } from '../domain/OfflineFolder'; -import { ServerFolder } from '../../../shared/domain/ServerFolder'; -import { CreateFolderDTO } from './dtos/CreateFolderDTO'; -import * as Sentry from '@sentry/electron/renderer'; +import { FileStatuses } from '../../files/domain/FileStatus'; +import { File } from '../../files/domain/File'; + +type NewServerFolder = Omit & { plainName: string }; +@Service() export class HttpRemoteFileSystem implements RemoteFileSystem { + private static PAGE_SIZE = 50; public folders: Record = {}; constructor( private readonly driveClient: Axios, - private readonly trashClient: Axios + private readonly trashClient: Axios, + private readonly maxRetries: number = 3 ) {} + async searchWith( + parentId: FolderId, + folderPath: FolderPath + ): Promise { + let page = 0; + const folders: Array = []; + let lastNumberOfFolders = 0; + + do { + const offset = page * HttpRemoteFileSystem.PAGE_SIZE; + + // eslint-disable-next-line no-await-in-loop + const result = await this.trashClient.get( + `${process.env.NEW_DRIVE_URL}/drive/folders/${parentId.value}/folders?offset=${offset}&limit=${HttpRemoteFileSystem.PAGE_SIZE}` + ); + + const founded = result.data.result as Array; + folders.push(...founded); + lastNumberOfFolders = founded.length; + + page++; + } while ( + folders.length % HttpRemoteFileSystem.PAGE_SIZE === 0 && + lastNumberOfFolders > 0 + ); + + const name = folderPath.name(); + + const folder = folders.find((folder) => folder.plainName === name); + + if (!folder) return; + + return Folder.from({ + ...folder, + path: folderPath.value, + }); + } + + async persistv2( + path: FolderPath, + parentId: FolderId, + uuid?: FolderUuid, + attempt = 0 + ): Promise> { + const body: CreateFolderDTO = { + folderName: path.name(), + parentFolderId: parentId.value, + uuid: uuid?.value, + }; + + try { + const response = await this.driveClient.post( + `${process.env.API_URL}/storage/folder`, + body + ); + + if (response.status !== 201) { + throw new Error('Folder creation failed'); + } + + const serverFolder = response.data as ServerFolder | null; + + if (!serverFolder) { + throw new Error('Folder creation failed, no data returned'); + } + + return right({ + id: serverFolder.id, + uuid: serverFolder.uuid, + parentId: parentId.value, + updatedAt: serverFolder.updatedAt, + createdAt: serverFolder.createdAt, + }); + } catch (err: any) { + const { status } = err.response; + + if (status === 400 && attempt < this.maxRetries) { + Logger.debug('Folder Creation failed with code 400'); + await new Promise((resolve) => { + setTimeout(resolve, 1_000); + }); + Logger.debug('Retrying'); + return this.persistv2(path, parentId, uuid, attempt + 1); + } + + if (status === 400) { + return left('WRONG_DATA'); + } + + if (status === 409) { + return left('ALREADY_EXISTS'); + } + + return left('UNHANDLED'); + } + } + async persist(offline: OfflineFolder): Promise { if (!offline.name || !offline.basename) { throw new Error('Bad folder name'); @@ -54,7 +166,6 @@ export class HttpRemoteFileSystem implements RemoteFileSystem { }; } catch (error: any) { Logger.error('[FOLDER FILE SYSTEM] Error creating folder', error); - Sentry.captureException(error); if (axios.isAxiosError(error)) { Logger.error('[Is Axios Error]', error.response?.data); } @@ -62,29 +173,6 @@ export class HttpRemoteFileSystem implements RemoteFileSystem { } } - async checkStatusFolder(uuid: Folder['uuid']): Promise { - let response; - try { - response = await this.trashClient.get( - `${process.env.NEW_DRIVE_URL}/drive/folders/${uuid}/meta` - ); - } catch (error) { - return FolderStatuses.DELETED; - } - - if (response.status !== 200) { - Logger.error( - '[FOLDER FILE SYSTEM] Error getting folder metadata', - response.status, - response.statusText - ); - Sentry.captureException(new Error('Error getting folder metadata')); - throw new Error('Error getting folder metadata'); - } - - return response.data.status as FolderStatuses; - } - async trash(id: Folder['id']): Promise { const result = await this.trashClient.post( `${process.env.NEW_DRIVE_URL}/drive/storage/trash/add`, @@ -99,7 +187,6 @@ export class HttpRemoteFileSystem implements RemoteFileSystem { result.status, result.statusText ); - Sentry.captureException(new Error('Error when deleting folder')); throw new Error('Error when deleting folder'); } @@ -133,4 +220,25 @@ export class HttpRemoteFileSystem implements RemoteFileSystem { throw new Error(`[FOLDER FILE SYSTEM] Error moving item: ${res.status}`); } } + + async checkStatusFile(uuid: File['uuid']): Promise { + const response = await this.driveClient.get( + `${process.env.NEW_DRIVE_URL}/drive/files/${uuid}/meta` + ); + + if (response.status === 404) { + return FileStatuses.DELETED; + } + + if (response.status !== 200) { + Logger.error( + '[FILE FILE SYSTEM] Error checking file status', + response.status, + response.statusText + ); + throw new Error('Error checking file status'); + } + + return response.data.status as FileStatuses; + } } diff --git a/src/context/virtual-drive/folders/infrastructure/InMemoryFolderRepository.ts b/src/context/virtual-drive/folders/infrastructure/InMemoryFolderRepository.ts index 6c02495e5..a75864fd6 100644 --- a/src/context/virtual-drive/folders/infrastructure/InMemoryFolderRepository.ts +++ b/src/context/virtual-drive/folders/infrastructure/InMemoryFolderRepository.ts @@ -1,6 +1,9 @@ +import { Service } from 'diod'; import { Folder, FolderAttributes } from '../domain/Folder'; import { FolderRepository } from '../domain/FolderRepository'; + +@Service() export class InMemoryFolderRepository implements FolderRepository { private folders: Map; @@ -12,6 +15,18 @@ export class InMemoryFolderRepository implements FolderRepository { return Array.from(this.folders.values()); } + matchingPartial(partial: Partial): Array { + const keys = Object.keys(partial) as Array>; + + const foldersAttributes = this.values.filter((attributes) => { + return keys.every( + (key: keyof FolderAttributes) => attributes[key] === partial[key] + ); + }); + + return foldersAttributes.map((attributes) => Folder.from(attributes)); + } + all(): Promise { const folders = [...this.folders.values()].map((attributes) => Folder.from(attributes) diff --git a/src/context/virtual-drive/remoteTree/application/FileCreatorFromServerFile.ts b/src/context/virtual-drive/remoteTree/application/FileCreatorFromServerFile.ts new file mode 100644 index 000000000..12a4cbdc0 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/application/FileCreatorFromServerFile.ts @@ -0,0 +1,21 @@ +import { ServerFile } from '../../../shared/domain/ServerFile'; +import { File } from '../../files/domain/File'; +import { FileStatuses } from '../../files/domain/FileStatus'; + +export function createFileFromServerFile( + server: ServerFile, + relativePath: string +): File { + return File.from({ + id: server.id, + uuid: server.uuid, + folderId: server.folderId, + contentsId: server.fileId, + modificationTime: server.modificationTime, + size: server.size, + createdAt: server.createdAt, + updatedAt: server.updatedAt, + path: relativePath, + status: FileStatuses[server.status as 'EXISTS' | 'TRASHED' | 'DELETED'], + }); +} diff --git a/src/context/virtual-drive/remoteTree/application/FolderCreatorFromServerFolder.ts b/src/context/virtual-drive/remoteTree/application/FolderCreatorFromServerFolder.ts new file mode 100644 index 000000000..e1816c7b1 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/application/FolderCreatorFromServerFolder.ts @@ -0,0 +1,17 @@ +import { ServerFolder } from '../../../shared/domain/ServerFolder'; +import { Folder } from '../../folders/domain/Folder'; + +export function createFolderFromServerFolder( + server: ServerFolder, + relativePath: string +): Folder { + return Folder.from({ + id: server.id, + uuid: server.uuid, + parentId: server.parentId as number, + updatedAt: server.updatedAt, + createdAt: server.createdAt, + path: relativePath, + status: server.status, + }); +} diff --git a/src/context/virtual-drive/remoteTree/application/ItemsSearcher.ts b/src/context/virtual-drive/remoteTree/application/ItemsSearcher.ts new file mode 100644 index 000000000..9a7b4f963 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/application/ItemsSearcher.ts @@ -0,0 +1,26 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export class ItemsSearcher { + listFilesAndFolders(directory: string): string[] { + let result: string[] = []; + + // Lee el contenido del directorio + const items = fs.readdirSync(directory); + + for (const item of items) { + const fullPath = path.join(directory, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + result.push(fullPath); + // Si es un directorio, explora su contenido de forma recursiva + result = result.concat(this.listFilesAndFolders(fullPath)); + } else if (stat.isFile()) { + result.push(fullPath); + } + } + + return result; + } +} diff --git a/src/context/virtual-drive/remoteTree/application/RemoteTreeBuilder.ts b/src/context/virtual-drive/remoteTree/application/RemoteTreeBuilder.ts new file mode 100644 index 000000000..e292c3cfc --- /dev/null +++ b/src/context/virtual-drive/remoteTree/application/RemoteTreeBuilder.ts @@ -0,0 +1,22 @@ +import { Service } from 'diod'; +import { RemoteItemsGenerator } from '../domain/RemoteItemsGenerator'; +import { RemoteTree } from '../domain/RemoteTree'; +import { Traverser } from './Traverser'; + +@Service() +export class RemoteTreeBuilder { + constructor( + private readonly remoteItemsGenerator: RemoteItemsGenerator, + private readonly traverser: Traverser + ) {} + + async run(rootFolderId: number, refresh = false): Promise { + if (refresh) { + await this.remoteItemsGenerator.forceRefresh(); + } + + const items = await this.remoteItemsGenerator.getAll(); + + return this.traverser.run(rootFolderId, items); + } +} diff --git a/src/context/virtual-drive/remoteTree/application/Traverser.ts b/src/context/virtual-drive/remoteTree/application/Traverser.ts new file mode 100644 index 000000000..3e09145d6 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/application/Traverser.ts @@ -0,0 +1,155 @@ +import * as Sentry from '@sentry/electron/renderer'; +import { Service } from 'diod'; +import Logger from 'electron-log'; +import { + ServerFile, + ServerFileStatus, +} from '../../../shared/domain/ServerFile'; +import { + ServerFolder, + ServerFolderStatus, +} from '../../../shared/domain/ServerFolder'; +import { createFileFromServerFile } from './FileCreatorFromServerFile'; +import { createFolderFromServerFolder } from '../../folders/application/create/FolderCreatorFromServerFolder'; +import { Folder } from '../../folders/domain/Folder'; +import { + FolderStatus, + FolderStatuses, +} from '../../folders/domain/FolderStatus'; +import { EitherTransformer } from '../../shared/application/EitherTransformer'; +import { NameDecrypt } from '../domain/NameDecrypt'; +import { RemoteTree } from '../domain/RemoteTree'; + +type Items = { + files: Array; + folders: Array; +}; +@Service() +export class Traverser { + constructor( + private readonly decrypt: NameDecrypt, + private readonly fileStatusesToFilter: Array, + private readonly folderStatusesToFilter: Array + ) {} + + static existingItems(decrypt: NameDecrypt): Traverser { + return new Traverser( + decrypt, + [ServerFileStatus.EXISTS], + [ServerFolderStatus.EXISTS] + ); + } + + static allItems(decrypt: NameDecrypt): Traverser { + return new Traverser(decrypt, [], []); + } + + private createRootFolder(id: number): Folder { + const rootFolderUuid = '43711926-15c2-5ebf-8c24-5099fa9af3c3'; + + return Folder.from({ + id: id, + uuid: rootFolderUuid, + parentId: null, + updatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + path: '/', + status: FolderStatus.Exists.value, + }); + } + + private traverse(tree: RemoteTree, items: Items, currentFolder: Folder) { + if (!items) return; + + const filesInThisFolder = items.files.filter( + (file) => file.folderId === currentFolder.id + ); + + const foldersInThisFolder = items.folders.filter((folder) => { + return folder.parentId === currentFolder.id; + }); + + filesInThisFolder.forEach((serverFile) => { + if (!this.fileStatusesToFilter.includes(serverFile.status)) { + return; + } + + const decryptedName = + serverFile.plainName ?? + this.decrypt.decryptName( + serverFile.name, + serverFile.folderId.toString(), + serverFile.encrypt_version + ); + const extensionToAdd = serverFile.type ? `.${serverFile.type}` : ''; + + const relativeFilePath = + `${currentFolder.path}/${decryptedName}${extensionToAdd}`.replaceAll( + '//', + '/' + ); + + EitherTransformer.handleWithEither(() => { + const file = createFileFromServerFile(serverFile, relativeFilePath); + tree.addFile(currentFolder, file); + }).fold( + (error): void => { + Logger.warn('[Traverser] Error adding file:', error); + Sentry.captureException(error); + }, + () => { + // no-op + } + ); + }); + + foldersInThisFolder.forEach((serverFolder: ServerFolder) => { + const plainName = + serverFolder.plain_name || + this.decrypt.decryptName( + serverFolder.name, + (serverFolder.parentId as number).toString(), + '03-aes' + ) || + serverFolder.name; + + const name = `${currentFolder.path}/${plainName}`; + + if (!this.folderStatusesToFilter.includes(serverFolder.status)) { + return; + } + + EitherTransformer.handleWithEither(() => { + const folder = createFolderFromServerFolder(serverFolder, name); + + tree.addFolder(currentFolder, folder); + + return folder; + }).fold( + (error) => { + Logger.warn(`[Traverser] Error adding folder: ${error} `); + Sentry.captureException(error); + }, + (folder) => { + if (folder.hasStatus(FolderStatuses.EXISTS)) { + // The folders and the files inside trashed or deleted folders + // will have the status "EXISTS", to avoid filtering witch folders and files + // are in a deleted or trashed folder they not included on the collection. + // We cannot perform any action on them either way + this.traverse(tree, items, folder); + } + } + ); + }); + } + + public run(rootFolderId: number, items: Items): RemoteTree { + const rootFolder = this.createRootFolder(rootFolderId); + + const tree = new RemoteTree(rootFolder); + + this.traverse(tree, items, rootFolder); + + return tree; + } +} diff --git a/src/context/virtual-drive/remoteTree/domain/FileNode.ts b/src/context/virtual-drive/remoteTree/domain/FileNode.ts new file mode 100644 index 000000000..b49b14b8d --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/FileNode.ts @@ -0,0 +1,22 @@ +import { File } from '../../files/domain/File'; +import { FolderNode } from './FolderNode'; + +export class FileNode { + private constructor(public readonly file: File) {} + + static from(file: File): FileNode { + return new FileNode(file); + } + + public get id(): string { + return this.file.path; + } + + public isFile(): this is FileNode { + return true; + } + + public isFolder(): this is FolderNode { + return false; + } +} diff --git a/src/context/virtual-drive/remoteTree/domain/FolderNode.ts b/src/context/virtual-drive/remoteTree/domain/FolderNode.ts new file mode 100644 index 000000000..583eb1e14 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/FolderNode.ts @@ -0,0 +1,39 @@ +import { Folder } from '../../folders/domain/Folder'; +import { FileNode } from './FileNode'; +import { Node } from './Node'; + +export class FolderNode { + private constructor( + public readonly folder: Folder, + private children: Map, + public readonly isRoot: boolean + ) {} + + static from(folder: Folder): FolderNode { + return new FolderNode(folder, new Map(), false); + } + + static createRoot(folder: Folder): FolderNode { + return new FolderNode(folder, new Map(), true); + } + + public get id(): string { + return this.folder.path; + } + + addChild(node: Node): void { + if (this.children.has(node.id)) { + throw new Error(`Duplicated node detected: ${node.id}`); + } + + this.children.set(node.id, node); + } + + public isFile(): this is FileNode { + return false; + } + + public isFolder(): this is FolderNode { + return true; + } +} diff --git a/src/context/virtual-drive/remoteTree/domain/ItemsIndexedByPath.ts b/src/context/virtual-drive/remoteTree/domain/ItemsIndexedByPath.ts new file mode 100644 index 000000000..c7da32338 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/ItemsIndexedByPath.ts @@ -0,0 +1,5 @@ +import { File } from '../../files/domain/File'; +import { Folder } from '../../folders/domain/Folder'; + +/** @deprecated */ +export type ItemsIndexedByPath = Record; diff --git a/src/context/virtual-drive/remoteTree/domain/NameDecrypt.ts b/src/context/virtual-drive/remoteTree/domain/NameDecrypt.ts new file mode 100644 index 000000000..064d1f85f --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/NameDecrypt.ts @@ -0,0 +1,7 @@ +export abstract class NameDecrypt { + abstract decryptName: ( + name: string, + folderId: string, + encryptVersion: string + ) => string | null; +} diff --git a/src/context/virtual-drive/remoteTree/domain/Node.ts b/src/context/virtual-drive/remoteTree/domain/Node.ts new file mode 100644 index 000000000..27d930a89 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/Node.ts @@ -0,0 +1,4 @@ +import { FileNode } from './FileNode'; +import { FolderNode } from './FolderNode'; + +export type Node = FileNode | FolderNode; diff --git a/src/context/virtual-drive/remoteTree/domain/RemoteItemsGenerator.ts b/src/context/virtual-drive/remoteTree/domain/RemoteItemsGenerator.ts new file mode 100644 index 000000000..2dff36386 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/RemoteItemsGenerator.ts @@ -0,0 +1,8 @@ +import { ServerFile } from '../../../shared/domain/ServerFile'; +import { ServerFolder } from '../../../shared/domain/ServerFolder'; + +export abstract class RemoteItemsGenerator { + abstract getAll(): Promise<{ files: ServerFile[]; folders: ServerFolder[] }>; + + abstract forceRefresh(): Promise; +} diff --git a/src/context/virtual-drive/remoteTree/domain/RemoteTree.ts b/src/context/virtual-drive/remoteTree/domain/RemoteTree.ts new file mode 100644 index 000000000..3bc501ad9 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/RemoteTree.ts @@ -0,0 +1,130 @@ +import path from 'path'; +import { File } from '../../files/domain/File'; +import { Folder } from '../../folders/domain/Folder'; +import { FileNode } from './FileNode'; +import { FolderNode } from './FolderNode'; +import { Node } from './Node'; +import Logger from 'electron-log'; +export class RemoteTree { + private tree = new Map(); + + constructor(rootFolder: Folder) { + const node = FolderNode.createRoot(rootFolder); + this.tree.set('/', node); + } + + public get files(): Array { + const files: Array = []; + this.tree.forEach((node) => { + if (node.isFile()) { + files.push(node.file); + } + }); + return files; + } + + public get root(): Folder { + const r = this.get('/'); + if (r.isFile()) { + throw new Error('Root node is a file, which is not expected.'); + } + return r; + } + + public get filePaths(): Array { + return this.files.map((f) => f.path); + } + + public get folders(): Array { + const folders: Array = []; + this.tree.forEach((node) => { + if (node.isFolder()) { + folders.push(node.folder); + } + }); + return folders; + } + + public get foldersWithOutRoot(): Array { + const folders: Array = []; + this.tree.forEach((node) => { + if (node.isFolder() && !node.isRoot) { + folders.push(node.folder); + } + }); + return folders; + } + + public get folderPaths(): Array { + return this.folders.map((f) => f.path); + } + + private addNode(node: Node): void { + this.tree.set(node.id, node); + } + + public addFile(parentNode: Folder, file: File): void { + const parent = this.tree.get(parentNode.path) as FolderNode; + + if (!parent) { + throw new Error(`Parent node not found for path: ${parentNode.path}`); + } + + const node = FileNode.from(file); + parent.addChild(node); + this.addNode(node); + } + + public addFolder(parentNode: Folder, folder: Folder): void { + const parent = this.tree.get(parentNode.path) as FolderNode; + + if (!parent) { + throw new Error(`Parent node not found for path: ${parentNode.path}`); + } + + const node = FolderNode.from(folder); + parent.addChild(node); + this.addNode(node); + } + + public has(id: string): boolean { + return this.tree.has(id); + } + + public get(id: string): File | Folder { + Logger.info(`Getting node for id: ${id}`); + const node = this.tree.get(id); + + if (!node) { + throw new Error(`Node not found with id: ${id}`); + } + + if (node.isFile()) { + return node.file; + } + + return node.folder; + } + + public hasParent(id: string): boolean { + const dirname = path.dirname(id); + const parentId = dirname === '.' ? path.sep : dirname; + return this.has(parentId); + } + + public getParent(id: string): Folder { + Logger.info(`Getting parent for id: ${id}`); + const dirname = path.dirname(id); + const parentId = dirname === '.' ? path.sep : dirname; + + const element = this.get(parentId); + + if (element.isFile()) { + throw new Error( + `Expected a folder but found a file at path: ${parentId}` + ); + } + + return element; + } +} diff --git a/src/context/virtual-drive/remoteTree/domain/TreeBuilderMessenger.ts b/src/context/virtual-drive/remoteTree/domain/TreeBuilderMessenger.ts new file mode 100644 index 000000000..6b0fa3b60 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/domain/TreeBuilderMessenger.ts @@ -0,0 +1,3 @@ +export interface TreeBuilderMessenger { + duplicatedNode(name: string): Promise; +} diff --git a/src/context/virtual-drive/remoteTree/infrastructure/CryptoJsNameDecrypt.ts b/src/context/virtual-drive/remoteTree/infrastructure/CryptoJsNameDecrypt.ts new file mode 100644 index 000000000..7f749bd76 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/infrastructure/CryptoJsNameDecrypt.ts @@ -0,0 +1,14 @@ +import { Service } from 'diod'; +import crypto from '../../../shared/infrastructure/crypt'; +import { NameDecrypt } from '../domain/NameDecrypt'; + +@Service() +export class CryptoJsNameDecrypt implements NameDecrypt { + decryptName( + name: string, + folderId: string, + encryptVersion: string + ): string | null { + return crypto.decryptName(name, folderId, encryptVersion); + } +} diff --git a/src/context/virtual-drive/remoteTree/infrastructure/IpcRemoteItemsGenerator.ts b/src/context/virtual-drive/remoteTree/infrastructure/IpcRemoteItemsGenerator.ts new file mode 100644 index 000000000..338676b1c --- /dev/null +++ b/src/context/virtual-drive/remoteTree/infrastructure/IpcRemoteItemsGenerator.ts @@ -0,0 +1,64 @@ +import { Service } from 'diod'; +import { + ServerFile, + ServerFileStatus, +} from '../../../shared/domain/ServerFile'; +import { + ServerFolder, + ServerFolderStatus, +} from '../../../shared/domain/ServerFolder'; +import { RemoteItemsGenerator } from '../domain/RemoteItemsGenerator'; +import { SyncEngineIpc } from '../../../../apps/sync-engine/ipcRendererSyncEngine'; + +@Service() +export class IpcRemoteItemsGenerator implements RemoteItemsGenerator { + constructor(private readonly ipc: SyncEngineIpc) {} + + async getAll(): Promise<{ files: ServerFile[]; folders: ServerFolder[] }> { + const updatedRemoteItems = await this.ipc.invoke( + 'GET_UPDATED_REMOTE_ITEMS' + ); + + const files = updatedRemoteItems.files.map((updatedFile) => { + return { + bucket: updatedFile.bucket, + createdAt: updatedFile.createdAt, + encrypt_version: '03-aes', + fileId: updatedFile.fileId, + folderId: updatedFile.folderId, + id: updatedFile.id, + modificationTime: updatedFile.modificationTime, + name: updatedFile.name, + plainName: updatedFile.plainName, + size: updatedFile.size, + type: updatedFile.type ?? null, + updatedAt: updatedFile.updatedAt, + userId: updatedFile.userId, + status: updatedFile.status as ServerFileStatus, + uuid: updatedFile.uuid, + }; + }); + + const folders = updatedRemoteItems.folders.map( + (updatedFolder) => { + return { + bucket: updatedFolder.bucket ?? null, + createdAt: updatedFolder.createdAt, + id: updatedFolder.id, + name: updatedFolder.name, + parentId: updatedFolder.parentId ?? null, + updatedAt: updatedFolder.updatedAt, + plain_name: updatedFolder.plainName ?? null, + status: updatedFolder.status as ServerFolderStatus, + uuid: updatedFolder.uuid, + }; + } + ); + + return { files, folders }; + } + + async forceRefresh(): Promise { + await this.ipc.invoke('START_REMOTE_SYNC'); + } +} diff --git a/src/context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator.ts b/src/context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator.ts new file mode 100644 index 000000000..de80c2538 --- /dev/null +++ b/src/context/virtual-drive/remoteTree/infrastructure/SQLiteRemoteItemsGenerator.ts @@ -0,0 +1,62 @@ +import { Service } from 'diod'; + +import { + ServerFile, + ServerFileStatus, +} from '../../../shared/domain/ServerFile'; +import { + ServerFolder, + ServerFolderStatus, +} from '../../../shared/domain/ServerFolder'; +import { RemoteItemsGenerator } from '../domain/RemoteItemsGenerator'; +import { + getUpdatedRemoteItems, + startRemoteSync, +} from '../../../../apps/main/remote-sync/handlers'; + +@Service() +export class SQLiteRemoteItemsGenerator implements RemoteItemsGenerator { + async getAll(): Promise<{ files: ServerFile[]; folders: ServerFolder[] }> { + const result = await getUpdatedRemoteItems(); + + const files = result.files.map((updatedFile) => { + return { + bucket: updatedFile.bucket, + createdAt: updatedFile.createdAt, + encrypt_version: '03-aes', + fileId: updatedFile.fileId, + folderId: updatedFile.folderId, + id: updatedFile.id, + modificationTime: updatedFile.modificationTime, + name: updatedFile.name, + plainName: updatedFile.plainName, + size: updatedFile.size, + type: updatedFile.type ?? null, + updatedAt: updatedFile.updatedAt, + userId: updatedFile.userId, + status: updatedFile.status as ServerFileStatus, + uuid: updatedFile.uuid, + }; + }); + + const folders = result.folders.map((updatedFolder) => { + return { + bucket: updatedFolder.bucket ?? null, + createdAt: updatedFolder.createdAt, + id: updatedFolder.id, + name: updatedFolder.name, + parentId: updatedFolder.parentId ?? null, + updatedAt: updatedFolder.updatedAt, + plain_name: updatedFolder.plainName ?? null, + status: updatedFolder.status as ServerFolderStatus, + uuid: updatedFolder.uuid, + }; + }); + + return { files, folders }; + } + + async forceRefresh(): Promise { + await startRemoteSync(); + } +} diff --git a/src/context/virtual-drive/remoteTree/infrastructure/TreeBuilderMessengers/BackgroundProcessTreeBuilderMessenger.ts b/src/context/virtual-drive/remoteTree/infrastructure/TreeBuilderMessengers/BackgroundProcessTreeBuilderMessenger.ts new file mode 100644 index 000000000..4eb8513eb --- /dev/null +++ b/src/context/virtual-drive/remoteTree/infrastructure/TreeBuilderMessengers/BackgroundProcessTreeBuilderMessenger.ts @@ -0,0 +1,15 @@ +import { SyncEngineIpc } from '../../../../../apps/sync-engine/SyncEngineIpc'; +import { TreeBuilderMessenger } from '../../domain/TreeBuilderMessenger'; + +export class BackgroundProcessTreeBuilderMessenger + implements TreeBuilderMessenger +{ + constructor(private readonly ipc: SyncEngineIpc) {} + + async duplicatedNode(name: string): Promise { + this.ipc.send('TREE_BUILD_ERROR', { + error: 'DUPLICATED_NODE', + name, + }); + } +} diff --git a/src/context/virtual-drive/remoteTree/infrastructure/TreeBuilderMessengers/MainProcessTreeBuilderMessenger.ts b/src/context/virtual-drive/remoteTree/infrastructure/TreeBuilderMessengers/MainProcessTreeBuilderMessenger.ts new file mode 100644 index 000000000..d6969b61f --- /dev/null +++ b/src/context/virtual-drive/remoteTree/infrastructure/TreeBuilderMessengers/MainProcessTreeBuilderMessenger.ts @@ -0,0 +1,12 @@ +import { addVirtualDriveIssue } from '../../../../../apps/main/issues/virtual-drive'; +import { TreeBuilderMessenger } from '../../domain/TreeBuilderMessenger'; + +export class MainProcessTreeBuilderMessenger implements TreeBuilderMessenger { + async duplicatedNode(name: string): Promise { + addVirtualDriveIssue({ + error: 'GENERATE_TREE', + cause: 'DUPLICATED_NODE', + name: name, + }); + } +}