From f5f834ef1fd5bb0d32c0ea4851b2ae2811e5768f Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Sat, 12 Sep 2026 09:09:47 +0200 Subject: [PATCH 1/2] fix(handlers): offer encrypted files only to handlers that read them over dav Since Nextcloud 33 the end-to-end encryption app decrypts files transparently, but only on the WebDAV endpoint. A handler that fetches the bytes through an endpoint of its own gets ciphertext, so the old viewer app limited encrypted files to its image, video and audio handlers by mime (nextcloud/viewer@5aeb510e). This library did not carry that over and offered an encrypted file to every handler taking its mime. A handler now opts in with supportsEndToEndEncryption, set on the three default handlers, and a set holding an encrypted file is never shown to a handler without it: not through the file actions, not through canView(), and not by the viewer picking a handler for a file in the list. The handler's own enabled() is not even asked. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- README.md | 7 +++ __tests__/encryptedFiles.spec.ts | 86 ++++++++++++++++++++++++++++++++ lib/handlers.ts | 19 +++++++ lib/models/audios.ts | 1 + lib/models/images.ts | 1 + lib/models/videos.ts | 1 + 6 files changed, 115 insertions(+) create mode 100644 __tests__/encryptedFiles.spec.ts diff --git a/README.md b/README.md index 6dafdf2..63c6b29 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 can show an end-to-end encrypted file. + // The server decrypts one only on its WebDAV endpoint, so this is true + // when the component reads the file from `node.encodedSource` or from a + // preview, and stays unset when it fetches through an endpoint of its own. + 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 is offered end-to-end encrypted files | Gotchas: diff --git a/__tests__/encryptedFiles.spec.ts b/__tests__/encryptedFiles.spec.ts new file mode 100644 index 0000000..b085019 --- /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' + +/** + * The server decrypts an end-to-end encrypted file only on 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 or from a preview, 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..e233583 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 can show an end-to-end encrypted file. + * + * The server decrypts such a file only on its WebDAV endpoint, so a + * handler that fetches the bytes from an endpoint of its own gets + * ciphertext. Set it when the handler reads the file from its dav + * source, or from a preview. + */ + supportsEndToEndEncryption?: boolean } +/** The dav attribute the end-to-end encryption app marks encrypted files with */ +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, From dd60fbd2e3775b896381732f9c0bffbcbaf654c1 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Wed, 16 Sep 2026 17:44:03 +0200 Subject: [PATCH 2/2] fix(handlers): word the encryption flag after review Previews of end-to-end encrypted files are not a given, so the docs no longer name them as a way to read one: the flag is about reading the file from its dav source. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- README.md | 10 +++++----- __tests__/encryptedFiles.spec.ts | 4 ++-- lib/handlers.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 63c6b29..8c03887 100644 --- a/README.md +++ b/README.md @@ -144,10 +144,10 @@ registerHandler({ // Optional viewer modal theme: 'dark', 'light' or 'default'. theme: 'default', - // Optional: whether the handler can show an end-to-end encrypted file. - // The server decrypts one only on its WebDAV endpoint, so this is true - // when the component reads the file from `node.encodedSource` or from a - // preview, and stays unset when it fetches through an endpoint of its own. + // 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, }) ``` @@ -164,7 +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 is offered end-to-end encrypted files | +| `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 index b085019..4b9c8e9 100644 --- a/__tests__/encryptedFiles.spec.ts +++ b/__tests__/encryptedFiles.spec.ts @@ -9,7 +9,7 @@ import { getHandlerForFile } from '../lib/helpers/handlerHelper.ts' import { makeFile, makeHandler } from './factories.ts' /** - * The server decrypts an end-to-end encrypted file only on its WebDAV + * 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. */ @@ -74,7 +74,7 @@ describe('an end-to-end encrypted file', () => { }) describe('the default handlers', () => { - it('all read the file over dav or from a preview, so they take encrypted files', async () => { + 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() diff --git a/lib/handlers.ts b/lib/handlers.ts index e233583..899fddc 100644 --- a/lib/handlers.ts +++ b/lib/handlers.ts @@ -75,17 +75,17 @@ export interface IHandler { canEdit?: boolean /** - * Whether this handler can show an end-to-end encrypted file. + * Whether this handler works with end-to-end encrypted files. * - * The server decrypts such a file only on its WebDAV endpoint, so a - * handler that fetches the bytes from an endpoint of its own gets - * ciphertext. Set it when the handler reads the file from its dav - * source, or from a preview. + * 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 the end-to-end encryption app marks encrypted files with */ +/** The dav attribute used to flag end-to-end encrypted files */ const ENCRYPTED_ATTRIBUTE = 'e2ee-is-encrypted' /**