Skip to content
Open
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
64 changes: 63 additions & 1 deletion __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { makeFile } from '../factories.ts'
// media components perform, so we replace it with a deterministic fake blob URL.
vi.mock('../../lib/services/mediaPreloader.ts', () => ({
preloadMedia: vi.fn(async () => 'blob:mock-preloaded-media'),
preloadPreview: vi.fn(async () => 'blob:mock-preloaded-preview'),
}))

// An svg is read and sanitized rather than handed to the element, so the
Expand Down Expand Up @@ -93,9 +94,10 @@ import Images from '../../lib/components/Images.vue'
import Videos from '../../lib/components/Videos.vue'
import { usePlyrPlayer } from '../../lib/composables/usePlyrPlayer.ts'
import { logger } from '../../lib/services/logger.ts'
import { preloadMedia } from '../../lib/services/mediaPreloader.ts'
import { preloadMedia, preloadPreview } from '../../lib/services/mediaPreloader.ts'

const preloadMediaMock = vi.mocked(preloadMedia)
const preloadPreviewMock = vi.mocked(preloadPreview)

/**
* Build the full ViewerProps set with sensible defaults for a mounted media component.
Expand Down Expand Up @@ -128,6 +130,7 @@ function mountImages(overrides: Partial<ViewerProps> = {}) {

beforeEach(() => {
preloadMediaMock.mockClear()
preloadPreviewMock.mockClear()
})

describe('Images.vue', () => {
Expand Down Expand Up @@ -184,6 +187,65 @@ describe('Images.vue', () => {
expect(wrapper.emitted('errored')).toBeUndefined()
})

it('asks for the preview by hand when the share forbids downloading', async () => {
// The share refuses the file itself, so fetching it would fail the
// same way the element's own request just did. Only the preview is
// still available, and only to a request that carries the header.
const file = makeFile({
basename: 'restricted.jpg',
attributes: { hasPreview: true, hideDownload: true },
})
const wrapper = mountImages({ file, files: [file] })
await flushPromises()

await wrapper.find('img').trigger('error')
await flushPromises()

expect(preloadPreviewMock).toHaveBeenCalledTimes(1)
expect(preloadPreviewMock.mock.calls[0]![0]).toContain('/core/preview')
expect(preloadMediaMock).not.toHaveBeenCalled()
expect(wrapper.find('img').attributes('src')).toBe('blob:mock-preloaded-preview')
expect(wrapper.emitted('errored')).toBeUndefined()
})

it('releases the blob it fetched when the viewer closes', async () => {
// An object URL holds its blob until it is revoked, so a folder of
// these would otherwise stay in memory for as long as the viewer is
// open
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const file = makeFile({ basename: 'broken.jpg' })
const wrapper = mountImages({ file, files: [file] })
await flushPromises()
await wrapper.find('img').trigger('error')
await flushPromises()

wrapper.unmount()

expect(revoke).toHaveBeenCalledWith('blob:mock-preloaded-media')
revoke.mockRestore()
})

it('releases the previous blob when it fetches another', async () => {
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
const first = makeFile({ basename: 'first.jpg' })
const second = makeFile({ basename: 'second.jpg' })
const wrapper = mountImages({ file: first, files: [first, second] })
await flushPromises()
await wrapper.find('img').trigger('error')
await flushPromises()
expect(revoke).not.toHaveBeenCalled()

preloadMediaMock.mockResolvedValueOnce('blob:second-media')
await wrapper.setProps({ file: second })
await flushPromises()
await wrapper.find('img').trigger('error')
await flushPromises()

expect(revoke).toHaveBeenCalledWith('blob:mock-preloaded-media')
revoke.mockRestore()
wrapper.unmount()
})

it('falls back for a file whose preview fails to load', async () => {
const file = makeFile({ basename: 'previewed.jpg', attributes: { hasPreview: true } })
const wrapper = mountImages({ file, files: [file] })
Expand Down
53 changes: 53 additions & 0 deletions __tests__/mediaPreloader.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const axiosGet = vi.hoisted(() => vi.fn())
vi.mock('@nextcloud/axios', () => ({ default: { get: axiosGet } }))
vi.mock('@nextcloud/files/dav', () => ({ getClient: () => ({ getFileContents: vi.fn() }) }))

const { preloadPreview } = await import('../lib/services/mediaPreloader.ts')

describe('preloadPreview', () => {
beforeEach(() => {
axiosGet.mockReset()
axiosGet.mockResolvedValue({ data: new Blob(['x'], { type: 'image/jpeg' }) })
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview')
})

it('says the request comes from the viewer', async () => {
// Without this the server refuses the preview of a share that may
// not be downloaded, and the element has nothing to show
await preloadPreview('/core/preview?fileId=1')

expect(axiosGet).toHaveBeenCalledWith(
'/core/preview?fileId=1',
expect.objectContaining({ headers: { 'x-nc-preview': 'true' } }),
)
})

it('asks for the bytes rather than a parsed body', async () => {
await preloadPreview('/core/preview?fileId=1')

expect(axiosGet.mock.calls[0]![1].responseType).toBe('blob')
})

it('hands back something an element can be pointed at', async () => {
expect(await preloadPreview('/core/preview?fileId=1')).toBe('blob:preview')
})

it('drops the request when the viewer moves on', async () => {
const controller = new AbortController()
await preloadPreview('/core/preview?fileId=1', controller.signal)

expect(axiosGet.mock.calls[0]![1].signal).toBe(controller.signal)
})

it('lets a failure reach the caller, which reports it', async () => {
axiosGet.mockRejectedValue(new Error('forbidden'))

await expect(preloadPreview('/core/preview?fileId=1')).rejects.toThrow('forbidden')
})
})
36 changes: 36 additions & 0 deletions e2e/previews.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,42 @@ test.describe('Previews', () => {
expect(url.searchParams.get('etag')).toBe('etag-2')
})

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
// from the viewer. An element cannot set that header on its own
// request, so a refusal here has to be answered by fetching it.
const headers: Array<string | undefined> = []
await page.route('**/core/preview*', async (route) => {
const header = route.request().headers()['x-nc-preview']
headers.push(header)
if (header !== 'true') {
await route.fulfill({ status: 403, contentType: 'text/plain', body: 'Forbidden' })
return
}
await route.fulfill({ contentType: 'image/jpeg', body: IMAGE })
})

