Skip to content
Draft
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
67 changes: 65 additions & 2 deletions __tests__/component/Viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
vi.mock('@nextcloud/event-bus')
// The viewer's own Delete goes through it
const axiosDelete = vi.hoisted(() => vi.fn(async () => ({})))
vi.mock('@nextcloud/axios', () => ({ default: { delete: axiosDelete } }))
// What a share is, asked for a file given by its link
const axiosRequest = vi.hoisted(() => vi.fn())
vi.mock('@nextcloud/axios', () => ({ default: { delete: axiosDelete, request: axiosRequest } }))
const showError = vi.hoisted(() => vi.fn())
vi.mock('@nextcloud/dialogs', async (importOriginal) => ({
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- vitest importOriginal idiom
Expand All @@ -32,7 +34,7 @@ vi.mock('@nextcloud/image-editor', async () => {
})

import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Permission, registerFileAction } from '@nextcloud/files'
import { File, Permission, registerFileAction } from '@nextcloud/files'
import { useViewerSettings } from '../../lib/composables/useViewerSettings.ts'
import { restoreTitle } from '../../lib/utils/documentTitle.ts'
import { getViewer } from '../../lib/viewer.ts'
Expand Down Expand Up @@ -1899,3 +1901,64 @@ describe('the viewer\'s own Download and Delete', () => {
expect(axiosDelete).not.toHaveBeenCalled()
})
})

// Talk hands a guest the files of a conversation by their share's link: in a
// folder share, the link downloads the whole folder as a zip
describe('a file given by its share\'s link', () => {
/**
* Open a file of a share by its link
*
* @param token - The share's, each test its own as what a share is is kept
* @param folder - Whether the share is a folder holding the file
*/
async function openLinked(token: string, folder: boolean) {
axiosRequest.mockResolvedValueOnce({ data: `<d:multistatus xmlns:d="DAV:"><d:response><d:propstat><d:prop><d:resourcetype>${folder ? '<d:collection/>' : ''}</d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>` })
// An element that keeps what it is given, to read it back
const { vm, wrapper } = mountViewer([makeHandler({ id: 'compared', tagName: 'oca-viewer-compared', enabled: () => true })])
const file = new File({
source: `https://cloud.example.com/index.php/s/${token}/download/beach.jpg`,
root: `/index.php/s/${token}`,
mime: 'image/jpeg',
owner: null,
})
await vm.open([file], file)
await flushPromises()
return { file, wrapper }
}

it('is shown from its address in the share', async () => {
const { wrapper } = await openLinked('shown1', true)

const shown = (wrapper.find('oca-viewer-compared').element as ComparedElement).file
expect(shown?.source).toBe('https://cloud.example.com/public.php/dav/files/shown1/beach.jpg')
})

it('is not shown over a file opened while its share was asked', async () => {
const answer = Promise.withResolvers<{ data: string }>()
axiosRequest.mockReturnValueOnce(answer.promise)
const { vm, wrapper } = mountViewer([makeHandler({ id: 'compared', tagName: 'oca-viewer-compared', enabled: () => true })])
const linked = new File({ source: 'https://cloud.example.com/index.php/s/slow1/download/a.jpg', root: '/index.php/s/slow1', mime: 'image/jpeg', owner: null })
const other = makeFile({ basename: 'b.jpg' })
await vm.open([linked, other], linked)
await vm.open([linked, other], other)
await flushPromises()

answer.resolve({ data: '<d:multistatus xmlns:d="DAV:"/>' })
await flushPromises()

expect((wrapper.find('oca-viewer-compared').element as ComparedElement).file?.source).toBe(other.source)
})

// The address of a file shared alone is the share's, named by its token
it('downloads from there, under its own name', async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const { wrapper } = await openLinked('download1', false)

await wrapper.findAll('.nc-action-button-stub').find((button) => button.text() === 'Download')!.trigger('click')
await flushPromises()

const link = click.mock.contexts[0] as HTMLAnchorElement
expect(link.href).toBe('https://cloud.example.com/public.php/dav/files/download1')
expect(link.download).toBe('beach.jpg')
})
})
96 changes: 94 additions & 2 deletions __tests__/publicShare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { File } from '@nextcloud/files'
import { describe, expect, it } from 'vitest'
import { publicShareOf } from '../lib/utils/publicShare.ts'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { request } = vi.hoisted(() => ({ request: vi.fn() }))
vi.mock('@nextcloud/axios', () => ({ default: { request } }))

