Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(uploader): Allow to specify custom headers #1366

Merged
merged 1 commit into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions __tests__/uploader.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
69 changes: 0 additions & 69 deletions __tests__/utils/uploader.spec.ts

This file was deleted.

37 changes: 34 additions & 3 deletions lib/uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class Uploader {
// Initialized via setter in the constructor
private _destinationFolder!: Folder
private _isPublic: boolean
private _customHeaders: Record<string, string>

// Global upload queue
private _uploadQueue: Array<Upload> = []
Expand All @@ -58,6 +59,7 @@ export class Uploader {
destinationFolder?: Folder,
) {
this._isPublic = isPublic
this._customHeaders = {}

if (!destinationFolder) {
const source = `${davRemoteURL}${davRootPath}`
Expand Down Expand Up @@ -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')
}

Expand All @@ -120,6 +122,30 @@ export class Uploader {
return this._destinationFolder.source
}

/**
* Get registered custom headers for uploads
*/
get customHeaders(): Record<string, string> {
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
*/
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -519,6 +549,7 @@ export class Uploader {
},
undefined,
{
...this._customHeaders,
'X-OC-Mtime': file.lastModified / 1000,
'Content-Type': file.type,
},
Expand Down
9 changes: 5 additions & 4 deletions lib/utils/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Blob>)

Expand Down Expand Up @@ -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),
},
})
}
Expand Down Expand Up @@ -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),
},
})

Expand Down
Loading