Skip to content

Commit

Permalink
feat: Add functions for handling WebDAV results
Browse files Browse the repository at this point in the history
Also add missing doc string

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Jul 26, 2023
1 parent 7180e18 commit b2caac4
Show file tree
Hide file tree
Showing 23 changed files with 899 additions and 521 deletions.
4 changes: 4 additions & 0 deletions __mocks__/@nextcloud/auth.js
Expand Up @@ -5,3 +5,7 @@ export const getCurrentUser = function() {
isAdmin: false,
}
}

export const getRequestToken = function() {
return 'some-token-string'
}
1 change: 1 addition & 0 deletions __mocks__/@nextcloud/router.js
@@ -0,0 +1 @@
export const generateRemoteUrl = (path) => `https://localhost/${path}`
77 changes: 77 additions & 0 deletions __tests__/dav/dav.spec.ts
@@ -0,0 +1,77 @@
import { afterAll, describe, expect, test, vi } from 'vitest'
import { readFile } from 'fs/promises'

import { File, Folder, davDefaultRootUrl, davGetDefaultPropfind, davGetFavoritesReport, davRootPath, getFavoriteNodes } from '../../lib'

vi.mock('@nextcloud/auth')
vi.mock('@nextcloud/router')

afterAll(() => {
vi.resetAllMocks()
})

describe('DAV functions', () => {
test('root path is correct', () => {
expect(davRootPath).toBe('/files/test')
})

test('root url is correct', () => {
expect(davDefaultRootUrl).toBe('https://localhost/dav/files/test')
})
})

describe('DAV requests', () => {
test('request all favorite files', async () => {
const favoritesResponseJSON = JSON.parse((await readFile(new URL('../fixtures/favorites-response.json', import.meta.url))).toString())

// Mock the WebDAV client
const client = {
getDirectoryContents: vi.fn((path: string, options: any) => {

Check warning on line 29 in __tests__/dav/dav.spec.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type
if (options?.details) {
return {
data: favoritesResponseJSON,
}
}
return favoritesResponseJSON
}),
}

const nodes = await getFavoriteNodes(client as never)
// Check client was called correctly
expect(client.getDirectoryContents).toBeCalled()
expect(client.getDirectoryContents.mock.lastCall?.at(0)).toBe('/')
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.data).toBe(davGetFavoritesReport())
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.headers?.method).toBe('REPORT')
// Check for correct output
expect(nodes.length).toBe(2)
expect(nodes[0] instanceof Folder).toBe(true)
expect(nodes[0].basename).toBe('Neuer Ordner')
expect(nodes[0].mtime?.getTime()).toBe(Date.parse('Mon, 24 Jul 2023 16:30:44 GMT'))
expect(nodes[1] instanceof File).toBe(true)
})

test('request inner favorites', async () => {
const favoritesResponseJSON = JSON.parse((await readFile(new URL('../fixtures/favorites-inner-response.json', import.meta.url))).toString())

// Mock the WebDAV client
const client = {
getDirectoryContents: vi.fn((path: string, options: any) => {

Check warning on line 58 in __tests__/dav/dav.spec.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected any. Specify a different type
if (options?.details) {
return {
data: favoritesResponseJSON,
}
}
return favoritesResponseJSON
}),
}

const nodes = await getFavoriteNodes(client as never, '/Neuer Ordner')
// Check client was called correctly
expect(client.getDirectoryContents).toBeCalled()
expect(client.getDirectoryContents.mock.lastCall?.at(0)).toBe('/Neuer Ordner')
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.data).toBe(davGetDefaultPropfind())
expect(client.getDirectoryContents.mock.lastCall?.at(1)?.headers?.method).toBe('PROPFIND')
// There are no inner nodes
expect(nodes.length).toBe(0)
})
})
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'

import { parseWebdavPermissions, Permission } from '../lib/permissions'
import { davParsePermissions } from '../../lib/dav/davPermissions'
import { Permission } from '../../lib/permissions'