const { isShareLink, publicShareOf, resolveShareLink } = await import('../lib/utils/publicShare.ts')

/**
* A file at an address, as an app hands it to the viewer
Expand Down Expand Up @@ -70,3 +74,91 @@ describe('the public share a file is in, from its link', () => {
expect(publicShareOf(file('https://cloud.example.com/index.php/s/abc123/download/beach%20day.jpg', '/index.php/s/abc123'))?.path).toBe('/beach day.jpg')
})
})

describe('whether a file is given by its share\'s link', () => {
it('is for a link', () => {
expect(isShareLink(file('https://cloud.example.com/index.php/s/abc123/download/beach.jpg', '/index.php/s/abc123'))).toBe(true)
})

it.each([
['a WebDAV address of a share', 'https://cloud.example.com/public.php/dav/files/abc123/beach.jpg', '/files/abc123'],
['a user\'s file', 'https://cloud.example.com/remote.php/dav/files/alice/beach.jpg', '/files/alice'],
// /s/ further down a WebDAV path is a folder named s
['a folder named s in a share', 'https://cloud.example.com/public.php/dav/files/abc123/s/x', '/files/abc123'],
])('is not for %s', (_, source, root) => {
expect(isShareLink(file(source, root))).toBe(false)
})
})

/**
* The share answers a depth 0 PROPFIND on its root
*
* @param collection - Whether it is a folder
*/
function shareIs(collection: boolean): void {
request.mockResolvedValueOnce({
data: `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/public.php/dav/files/x</d:href><d:propstat><d:prop><d:resourcetype>${collection ? '<d:collection/>' : ''}</d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`,
})
}

// Each test uses its own token, as what a share is is kept for the page
describe('a file given by its share\'s link, at its WebDAV address', () => {
beforeEach(() => {
request.mockReset()
})

it('is the file in the share for a folder share', async () => {
shareIs(true)
const resolved = await resolveShareLink(file('https://cloud.example.com/index.php/s/folder1/download/beach.jpg?path=/holiday', '/index.php/s/folder1'))
expect(resolved.source).toBe('https://cloud.example.com/public.php/dav/files/folder1/holiday/beach.jpg')
expect(resolved.root).toBe('/files/folder1')
expect(request).toHaveBeenCalledWith(expect.objectContaining({
method: 'PROPFIND',
url: 'https://cloud.example.com/public.php/dav/files/folder1',
headers: { Depth: '0' },
}))
})

it('is the share itself for a file shared alone', async () => {
shareIs(false)
const resolved = await resolveShareLink(file('https://cloud.example.com/index.php/s/single1/download/beach.jpg', '/index.php/s/single1'))
expect(resolved.source).toBe('https://cloud.example.com/public.php/dav/files/single1')
})

it('keeps what is known of the file', async () => {
shareIs(true)
const linked = new File({
id: 42,
source: 'https://cloud.example.com/index.php/s/keep1/download/beach.jpg',
root: '/index.php/s/keep1',
displayname: 'Beach',
mime: 'image/jpeg',
owner: null,
size: 1234,
attributes: { 'has-preview': false },
})
const resolved = await resolveShareLink(linked)
expect(resolved).toMatchObject({ fileid: 42, displayname: 'Beach', mime: 'image/jpeg', size: 1234 })
expect(resolved.attributes['has-preview']).toBe(false)
})

it('asks each share once', async () => {
shareIs(true)
await resolveShareLink(file('https://cloud.example.com/index.php/s/once1/download/a.jpg', '/index.php/s/once1'))
const resolved = await resolveShareLink(file('https://cloud.example.com/index.php/s/once1/download/b.jpg?path=/sub', '/index.php/s/once1'))
expect(request).toHaveBeenCalledOnce()
expect(resolved.source).toBe('https://cloud.example.com/public.php/dav/files/once1/sub/b.jpg')
})

it('is the link still when the share does not answer', async () => {
request.mockRejectedValueOnce(new Error('401'))
const linked = file('https://cloud.example.com/index.php/s/locked1/download/a.jpg', '/index.php/s/locked1')
expect(await resolveShareLink(linked)).toBe(linked)
})

it('is any other file as it is, without asking', async () => {
const dav = file('https://cloud.example.com/public.php/dav/files/abc123/a.jpg', '/files/abc123')
expect(await resolveShareLink(dav)).toBe(dav)
expect(request).not.toHaveBeenCalled()
})
})
29 changes: 29 additions & 0 deletions e2e/previews.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,35 @@ test.describe('Previews', () => {
expect(url.searchParams.get('file')).toBe('/holiday/shared-link.jpg')
})

