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

Add DAV functions for fetching nodes from Nextcloud #706

Merged
merged 1 commit into from Jul 26, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)
}