diff --git a/__tests__/uploader.spec.ts b/__tests__/uploader.spec.ts new file mode 100644 index 00000000..64d22e7c --- /dev/null +++ b/__tests__/uploader.spec.ts @@ -0,0 +1,116 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { beforeEach, describe, expect, it, test, vi } from 'vitest' +import { Uploader } from '../lib/uploader' +import * as nextcloudAuth from '@nextcloud/auth' +import * as nextcloudFiles from '@nextcloud/files' + +// This mocks auth to always return the `test` user by default +vi.mock('@nextcloud/auth') + +describe('Uploader', () => { + beforeEach(() => { + vi.restoreAllMocks() + // Reset mocks of DOM + document.body.innerHTML = '' + }) + + describe('Constructor', () => { + it('sets default target folder for user', async () => { + const uploader = new Uploader() + expect(uploader.destination.source).match(/\/remote\.php\/dav\/files\/test\/?$/) + }) + + it('sets default target folder for public share', async () => { + // no logged in user + vi.spyOn(nextcloudAuth, 'getCurrentUser').mockImplementationOnce(() => null) + // public share values + vi.spyOn(nextcloudFiles, 'davRemoteURL', 'get').mockReturnValue('http://example.com/public.php/dav') + vi.spyOn(nextcloudFiles, 'davRootPath', 'get').mockReturnValue('/files/share-token') + + const uploader = new Uploader(true) + expect(uploader.destination.source).match(/\/public\.php\/dav\/files\/share-token\/?$/) + expect(uploader.destination.owner).toBe('anonymous') + }) + + it('fails if not logged in and not on public share', async () => { + vi.spyOn(nextcloudAuth, 'getCurrentUser').mockImplementationOnce(() => null) + expect(async () => new Uploader()).rejects.toThrow(/User is not logged in/) + }) + }) + + describe('custom headers', () => { + test('default to none', () => { + const uploader = new Uploader() + expect(uploader.customHeaders).toEqual({}) + }) + + test('can set custom header', () => { + const uploader = new Uploader() + uploader.setCustomHeader('X-NC-Nickname', 'jane') + expect(uploader.customHeaders).toEqual({ 'X-NC-Nickname': 'jane' }) + }) + + test('can unset custom header', () => { + const uploader = new Uploader() + uploader.setCustomHeader('X-NC-Nickname', 'jane') + expect(uploader.customHeaders).toEqual({ 'X-NC-Nickname': 'jane' }) + uploader.deleteCustomerHeader('X-NC-Nickname') + expect(uploader.customHeaders).toEqual({}) + }) + + test('can unset custom header', () => { + const uploader = new Uploader() + uploader.setCustomHeader('X-NC-Nickname', 'jane') + expect(uploader.customHeaders).toEqual({ 'X-NC-Nickname': 'jane' }) + uploader.deleteCustomerHeader('X-NC-Nickname') + expect(uploader.customHeaders).toEqual({}) + }) + + test('can set an empty header', () => { + // This is valid as per RFC7230 + const uploader = new Uploader() + uploader.setCustomHeader('Host', '') + expect(uploader.customHeaders).toEqual({ 'Host': '' }) + }) + }) + + describe('destination', () => { + test('can overwrite the destination', () => { + const uploader = new Uploader() + expect(uploader.destination.path).toBe('/') + + const newDestination = new nextcloudFiles.Folder({ + owner: 'test', + source: 'http://example.com/remote.php/dav/files/test/some/folder', + root: '/files/test', + }) + + expect(() => { uploader.destination = newDestination }).not.toThrow() + expect(uploader.destination.path).toBe('/some/folder') + }) + + test('cannot unset destination', () => { + const uploader = new Uploader() + expect(uploader.destination.path).toBe('/') + + expect(() => { uploader.destination = undefined as any }).toThrowError(/invalid destination/i) + }) + + test('cannot set file as destination', () => { + const uploader = new Uploader() + expect(uploader.destination.path).toBe('/') + + const newDestination = new nextcloudFiles.File({ + owner: 'test', + source: 'http://example.com/remote.php/dav/files/test/some/folder-like', + root: '/files/test', + mime: 'text/plain', + }) + + expect(() => { uploader.destination = newDestination as nextcloudFiles.Folder }).toThrowError(/invalid destination/i) + }) + }) +}) diff --git a/__tests__/utils/uploader.spec.ts b/__tests__/utils/uploader.spec.ts deleted file mode 100644 index 44beb92e..00000000 --- a/__tests__/utils/uploader.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { Uploader } from '../../lib/uploader' -import type { NextcloudUser } from '@nextcloud/auth' -import { beforeEach, describe, expect, test, vi } from 'vitest' - -const auth = vi.hoisted(() => ({ getCurrentUser: vi.fn(() => null) })) -vi.mock('@nextcloud/auth', () => auth) - -describe('uploader', () => { - beforeEach(() => { - vi.resetModules() - vi.resetAllMocks() - // Reset mocks of DOM - document.body.innerHTML = '' - }) - - // wrapper to enforce reimport for mocking different dependency state - const newUploader = async (...args: ConstructorParameters) => { - const { Uploader } = await import('../../lib/uploader.js') - return new Uploader(...args) - } - - test('constructor sets default target folder for user', async () => { - auth.getCurrentUser.mockImplementation(() => ({ uid: 'my-user', displayName: 'User', isAdmin: false })) - const uploader = await newUploader() - expect(uploader.destination.source).match(/\/remote\.php\/dav\/files\/my-user\/?$/) - }) - - test('constructor sets default target folder for public share', async () => { - const isPublicInput = document.createElement('input') - isPublicInput.id = 'initial-state-files_sharing-isPublic' - isPublicInput.value = btoa(JSON.stringify(true)) - document.body.appendChild(isPublicInput) - - const input = document.createElement('input') - input.id = 'initial-state-files_sharing-sharingToken' - input.value = btoa(JSON.stringify('modern-token')) - document.body.appendChild(input) - - const uploader = await newUploader(true) - expect(uploader.destination.source).match(/\/public\.php\/dav\/files\/modern-token\/?$/) - }) - - test('constructor sets default target folder for legacy public share', async () => { - const isPublicInput = document.createElement('input') - isPublicInput.id = 'isPublic' - isPublicInput.name = 'isPublic' - isPublicInput.type = 'hidden' - isPublicInput.value = '1' - document.body.appendChild(isPublicInput) - - const input = document.createElement('input') - input.id = 'sharingToken' - input.name = 'sharingToken' - input.type = 'hidden' - input.value = 'legacy-token' - document.body.appendChild(input) - - const uploader = await newUploader(true) - expect(uploader.destination.source).match(/\/public\.php\/dav\/files\/legacy-token\/?$/) - }) - - test('fails if not logged in and not on public share', async () => { - expect(async () => await newUploader()).rejects.toThrow(/User is not logged in/) - }) -}) diff --git a/lib/uploader.ts b/lib/uploader.ts index a841527a..c5d95012 100644 --- a/lib/uploader.ts +++ b/lib/uploader.ts @@ -37,6 +37,7 @@ export class Uploader { // Initialized via setter in the constructor private _destinationFolder!: Folder private _isPublic: boolean + private _customHeaders: Record // Global upload queue private _uploadQueue: Array = [] @@ -58,6 +59,7 @@ export class Uploader { destinationFolder?: Folder, ) { this._isPublic = isPublic + this._customHeaders = {} if (!destinationFolder) { const source = `${davRemoteURL}${davRootPath}` @@ -105,7 +107,7 @@ export class Uploader { * Set the upload destination path relative to the root folder */ set destination(folder: Folder) { - if (!folder) { + if (!folder || !(folder instanceof Folder)) { throw new Error('Invalid destination folder') } @@ -120,6 +122,30 @@ export class Uploader { return this._destinationFolder.source } + /** + * Get registered custom headers for uploads + */ + get customHeaders(): Record { + return structuredClone(this._customHeaders) + } + + /** + * Set a custom header + * @param name The header to set + * @param value The string value + */ + setCustomHeader(name: string, value: string = ''): void { + this._customHeaders[name] = value + } + + /** + * Unset a custom header + * @param name The header to unset + */ + deleteCustomerHeader(name: string): void { + delete this._customHeaders[name] + } + /** * Get the upload queue */ @@ -216,7 +242,7 @@ export class Uploader { * * async handleConflicts(nodes: File[], currentPath: string) { * const conflicts = getConflicts(nodes, this.fetchContent(currentPath)) - * if (conficts.length === 0) { + * if (conflicts.length === 0) { * // No conflicts so upload all * return nodes * } else { @@ -247,8 +273,10 @@ export class Uploader { upload.status = UploadStatus.UPLOADING this._uploadQueue.push(upload) try { + // setup client with root and custom header + const client = davGetClient(this.root, this._customHeaders) // Create the promise for the virtual root directory - const promise = this.uploadDirectory(destination, rootFolder, callback, davGetClient(this.root)) + const promise = this.uploadDirectory(destination, rootFolder, callback, client) // Make sure to cancel it when requested onCancel(() => promise.cancel()) // await the uploads and resolve with "finished" status @@ -438,6 +466,7 @@ export class Uploader { () => this.updateStats(), encodedDestinationFile, { + ...this._customHeaders, 'X-OC-Mtime': file.lastModified / 1000, 'OC-Total-Length': file.size, 'Content-Type': 'application/octet-stream', @@ -474,6 +503,7 @@ export class Uploader { method: 'MOVE', url: `${tempUrl}/.file`, headers: { + ...this._customHeaders, 'X-OC-Mtime': file.lastModified / 1000, 'OC-Total-Length': file.size, Destination: encodedDestinationFile, @@ -519,6 +549,7 @@ export class Uploader { }, undefined, { + ...this._customHeaders, 'X-OC-Mtime': file.lastModified / 1000, 'Content-Type': file.type, }, diff --git a/lib/utils/upload.ts b/lib/utils/upload.ts index fdbe1ad6..433db526 100644 --- a/lib/utils/upload.ts +++ b/lib/utils/upload.ts @@ -6,8 +6,9 @@ import type { AxiosProgressEvent, AxiosResponse } from 'axios' import { generateRemoteUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' import axios from '@nextcloud/axios' -import axiosRetry from 'axios-retry'; -axiosRetry(axios, { retries: 0 }); +import axiosRetry, { exponentialDelay } from 'axios-retry' + +axiosRetry(axios, { retries: 0 }) type UploadData = Blob | (() => Promise) @@ -59,7 +60,7 @@ export const uploadData = async function( headers, 'axios-retry': { retries, - retryDelay: (retryCount, error) => axiosRetry.exponentialDelay(retryCount, error, 1000), + retryDelay: (retryCount, error) => exponentialDelay(retryCount, error, 1000), }, }) } @@ -97,7 +98,7 @@ export const initChunkWorkspace = async function(destinationFile: string | undef headers, 'axios-retry': { retries, - retryDelay: (retryCount, error) => axiosRetry.exponentialDelay(retryCount, error, 1000), + retryDelay: (retryCount, error) => exponentialDelay(retryCount, error, 1000), }, })