// Through the link, a file of a folder share downloads as a zip of the folder
test('loads a guest the file a link points into from its WebDAV address', async ({ page }) => {
const links: string[] = []
await page.route('**/s/origtoken/**', async (route) => {
links.push(route.request().url())
await route.fulfill({ status: 404 })
})
await page.route('**/public.php/dav/files/origtoken**', async (route) => {
if (route.request().method() === 'PROPFIND') {
await route.fulfill({
status: 207,
contentType: 'application/xml',
body: '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/public.php/dav/files/origtoken/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>',
})
return
}
await route.fulfill({ contentType: 'image/jpeg', body: IMAGE })
})

const viewer = new ViewerPage(page)
await viewer.open('shared-original.jpg', 'shared&guest')
await viewer.waitForOpen()
const image = viewer.container.locator('img').first()
await expect(image).toBeVisible()

expect(new URL(await image.getAttribute('src') ?? '', page.url()).pathname).toBe('/public.php/dav/files/origtoken/holiday/shared-original.jpg')
expect(links).toEqual([])
})

test('asks for the preview by hand when the share forbids downloading', async ({ page }) => {
// The server refuses a plain request for the preview of a share that
// cannot be downloaded, and serves it when the request says it comes
Expand Down
78 changes: 77 additions & 1 deletion lib/utils/publicShare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { INode } from '@nextcloud/files'
import type { IFile, INode } from '@nextcloud/files'

import axios from '@nextcloud/axios'
import { File } from '@nextcloud/files'

/** The public share a node is reached through */
export interface PublicShare {
Expand Down Expand Up @@ -66,3 +69,76 @@ function parse(address: string): URL | undefined {
return undefined
}
}

/**
* Whether a file is given by its share's link, `/s/<token>鈥, rather than by
* an address it can be fetched at as it is.
*
* @param file - The file
*/
export function isShareLink(file: INode): boolean {
return !PUBLIC_DAV_PATH.test(parse(file.encodedSource)?.pathname ?? '')
&& SHARE_LINK_PATH.test(parse(file.source)?.pathname ?? '')
}

// Whether each share is a file or a folder, by token, asked once a page
const shareKinds = new Map<string, Promise<'file' | 'folder' | undefined>>()

/**
* Ask a share whether it is a file or a folder, which its link does not say.
*
* @param share - The share
*/
function shareKind(share: PublicShare): Promise<'file' | 'folder' | undefined> {
let kind = shareKinds.get(share.token)
if (kind === undefined) {
kind = axios.request({
method: 'PROPFIND',
url: `${share.remoteURL}/files/${encodeURIComponent(share.token)}`,
headers: { Depth: '0' },
data: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
responseType: 'text',
}).then(({ data }) => {
const xml = new DOMParser().parseFromString(String(data), 'application/xml')
return xml.getElementsByTagNameNS('DAV:', 'collection').length > 0 ? 'folder' as const : 'file' as const
}).catch(() => undefined)
shareKinds.set(share.token, kind)
}
return kind
}

/**
* A file given by its share's link, `/s/<token>鈥, at its WebDAV address
* instead, which the share is asked for once: the link cannot say whether
* the share is the file itself, whose address is the share's, or a folder
* holding it, where the file is found by its path. Through the link, the
* file of a folder share downloads as a zip of the whole folder.
*
* Any other file comes back as it is, and so does one whose share does not
* answer, for a password say.
*
* @param file - The file
*/
export async function resolveShareLink(file: IFile): Promise<IFile> {
const share = publicShareOf(file)
if (share === undefined || !isShareLink(file)) {
return file
}
const kind = await shareKind(share)
if (kind === undefined) {
return file
}
const root = `/files/${share.token}`
return new File({
id: file.fileid,
source: `${share.remoteURL}${root}${kind === 'folder' && share.path !== '/' ? share.path : ''}`,
root,
displayname: file.displayname,
mime: file.mime,
owner: file.owner,
permissions: file.permissions,
size: file.size,
mtime: file.mtime,
attributes: { ...file.attributes },
})
}
Loading
Loading