Skip to content

Commit

Permalink
feat(uploader): Allow to specify custom headers
Browse files Browse the repository at this point in the history
Use case: file-request need to set `X-NC-Nickname` header on upload.

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Sep 4, 2024
1 parent 17f75ac commit e31858e
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 76 deletions.
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

0 comments on commit e31858e

Please sign in to comment.