const viewer = new ViewerPage(page)
await viewer.open('restricted.jpg', 'previews')
await viewer.waitForOpen()

// The picture is on screen, which it could not be without the retry
const image = viewer.container.locator('img').first()
await expect(image).toBeVisible()
await expect(async () => {
const decoded = await image.evaluate((element: HTMLImageElement) => ({
complete: element.complete,
width: element.naturalWidth,
}))
expect(decoded.complete).toBe(true)
expect(decoded.width).toBeGreaterThan(0)
}).toPass({ timeout: 10_000 })

// Refused once as the element asked, then asked for again with the header
expect(headers).toEqual([undefined, 'true'])
})

test('loads the file itself when there is no preview', async ({ page }) => {
let asked = false
await page.route('**/core/preview*', async (route) => {
Expand Down
39 changes: 37 additions & 2 deletions lib/components/Images.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import PlayCircleOutline from 'vue-material-design-icons/PlayCircleOutline.vue'
import { useViewerProps } from '../composables/useViewerProps.ts'
import { logger } from '../services/logger.ts'
import { preloadMedia } from '../services/mediaPreloader.ts'
import { preloadMedia, preloadPreview } from '../services/mediaPreloader.ts'
import { canDownload } from '../utils/canDownload.ts'
import { t } from '../utils/l10n.ts'
import { findLivePhotoPeerFromFileId } from '../utils/livePhotoUtils.ts'
import { getPreviewIfAny } from '../utils/previewUtils.ts'
Expand Down Expand Up @@ -192,6 +193,29 @@ const livePhotoSrc = computed(() => livePhoto.value?.encodedSource ?? null)
*/
let inFlight: { controller: AbortController, source: string } | null = null

/**
* The object URL this component made, if it made one.
*
* An object URL holds its blob until it is revoked, so paging through a
* folder of E2EE files or of previews fetched by hand would otherwise keep
* every one of them in memory for as long as the viewer is open. Only URLs
* made here are released: `localSource` belongs to whoever passed it.
*/
let ownedUrl: string | null = null

/**
* Show a blob this component fetched, releasing the one it showed before.
*
* @param url an object URL made here
*/
function showOwnedUrl(url: string): void {
if (ownedUrl !== null) {
URL.revokeObjectURL(ownedUrl)
}
ownedUrl = url
data.value = url
}

// Load data when component mounts or file changes. Keyed on the source, as
// two files can be shown under one name and it is the source that says
// which bytes to fetch.
Expand Down Expand Up @@ -254,7 +278,14 @@ async function loadData() {
// use: an E2EE file, or a preview the server cannot produce. Fetch the
// bytes by hand and show those instead.
if (fallback.value) {
data.value = await preloadMedia(props.file, signal)
// Which retry depends on what the share allows. A file that may be
// downloaded is fetched whole, which is what an E2EE file needs. One
// that may not is refused at the source too, so its preview is the
// only thing left to ask for, and only with a header an element
// cannot set on its own request.
showOwnedUrl(canDownload(props.file)
? await preloadMedia(props.file, signal)
: await preloadPreview(previewPath.value, signal))
return
}

Expand Down Expand Up @@ -346,6 +377,10 @@ watch(turns, () => {

onUnmounted(() => {
inFlight?.controller.abort()
if (ownedUrl !== null) {
URL.revokeObjectURL(ownedUrl)
ownedUrl = null
}
})

/**
Expand Down
26 changes: 26 additions & 0 deletions lib/services/mediaPreloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { IFile } from '@nextcloud/files'
import type { ResponseDataDetailed, WebDAVClient } from 'webdav'

import axios from '@nextcloud/axios'
import { getClient } from '@nextcloud/files/dav'

// Manually load a WebDAV media from its filename, then expose the received Blob as an object URL.
Expand All @@ -18,3 +19,28 @@ export async function preloadMedia(file: IFile, signal?: AbortSignal): Promise<s
const response = await client.getFileContents(file.source, { details: true, signal }) as ResponseDataDetailed<ArrayBuffer>
return URL.createObjectURL(new Blob([response.data], { type: response.headers['content-type'] }))
}

/**
* Fetch a preview the element cannot ask for itself.
*
* A share with download turned off has the preview endpoint refuse a plain
* request, and the server offers one way through: the `x-nc-preview` header
* (`core/Controller/PreviewController.php`, and the public-share controller
* beside it). An `img` element cannot set a header on its own request, so
* the bytes are fetched here and handed over as an object URL instead.
*
* The server calls this obfuscation rather than a boundary, and so should
* we: it is what keeps the preview URL from being useful when pasted
* elsewhere, not what decides who may see the file.
*
* @param url the preview URL to fetch
* @param signal aborts the request when the viewer moves to another file
*/
export async function preloadPreview(url: string, signal?: AbortSignal): Promise<string> {
const response = await axios.get(url, {
headers: { 'x-nc-preview': 'true' },
responseType: 'blob',
signal,
})
return URL.createObjectURL(response.data as Blob)
}
3 changes: 3 additions & 0 deletions playground/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const startSlideshow = flags.has('slideshow')
const fixtures: Fixture[] = [
{ name: 'photo.jpg', mime: 'image/jpeg', editable: true },
...(withPreviews ? [{ name: 'previewed.jpg', mime: 'image/jpeg', hasPreview: true }] : []),
// A share that forbids downloading: the preview endpoint refuses the
// element's own request, so the viewer has to ask for it by hand
...(withPreviews ? [{ name: 'restricted.jpg', mime: 'image/jpeg', hasPreview: true, noDownload: true }] : []),
{ name: 'gradient.jpg', mime: 'image/jpeg', editable: true },
{ name: 'portrait.jpg', mime: 'image/jpeg', editable: true },
{ name: 'photo.avif', mime: 'image/avif', editable: true },
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading