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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ registerHandler({

// Optional viewer modal theme: 'dark', 'light' or 'default'.
theme: 'default',

// Optional: whether the handler works with end-to-end encrypted files.
// They are decrypted when fetched from their WebDAV endpoint, a handler
// fetching from a different endpoint gets ciphertext. True when the
// component reads the file from `node.encodedSource`.
supportsEndToEndEncryption: true,
})
```

Expand All @@ -158,6 +164,7 @@ The full handler shape (see the `IHandler` interface):
| `group` | `string` | no | Group used to combine handlers when opening a folder |
| `preload` | `(node: File) => Promise<void>` | no | Preload data for neighbouring files |
| `theme` | `'dark' \| 'light' \| 'default'` | no | Viewer modal theme |
| `supportsEndToEndEncryption` | `boolean` | no | Whether the handler supports end-to-end encrypted files |

Gotchas:

Expand Down
86 changes: 86 additions & 0 deletions __tests__/encryptedFiles.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getFileActions } from '@nextcloud/files'
import { describe, expect, it, vi } from 'vitest'
import { canView, isHandlerEnabled, registerHandler } from '../lib/handlers.ts'
import { getHandlerForFile } from '../lib/helpers/handlerHelper.ts'
import { makeFile, makeHandler } from './factories.ts'

/**
* An end-to-end encrypted file is decrypted when fetched from its WebDAV
* endpoint. A handler fetching through an endpoint of its own would show
* ciphertext, so it is never offered such a file.
*/
describe('an end-to-end encrypted file', () => {
const encrypted = makeFile({ mime: 'text/markdown', attributes: { 'e2ee-is-encrypted': true } })
const plain = makeFile({ mime: 'text/markdown', attributes: { 'e2ee-is-encrypted': false } })
const unmarked = makeFile({ mime: 'text/markdown' })

const custom = makeHandler({ id: 'custom', tagname: 'oca-viewer-custom' })
const dav = makeHandler({ id: 'dav', tagname: 'oca-viewer-dav', supportsEndToEndEncryption: true })

it('is refused by a handler that has not opted in, without asking it', () => {
const enabled = vi.fn(() => true)

expect(isHandlerEnabled(makeHandler({ enabled }), [encrypted])).toBe(false)
expect(enabled).not.toHaveBeenCalled()
})

it('goes to a handler that reads it over dav', () => {
expect(isHandlerEnabled(dav, [encrypted])).toBe(true)
})

it('taints a set: one encrypted file refuses the whole set', () => {
expect(isHandlerEnabled(custom, [plain, encrypted])).toBe(false)
expect(isHandlerEnabled(dav, [plain, encrypted])).toBe(true)
})

it('changes nothing for a file the attribute marks as not encrypted, or does not mark', () => {
expect(isHandlerEnabled(custom, [plain])).toBe(true)
expect(isHandlerEnabled(custom, [unmarked])).toBe(true)
})

it('is not viewable when only handlers without the flag take its mime', () => {
registerHandler(custom)

expect(canView(plain)).toBe(true)
expect(canView(encrypted)).toBe(false)
expect(getHandlerForFile(encrypted)).toBeUndefined()
})

it('skips to the handler that can read it', () => {
registerHandler(custom)
registerHandler(dav)

expect(getHandlerForFile(plain)?.id).toBe('custom')
expect(getHandlerForFile(encrypted)?.id).toBe('dav')
})

it('hides the "Open with" entry of a handler that cannot read it', () => {
registerHandler(custom)
registerHandler(dav)
const ctx = { view: {} as never, folder: {} as never, contents: [] }
const actionIds = (file: typeof encrypted) => getFileActions()
.filter((action) => ['viewer-open', 'viewer-open-with-custom', 'viewer-open-with-dav'].includes(action.id))
.filter((action) => action.enabled?.({ ...ctx, nodes: [file] }))
.map((action) => action.id)
.sort()

expect(actionIds(plain)).toEqual(['viewer-open', 'viewer-open-with-custom', 'viewer-open-with-dav'])
expect(actionIds(encrypted)).toEqual(['viewer-open', 'viewer-open-with-dav'])
})
})

describe('the default handlers', () => {
it('all read the file over dav, so they take encrypted files', async () => {
const { registerDefaultHandlers } = await import('../lib/defaults.ts')
const { getHandlers } = await import('../lib/handlers.ts')
registerDefaultHandlers()

for (const id of ['images', 'videos', 'audios']) {
expect(getHandlers().get(id)?.supportsEndToEndEncryption, id).toBe(true)
}
})
})
19 changes: 19 additions & 0 deletions lib/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,21 @@ export interface IHandler {
* `editing` prop (e.g. the image editor).
*/
canEdit?: boolean

/**
* Whether this handler works with end-to-end encrypted files.
*
* End-to-end encrypted files are decrypted when fetched from their
* WebDAV endpoint. A handler that fetches the file from a different
* endpoint gets ciphertext. Set the property to true if the handler
* reads the file from its dav source.
*/
Comment thread
skjnldsv marked this conversation as resolved.
supportsEndToEndEncryption?: boolean
}

/** The dav attribute used to flag end-to-end encrypted files */
const ENCRYPTED_ATTRIBUTE = 'e2ee-is-encrypted'

/**
* Whether the viewer can open the given nodes.
*
Expand All @@ -93,6 +106,9 @@ export function canView(nodes: INode | INode[]): boolean {
/**
* Whether a handler accepts the given files.
*
* An end-to-end encrypted file goes only to a handler that says it can
* read one; the others are never asked.
*
* A handler is third-party code: one that throws from `enabled()` is
* reported and treated as not matching, so it cannot break the Files
* actions or the viewer for every other handler on the page.
Expand All @@ -101,6 +117,9 @@ export function canView(nodes: INode | INode[]): boolean {
* @param nodes - The files to test it against
*/
export function isHandlerEnabled(handler: IHandler, nodes: IFile[]): boolean {
if (!handler.supportsEndToEndEncryption && nodes.some((node) => Boolean(node.attributes?.[ENCRYPTED_ATTRIBUTE]))) {
return false
}
try {
return Boolean(handler.enabled(nodes))
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions lib/models/audios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function registerAudioHandler() {
id: 'audios',
displayName: t('Audio player'),
tagname,
supportsEndToEndEncryption: true,

iconSvgInline: AudioOutlineSvg,

Expand Down
1 change: 1 addition & 0 deletions lib/models/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function registerImageHandler() {
id: 'images',
displayName: t('Images'),
tagname,
supportsEndToEndEncryption: true,
canEdit: true,

enabled: (nodes) => {
Expand Down
1 change: 1 addition & 0 deletions lib/models/videos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function registerVideoHandler() {
id: 'videos',
displayName: t('Video player'),
tagname,
supportsEndToEndEncryption: true,

iconSvgInline: MovieOutlineSvg,

Expand Down
Loading