diff --git a/packages/web-app-files/src/HandleUpload.ts b/packages/web-app-files/src/HandleUpload.ts index 64e5df154fe..d4eabbb0900 100644 --- a/packages/web-app-files/src/HandleUpload.ts +++ b/packages/web-app-files/src/HandleUpload.ts @@ -41,6 +41,10 @@ export interface HandleUploadOptions { * 5. start upload */ export class HandleUpload extends BasePlugin { + id: string + type: string + uppy: Uppy + clientService: ClientService hasSpaces: Ref language: Language @@ -54,8 +58,10 @@ export class HandleUpload extends BasePlugin { constructor(uppy: Uppy, opts: HandleUploadOptions) { super(uppy, opts) - ;(this as any).id = opts.id || 'HandleUpload' - ;(this as any).type = 'modifier' + this.id = opts.id || 'HandleUpload' + this.type = 'modifier' + this.uppy = uppy + this.clientService = opts.clientService this.hasSpaces = opts.hasSpaces this.language = opts.language @@ -71,10 +77,6 @@ export class HandleUpload extends BasePlugin { this.handleUpload = this.handleUpload.bind(this) } - get _uppy(): Uppy { - return (this as any).uppy - } - get currentFolder(): Resource { return this.store.getters['Files/currentFolder'] } @@ -89,49 +91,45 @@ export class HandleUpload extends BasePlugin { removeFilesFromUpload(filesToUpload: UppyResource[]) { for (const file of filesToUpload) { - this._uppy.removeFile(file.id) + this.uppy.removeFile(file.id) } } - /** - * Sets the endpoint url for a given file. - */ - setEndpointUrl(fileId: string, endpoint: string) { - if (this._uppy.getPlugin('Tus')) { - this._uppy.setFileState(fileId, { tus: { endpoint } }) - return - } - this._uppy.setFileState(fileId, { xhrUpload: { endpoint } }) + getUploadPluginName() { + return this.uppy.getPlugin('Tus') ? 'tus' : 'xhrUpload' } /** * Converts the input files type UppyResources and updates the uppy upload queue */ prepareFiles(files: UppyFile[]): UppyResource[] { - const filesToUpload = [] + const filesToUpload: Record = {} if (!this.currentFolder && unref(this.route)?.params?.token) { // public file drop const publicLinkToken = unref(this.route).params.token let endpoint = this.clientService.owncloudSdk.publicFiles.getFileUrl(publicLinkToken) + '/' for (const file of files) { - if (!this._uppy.getPlugin('Tus')) { + if (!this.uppy.getPlugin('Tus')) { endpoint = urlJoin(endpoint, encodeURIComponent(file.name)) } - this.setEndpointUrl(file.id, endpoint) - this._uppy.setFileMeta(file.id, { + + file[this.getUploadPluginName()] = { endpoint } + file.meta = { + ...file.meta, tusEndpoint: endpoint, uploadId: uuid.v4() - }) + } - filesToUpload.push(this._uppy.getFile(file.id)) + filesToUpload[file.id] = file as unknown as UppyResource } - return filesToUpload + this.uppy.setState({ files: { ...this.uppy.getState().files, ...filesToUpload } }) + return Object.values(filesToUpload) } const { id: currentFolderId, path: currentFolderPath } = this.currentFolder const { name, params, query } = unref(this.route) - const topLevelFolderIds = {} + const topLevelFolderIds: Record = {} for (const file of files) { const relativeFilePath = file.meta.relativePath as string @@ -139,7 +137,7 @@ export class HandleUpload extends BasePlugin { const directory = !relativeFilePath || dirname(relativeFilePath) === '.' ? '' : dirname(relativeFilePath) - let topLevelFolderId + let topLevelFolderId: string if (relativeFilePath) { const topLevelDirectory = relativeFilePath.split('/').filter(Boolean)[0] if (!topLevelFolderIds[topLevelDirectory]) { @@ -153,12 +151,13 @@ export class HandleUpload extends BasePlugin { }) let endpoint = urlJoin(webDavUrl, directory.split('/').map(encodeURIComponent).join('/')) - if (!this._uppy.getPlugin('Tus')) { + if (!this.uppy.getPlugin('Tus')) { endpoint = urlJoin(endpoint, encodeURIComponent(file.name)) } - this.setEndpointUrl(file.id, endpoint) - this._uppy.setFileMeta(file.id, { + file[this.getUploadPluginName()] = { endpoint } + file.meta = { + ...file.meta, // file data name: file.name, mtime: (file.data as any).lastModified / 1000, @@ -179,19 +178,20 @@ export class HandleUpload extends BasePlugin { routeName: name as string, routeDriveAliasAndItem: (params as any)?.driveAliasAndItem || '', routeShareId: (query as any)?.shareId || '' - }) + } - filesToUpload.push(this._uppy.getFile(file.id)) + filesToUpload[file.id] = file as unknown as UppyResource } - return filesToUpload + this.uppy.setState({ files: { ...this.uppy.getState().files, ...filesToUpload } }) + return Object.values(filesToUpload) } checkQuotaExceeded(filesToUpload: UppyResource[]): boolean { let quotaExceeded = false const uploadSizeSpaceMapping = filesToUpload.reduce((acc, uppyResource) => { - let targetUploadSpace + let targetUploadSpace: SpaceResource if (uppyResource.meta.routeName === locationPublicLink.name) { return acc @@ -338,14 +338,14 @@ export class HandleUpload extends BasePlugin { } } - let filesToRemove = [] + let filesToRemove: string[] = [] if (failedFolders.length) { - // remove file of folders that could not be created + // remove files of folders that could not be created filesToRemove = filesToUpload .filter((f) => failedFolders.some((r) => f.meta.relativeFolder.startsWith(r))) .map(({ id }) => id) for (const fileId of filesToRemove) { - this._uppy.removeFile(fileId) + this.uppy.removeFile(fileId) } } @@ -370,37 +370,26 @@ export class HandleUpload extends BasePlugin { // name conflict handling if (this.conflictHandlingEnabled) { - const confictHandler = new ResourceConflict(this.store, this.language) - const conflicts = confictHandler.getConflicts(filesToUpload) + const conflictHandler = new ResourceConflict(this.store, this.language) + const conflicts = conflictHandler.getConflicts(filesToUpload) if (conflicts.length) { const dashboard = document.getElementsByClassName('uppy-Dashboard') if (dashboard.length) { ;(dashboard[0] as HTMLElement).style.display = 'none' } - const result = await confictHandler.displayOverwriteDialog(filesToUpload, conflicts) + const result = await conflictHandler.displayOverwriteDialog(filesToUpload, conflicts) if (result.length === 0) { this.removeFilesFromUpload(filesToUpload) return this.uppyService.clearInputs() } - for (const file of filesToUpload) { - const conflictResult = result.find(({ id }) => id === file.id) - if (!conflictResult) { - this._uppy.removeFile(file.id) - continue - } - this._uppy.setFileMeta(file.id, conflictResult.meta) - this._uppy.setFileState(file.id, { name: conflictResult.name }) - this.setEndpointUrl( - file.id, - !!this._uppy.getPlugin('Tus') - ? conflictResult.meta.tusEndpoint - : conflictResult.xhrUpload.endpoint - ) - } - filesToUpload = result + const conflictMap = result.reduce>((acc, file) => { + acc[file.id] = file + return acc + }, {}) + this.uppy.setState({ files: { ...this.uppy.getState().files, ...conflictMap } }) } } @@ -419,10 +408,10 @@ export class HandleUpload extends BasePlugin { } install() { - this._uppy.on('files-added', this.handleUpload) + this.uppy.on('files-added', this.handleUpload) } uninstall() { - this._uppy.off('files-added', this.handleUpload) + this.uppy.off('files-added', this.handleUpload) } } diff --git a/packages/web-app-files/src/helpers/resource/actions/upload.ts b/packages/web-app-files/src/helpers/resource/actions/upload.ts index 3f15c2894b1..bf07a204a7a 100644 --- a/packages/web-app-files/src/helpers/resource/actions/upload.ts +++ b/packages/web-app-files/src/helpers/resource/actions/upload.ts @@ -170,6 +170,12 @@ export class ResourceConflict extends ConflictDialog { `/${encodeURIComponent(newFolderName)}` ) } + if (file.tus?.endpoint) { + file.tus.endpoint = file.tus.endpoint.replace( + new RegExp(`/${encodeURIComponent(folder)}`), + `/${encodeURIComponent(newFolderName)}` + ) + } } } return files diff --git a/packages/web-app-files/tests/unit/HandleUpload.spec.ts b/packages/web-app-files/tests/unit/HandleUpload.spec.ts new file mode 100644 index 00000000000..deeeacf6c1f --- /dev/null +++ b/packages/web-app-files/tests/unit/HandleUpload.spec.ts @@ -0,0 +1,225 @@ +import Uppy, { UppyFile, State, UIPlugin } from '@uppy/core' +import { HandleUpload } from '../../src/HandleUpload' +import { mock, mockDeep } from 'jest-mock-extended' +import { UppyResource } from 'web-runtime/src/composables/upload' +import { Resource, SpaceResource } from 'web-client/src' +import { UppyService } from 'web-runtime/src/services/uppyService' +import { RouteLocationNormalizedLoaded } from 'vue-router' +import { ref, unref } from 'vue' +import { ClientService } from 'web-pkg/src' +import { Language } from 'vue3-gettext' +import { ResourceConflict } from 'web-app-files/src/helpers/resource/actions' + +jest.mock('web-app-files/src/helpers/resource/actions') + +describe('HandleUpload', () => { + it('installs the handleUpload callback when files are being added', () => { + const { instance, mocks } = getWrapper() + instance.install() + expect(mocks.uppy.on).toHaveBeenCalledWith('files-added', instance.handleUpload) + }) + it('uninstalls the handleUpload callback when files are being added', () => { + const { instance, mocks } = getWrapper() + instance.uninstall() + expect(mocks.uppy.off).toHaveBeenCalledWith('files-added', instance.handleUpload) + }) + it('removes files from the uppy upload queue', () => { + const { instance, mocks } = getWrapper() + const fileToRemove = mock() + instance.removeFilesFromUpload([fileToRemove]) + expect(mocks.uppy.removeFile).toHaveBeenCalledWith(fileToRemove.id) + }) + it('correctly prepares all files that need to be uploaded', () => { + const { instance, mocks } = getWrapper() + mocks.uppy.getPlugin.mockReturnValue(mock()) + const fileToUpload = mock({ name: 'name' }) + const processedFiles = instance.prepareFiles([fileToUpload]) + + const currentFolder = mocks.opts.store.getters['Files/currentFolder'] + const route = unref(mocks.opts.route) + + expect(processedFiles[0].tus.endpoint).toEqual('/') + expect(processedFiles[0].meta.name).toEqual(fileToUpload.name) + expect(processedFiles[0].meta.spaceId).toEqual(mocks.opts.space.id) + expect(processedFiles[0].meta.spaceName).toEqual(mocks.opts.space.name) + expect(processedFiles[0].meta.driveAlias).toEqual(mocks.opts.space.driveAlias) + expect(processedFiles[0].meta.driveType).toEqual(mocks.opts.space.driveType) + expect(processedFiles[0].meta.currentFolder).toEqual(currentFolder.path) + expect(processedFiles[0].meta.currentFolderId).toEqual(currentFolder.id) + expect(processedFiles[0].meta.tusEndpoint).toEqual(currentFolder.path) + expect(processedFiles[0].meta.relativeFolder).toEqual('') + expect(processedFiles[0].meta.routeName).toEqual(route.name) + expect(processedFiles[0].meta.routeDriveAliasAndItem).toEqual(route.params.driveAliasAndItem) + expect(processedFiles[0].meta.routeShareId).toEqual(route.query.shareId) + }) + describe('method createDirectoryTree', () => { + it('creates a directory for a single file with a relative folder given', async () => { + const { instance, mocks } = getWrapper() + mocks.uppy.getPlugin.mockReturnValue(mock()) + const relativeFolder = '/relativeFolder' + const fileToUpload = mock({ name: 'name', meta: { relativeFolder } }) + const createdFolder = mock() + mocks.opts.clientService.webdav.createFolder.mockResolvedValue(createdFolder) + + const result = await instance.createDirectoryTree([fileToUpload]) + const currentFolder = mocks.opts.store.getters['Files/currentFolder'] + + expect(mocks.opts.uppyService.publish).toHaveBeenCalledWith( + 'uploadSuccess', + expect.objectContaining({ + name: relativeFolder.split('/')[1], + isFolder: true, + type: 'folder', + meta: expect.objectContaining({ + spaceId: mocks.opts.space.id, + spaceName: mocks.opts.space.name, + driveAlias: mocks.opts.space.driveAlias, + driveType: mocks.opts.space.driveType, + currentFolder: currentFolder.path, + currentFolderId: currentFolder.id, + relativeFolder: '', + routeName: fileToUpload.meta.routeName, + routeDriveAliasAndItem: fileToUpload.meta.routeDriveAliasAndItem, + routeShareId: fileToUpload.meta.routeShareId, + fileId: createdFolder.fileId + }) + }) + ) + expect(mocks.opts.clientService.webdav.createFolder).toHaveBeenCalledTimes(1) + expect(mocks.opts.clientService.webdav.createFolder).toHaveBeenCalledWith(mocks.opts.space, { + path: relativeFolder + }) + expect(result.length).toBe(1) + }) + it('filters out files whose folders could not be created', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined) + + const { instance, mocks } = getWrapper() + mocks.uppy.getPlugin.mockReturnValue(mock()) + const relativeFolder = '/relativeFolder' + const fileToUpload = mock({ name: 'name', meta: { relativeFolder } }) + mocks.opts.clientService.webdav.createFolder.mockRejectedValue({}) + + const result = await instance.createDirectoryTree([fileToUpload]) + + expect(mocks.opts.uppyService.publish).toHaveBeenCalledWith('uploadError', expect.anything()) + expect(mocks.uppy.removeFile).toHaveBeenCalled() + expect(result.length).toBe(0) + }) + }) + describe('method handleUpload', () => { + it('prepares files and eventually triggers the upload in uppy', async () => { + const { instance, mocks } = getWrapper() + const prepareFilesSpy = jest.spyOn(instance, 'prepareFiles') + await instance.handleUpload([mock({ name: 'name' })]) + expect(prepareFilesSpy).toHaveBeenCalledTimes(1) + expect(mocks.opts.uppyService.publish).toHaveBeenCalledWith( + 'addedForUpload', + expect.anything() + ) + expect(mocks.opts.uppyService.uploadFiles).toHaveBeenCalledTimes(1) + }) + describe('quota check', () => { + it('checks quota if check enabled', async () => { + const { instance } = getWrapper() + const checkQuotaExceededSpy = jest.spyOn(instance, 'checkQuotaExceeded') + await instance.handleUpload([mock({ name: 'name' })]) + expect(checkQuotaExceededSpy).toHaveBeenCalled() + }) + it('does not check quota if check disabled', async () => { + const { instance } = getWrapper({ quotaCheckEnabled: false }) + const checkQuotaExceededSpy = jest.spyOn(instance, 'checkQuotaExceeded') + await instance.handleUpload([mock({ name: 'name' })]) + expect(checkQuotaExceededSpy).not.toHaveBeenCalled() + }) + }) + describe('conflict handling check', () => { + it('checks for conflicts if check enabled', async () => { + const { instance, mocks } = getWrapper() + await instance.handleUpload([mock({ name: 'name' })]) + expect(mocks.resourceConflict.getConflicts).toHaveBeenCalled() + }) + it('does not check for conflicts if check disabled', async () => { + const { instance, mocks } = getWrapper({ conflictHandlingEnabled: false }) + await instance.handleUpload([mock({ name: 'name' })]) + expect(mocks.resourceConflict.getConflicts).not.toHaveBeenCalled() + }) + it('does not start upload if all files were skipped in conflict handling', async () => { + const { instance, mocks } = getWrapper({ conflicts: [{}], conflictHandlerResult: [] }) + const removeFilesFromUploadSpy = jest.spyOn(instance, 'removeFilesFromUpload') + + await instance.handleUpload([mock({ name: 'name' })]) + expect(mocks.opts.uppyService.uploadFiles).not.toHaveBeenCalled() + expect(mocks.opts.uppyService.clearInputs).toHaveBeenCalled() + expect(removeFilesFromUploadSpy).toHaveBeenCalled() + }) + it('sets the result of the conflict handler as uppy file state', async () => { + const conflictHandlerResult = [mock({ id: '1' })] + const { instance, mocks } = getWrapper({ conflicts: [{}], conflictHandlerResult }) + await instance.handleUpload([mock(), mock()]) + + expect(mocks.uppy.setState).toHaveBeenCalledWith({ + files: { [conflictHandlerResult[0].id]: conflictHandlerResult[0] } + }) + }) + }) + describe('create directory tree', () => { + it('creates the directly tree if enabled', async () => { + const { instance } = getWrapper() + const createDirectoryTreeSpy = jest.spyOn(instance, 'createDirectoryTree') + await instance.handleUpload([mock({ name: 'name' })]) + expect(createDirectoryTreeSpy).toHaveBeenCalled() + }) + it('does not create the directly tree if disabled', async () => { + const { instance } = getWrapper({ directoryTreeCreateEnabled: false }) + const createDirectoryTreeSpy = jest.spyOn(instance, 'createDirectoryTree') + await instance.handleUpload([mock({ name: 'name' })]) + expect(createDirectoryTreeSpy).not.toHaveBeenCalled() + }) + }) + }) +}) + +const getWrapper = ({ + conflictHandlingEnabled = true, + directoryTreeCreateEnabled = true, + quotaCheckEnabled = true, + conflicts = [], + conflictHandlerResult = [] +} = {}) => { + const resourceConflict = mock() + resourceConflict.getConflicts.mockReturnValue(conflicts) + resourceConflict.displayOverwriteDialog.mockResolvedValue(conflictHandlerResult) + jest.mocked(ResourceConflict).mockImplementation(() => resourceConflict) + + const store = { + getters: { + 'Files/currentFolder': mock({ path: '/' }), + 'Files/files': [mock()], + 'runtime/spaces/spaces': mock() + } + } as any + const route = mock() + route.params.driveAliasAndItem = '1' + route.query.shareId = '1' + + const uppy = mockDeep() + uppy.getState.mockReturnValue(mock({ files: {} })) + + const opts = { + clientService: mockDeep(), + hasSpaces: ref(true), + language: mock(), + route: ref(route), + store, + space: mock(), + uppyService: mock(), + conflictHandlingEnabled, + directoryTreeCreateEnabled, + quotaCheckEnabled + } + + const mocks = { uppy, opts, resourceConflict } + const instance = new HandleUpload(uppy, opts) + return { instance, mocks } +} diff --git a/packages/web-runtime/src/composables/upload/useUpload.ts b/packages/web-runtime/src/composables/upload/useUpload.ts index 85541794f1f..12c211dd4c0 100644 --- a/packages/web-runtime/src/composables/upload/useUpload.ts +++ b/packages/web-runtime/src/composables/upload/useUpload.ts @@ -45,6 +45,9 @@ export interface UppyResource { routeDriveAliasAndItem?: string routeShareId?: string } + tus?: { + endpoint: string + } xhrUpload?: { endpoint: string }