const dataSet = [
{ input: undefined, permissions: Permission.NONE },
Expand All @@ -21,11 +22,11 @@ const dataSet = [
{ input: 'RGDNVCK', permissions: Permission.UPDATE | Permission.READ | Permission.DELETE | Permission.CREATE | Permission.SHARE },
]

describe('parseWebdavPermissions', () => {
describe('davParsePermissions', () => {
dataSet.forEach(({ input, permissions }) => {
it(`expect ${input} to be ${permissions}`, () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(parseWebdavPermissions(input as any as string)).toBe(permissions)
expect(davParsePermissions(input as any as string)).toBe(permissions)
})
})
})
98 changes: 98 additions & 0 deletions __tests__/dav/davProperties.spec.ts
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { XMLValidator } from 'fast-xml-parser'

import {
davGetDefaultPropfind,
davGetFavoritesReport,
getDavNameSpaces,
getDavProperties,
registerDavProperty,
defaultDavNamespaces,
defaultDavProperties,
} from '../../lib/dav/davProperties'

import logger from '../../lib/utils/logger'

describe('DAV Properties', () => {

beforeEach(() => {
delete window._nc_dav_properties
delete window._nc_dav_namespaces
})

test('getDavNameSpaces fall back to defaults', () => {
expect(window._nc_dav_namespaces).toBeUndefined()
const namespace = getDavNameSpaces()
expect(namespace).toBeTruthy()
Object.keys(defaultDavNamespaces).forEach(n => expect(namespace.includes(n) && namespace.includes(defaultDavNamespaces[n])).toBe(true))
})

test('getDavProperties fall back to defaults', () => {
expect(window._nc_dav_properties).toBeUndefined()
const props = getDavProperties()
expect(props).toBeTruthy()
defaultDavProperties.forEach(p => expect(props.includes(p)).toBe(true))
})

test('davGetDefaultPropfind', () => {
expect(typeof davGetDefaultPropfind()).toBe('string')
expect(XMLValidator.validate(davGetDefaultPropfind())).toBe(true)
})

test('davGetFavoritesReport', () => {
expect(typeof davGetFavoritesReport()).toBe('string')
expect(XMLValidator.validate(davGetFavoritesReport())).toBe(true)
})

test('registerDavProperty registers successfully', () => {
logger.error = vi.fn()

expect(window._nc_dav_namespaces).toBeUndefined()
expect(window._nc_dav_properties).toBeUndefined()

expect(registerDavProperty('my:prop', { my: 'https://example.com/ns' })).toBe(true)
expect(logger.error).not.toBeCalled()
expect(getDavProperties().includes('my:prop')).toBe(true)
expect(getDavNameSpaces().includes('xmlns:my="https://example.com/ns"')).toBe(true)
})

test('registerDavProperty fails when registered multipletimes', () => {
logger.error = vi.fn()

expect(window._nc_dav_namespaces).toBeUndefined()
expect(window._nc_dav_properties).toBeUndefined()

expect(registerDavProperty('my:prop', { my: 'https://example.com/ns' })).toBe(true)
expect(registerDavProperty('my:prop')).toBe(false)
expect(logger.error).toBeCalled()
// but still included
expect(getDavProperties().includes('my:prop')).toBe(true)
expect(getDavNameSpaces().includes('xmlns:my="https://example.com/ns"')).toBe(true)
})

test('registerDavProperty fails with invalid props', () => {
logger.error = vi.fn()

expect(window._nc_dav_namespaces).toBeUndefined()
expect(window._nc_dav_properties).toBeUndefined()

expect(registerDavProperty('my:prop:invalid', { my: 'https://example.com/ns' })).toBe(false)
expect(logger.error).toBeCalled()
expect(getDavProperties().includes('my:prop')).toBe(false)

expect(registerDavProperty('<my:prop />', { my: 'https://example.com/ns' })).toBe(false)
expect(logger.error).toBeCalled()
expect(getDavProperties().includes('my:prop')).toBe(false)
})

test('registerDavProperty fails with missing namespace', () => {
logger.error = vi.fn()

expect(window._nc_dav_namespaces).toBeUndefined()
expect(window._nc_dav_properties).toBeUndefined()

expect(registerDavProperty('my:prop', { other: 'https://example.com/ns' })).toBe(false)
expect(logger.error).toBeCalled()
expect(getDavProperties().includes('my:prop')).toBe(false)
})
})
2 changes: 1 addition & 1 deletion __tests__/files/node.spec.ts
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest'

import { File } from '../../lib/files/file'
import { Folder } from '../../lib/files/folder'
import NodeData, { Attribute } from '../../lib/files/nodeData'
import { Attribute, NodeData } from '../../lib/files/nodeData'
import { Permission } from '../../lib/permissions'

describe('Node testing', () => {
Expand Down
1 change: 1 addition & 0 deletions __tests__/fixtures/favorites-inner-response.json
@@ -0,0 +1 @@
[{"filename":"/Neuer Ordner","basename":"Neuer Ordner","lastmod":"Mon, 24 Jul 2023 16:30:44 GMT","size":0,"type":"directory","etag":"64bea734d3987","props":{"getetag":"\"64bea734d3987\"","getlastmodified":"Mon, 24 Jul 2023 16:30:44 GMT","quota-available-bytes":-3,"resourcetype":{"collection":""},"has-preview":false,"is-encrypted":0,"mount-type":"","share-attributes":"[]","comments-unread":0,"favorite":1,"fileid":74,"owner-display-name":"user1","owner-id":"user1","permissions":"RGDNVCK","share-types":{"share-type":3},"size":0,"share-permissions":31}}]
2 changes: 2 additions & 0 deletions __tests__/fixtures/favorites-inner-response.xml
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:response><d:href>/remote.php/dav/files/user1/Neuer%20Ordner/</d:href><d:propstat><d:prop><d:getetag>&quot;64bea734d3987&quot;</d:getetag><d:getlastmodified>Mon, 24 Jul 2023 16:30:44 GMT</d:getlastmodified><d:quota-available-bytes>-3</d:quota-available-bytes><d:resourcetype><d:collection/></d:resourcetype><nc:has-preview>false</nc:has-preview><nc:is-encrypted>0</nc:is-encrypted><nc:mount-type></nc:mount-type><nc:share-attributes>[]</nc:share-attributes><oc:comments-unread>0</oc:comments-unread><oc:favorite>1</oc:favorite><oc:fileid>74</oc:fileid><oc:owner-display-name>user1</oc:owner-display-name><oc:owner-id>user1</oc:owner-id><oc:permissions>RGDNVCK</oc:permissions><oc:share-types><oc:share-type>3</oc:share-type></oc:share-types><oc:size>0</oc:size><x1:share-permissions xmlns:x1="http://open-collaboration-services.org/ns">31</x1:share-permissions></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><d:getcontentlength/><d:getcontenttype/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>
1 change: 1 addition & 0 deletions __tests__/fixtures/favorites-response.json
@@ -0,0 +1 @@
[{"filename":"/Neuer Ordner","basename":"Neuer Ordner","lastmod":"Mon, 24 Jul 2023 16:30:44 GMT","size":0,"type":"directory","etag":"64bea734d3987","props":{"getetag":"\"64bea734d3987\"","getlastmodified":"Mon, 24 Jul 2023 16:30:44 GMT","quota-available-bytes":-3,"resourcetype":{"collection":""},"has-preview":false,"is-encrypted":0,"mount-type":"","share-attributes":"[]","comments-unread":0,"favorite":1,"fileid":74,"owner-display-name":"user1","owner-id":"user1","permissions":"RGDNVCK","share-types":{"share-type":3},"size":0,"share-permissions":31}},{"filename":"/New folder/Neue Textdatei.md","basename":"Neue Textdatei.md","lastmod":"Tue, 25 Jul 2023 12:29:34 GMT","size":0,"type":"file","etag":"7a27142de0a62ed27a7293dbc16e93bc","mime":"text/markdown","props":{"getcontentlength":0,"getcontenttype":"text/markdown","getetag":"\"7a27142de0a62ed27a7293dbc16e93bc\"","getlastmodified":"Tue, 25 Jul 2023 12:29:34 GMT","resourcetype":"","has-preview":false,"mount-type":"shared","share-attributes":"[{\"scope\":\"permissions\",\"key\":\"download\",\"enabled\":false}]","comments-unread":0,"favorite":1,"fileid":80,"owner-display-name":"admin","owner-id":"admin","permissions":"SRGDNVW","share-types":"","size":0,"share-permissions":19}}]
2 changes: 2 additions & 0 deletions __tests__/fixtures/favorites-response.xml
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:response><d:href>/remote.php/dav/files/user1/</d:href><d:propstat><d:prop><d:getetag>&quot;632a3876842ffbf86f9e02df59829a56&quot;</d:getetag><d:getlastmodified>Tue, 25 Jul 2023 12:29:34 GMT</d:getlastmodified><d:quota-available-bytes>-3</d:quota-available-bytes><d:resourcetype><d:collection/></d:resourcetype><nc:has-preview>false</nc:has-preview><nc:is-encrypted>0</nc:is-encrypted><nc:mount-type></nc:mount-type><nc:share-attributes>[]</nc:share-attributes><oc:comments-unread>0</oc:comments-unread><oc:favorite>0</oc:favorite><oc:fileid>57</oc:fileid><oc:owner-display-name>user1</oc:owner-display-name><oc:owner-id>user1</oc:owner-id><oc:permissions>RGDNVCK</oc:permissions><oc:share-types/><oc:size>171</oc:size><x1:share-permissions xmlns:x1="http://open-collaboration-services.org/ns">31</x1:share-permissions></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat><d:propstat><d:prop><d:getcontentlength/><d:getcontenttype/></d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response></d:multistatus>
8 changes: 4 additions & 4 deletions __tests__/index.spec.ts
Expand Up @@ -10,7 +10,7 @@ import {
Folder,
Node,
Permission,
parseWebdavPermissions,
davParsePermissions,
} from '../lib/index'

import { Entry, NewFileMenu } from '../lib/newFileMenu'
Expand Down Expand Up @@ -47,9 +47,9 @@ describe('Exports checks', () => {
expect(typeof Permission).toBe('object')
})

test('parseWebdavPermissions', () => {
expect(parseWebdavPermissions).toBeTruthy()
expect(typeof parseWebdavPermissions).toBe('function')
test('davParsePermissions', () => {
expect(davParsePermissions).toBeTruthy()
expect(typeof davParsePermissions).toBe('function')
})

test('File', () => {
Expand Down
131 changes: 131 additions & 0 deletions lib/dav/dav.ts
@@ -0,0 +1,131 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { DAVResultResponseProps, FileStat, Response, ResponseDataDetailed, WebDAVClient } from 'webdav'
import type { Node } from '../files/node'

import { File } from '../files/file'
import { Folder } from '../files/folder'
import { NodeData } from '../files/nodeData'
import { davParsePermissions } from './davPermissions'
import { davGetDefaultPropfind, davGetFavoritesReport } from './davProperties'

import { getCurrentUser, getRequestToken } from '@nextcloud/auth'
import { generateRemoteUrl } from '@nextcloud/router'
import { createClient, getPatcher, RequestOptions } from 'webdav'
import { request } from 'webdav/dist/node/request.js'

/**
* Nextcloud DAV result response
*/
interface ResponseProps extends DAVResultResponseProps {
permissions: string
fileid: number
size: number
}

export const davRootPath = `/files/${getCurrentUser()?.uid}`
export const davDefaultRootUrl = generateRemoteUrl('dav' + davRootPath)

/**
* Get a WebDAV client configured to include the Nextcloud request token
*
* @param davURL The DAV root URL
*/
export const davGetClient = function(davURL = davDefaultRootUrl) {
const client = createClient(davURL, {

Check warning on line 55 in lib/dav/dav.ts

View check run for this annotation

Codecov / codecov/patch

lib/dav/dav.ts#L55

Added line #L55 was not covered by tests
headers: {
requesttoken: getRequestToken() || '',
},
})

/**
* Allow to override the METHOD to support dav REPORT
*
* @see https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/request.ts
*/
const patcher = getPatcher()

Check warning on line 66 in lib/dav/dav.ts

View check run for this annotation

Codecov / codecov/patch

lib/dav/dav.ts#L66

Added line #L66 was not covered by tests
// https://github.com/perry-mitchell/hot-patcher/issues/6
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
patcher.patch('request', (options: RequestOptions): Promise<Response> => {

Check warning on line 70 in lib/dav/dav.ts

View check run for this annotation

Codecov / codecov/patch

lib/dav/dav.ts#L70

Added line #L70 was not covered by tests
if (options.headers?.method) {
options.method = options.headers.method
delete options.headers.method

Check warning on line 73 in lib/dav/dav.ts

View check run for this annotation

Codecov / codecov/patch

lib/dav/dav.ts#L72-L73

Added lines #L72 - L73 were not covered by tests
}
return request(options)

Check warning on line 75 in lib/dav/dav.ts

View check run for this annotation

Codecov / codecov/patch

lib/dav/dav.ts#L75

Added line #L75 was not covered by tests
})
return client

Check warning on line 77 in lib/dav/dav.ts

View check run for this annotation

Codecov / codecov/patch

lib/dav/dav.ts#L77

Added line #L77 was not covered by tests
}

/**
* Use WebDAV to query for favorite Nodes
*
* @param davClient The WebDAV client to use for performing the request
* @param path Base path for the favorites, if unset all favorites are queried
*/
export const getFavoriteNodes = async (davClient: WebDAVClient, path = '/') => {
const contentsResponse = await davClient.getDirectoryContents(path, {
details: true,
// Only filter favorites if we're at the root
data: path === '/' ? davGetFavoritesReport() : davGetDefaultPropfind(),
headers: {
// Patched in WebdavClient.ts
method: path === '/' ? 'REPORT' : 'PROPFIND',
},
includeSelf: true,
}) as ResponseDataDetailed<FileStat[]>

return contentsResponse.data.filter(node => node.filename !== path).map((result) => davResultToNode(result))
}

/**
* Covert DAV result `FileStat` to `Node`
*
* @param node The DAV result
* @param davRoot The DAV root path
*/
export const davResultToNode = function(node: FileStat, davRoot = davRootPath): Node {
const props = node.props as ResponseProps
const permissions = davParsePermissions(props?.permissions)
const owner = getCurrentUser()?.uid as string

const nodeData: NodeData = {
id: (props?.fileid as number) || 0,
source: generateRemoteUrl(`dav${davRoot}${node.filename}`),
mtime: new Date(Date.parse(node.lastmod)),
mime: node.mime as string,
size: (props?.size as number) || 0,
permissions,
owner,
root: davRoot,
attributes: {
...node,
...props,
hasPreview: props?.['has-preview'],
},
}

delete nodeData.attributes?.props

return node.type === 'file' ? new File(nodeData) : new Folder(nodeData)
}

0 comments on commit b2caac4

Please sign in to comment.