Skip to content
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
4 changes: 4 additions & 0 deletions __mocks__/@nextcloud/auth.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const generateRemoteUrl = (path) => `https://localhost/${path}`
77 changes: 77 additions & 0 deletions __tests__/dav/dav.spec.ts
Original file line number Diff line number Diff line change
@@ -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) => {
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) => {
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)
})
})
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <[email protected]>
*
* @author John Molakvoæ <[email protected]>
* @author Ferdinand Thiessen <[email protected]>
*
* @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, {
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()
// 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> => {
if (options.headers?.method) {
options.method = options.headers.method
delete options.headers.method
}
return request(options)
})
return client
}

/**
* 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)
}
Loading