diff --git a/README.md b/README.md index 6dafdf2..8c03887 100644 --- a/README.md +++ b/README.md @@ -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, }) ``` @@ -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` | 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: diff --git a/__tests__/encryptedFiles.spec.ts b/__tests__/encryptedFiles.spec.ts new file mode 100644 index 0000000..4b9c8e9 --- /dev/null +++ b/__tests__/encryptedFiles.spec.ts @@ -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) + } + }) +}) diff --git a/lib/handlers.ts b/lib/handlers.ts index b25fec8..899fddc 100644 --- a/lib/handlers.ts +++ b/lib/handlers.ts @@ -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. + */ + 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. * @@ -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. @@ -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) { diff --git a/lib/models/audios.ts b/lib/models/audios.ts index 3268733..ec80e6a 100644 --- a/lib/models/audios.ts +++ b/lib/models/audios.ts @@ -44,6 +44,7 @@ export function registerAudioHandler() { id: 'audios', displayName: t('Audio player'), tagname, + supportsEndToEndEncryption: true, iconSvgInline: AudioOutlineSvg, diff --git a/lib/models/images.ts b/lib/models/images.ts index c3e1038..559ea7e 100644 --- a/lib/models/images.ts +++ b/lib/models/images.ts @@ -90,6 +90,7 @@ export function registerImageHandler() { id: 'images', displayName: t('Images'), tagname, + supportsEndToEndEncryption: true, canEdit: true, enabled: (nodes) => { diff --git a/lib/models/videos.ts b/lib/models/videos.ts index b841389..9e9180f 100644 --- a/lib/models/videos.ts +++ b/lib/models/videos.ts @@ -46,6 +46,7 @@ export function registerVideoHandler() { id: 'videos', displayName: t('Video player'), tagname, + supportsEndToEndEncryption: true, iconSvgInline: MovieOutlineSvg,