From 80fcc1df5e27d7f929989c91f6819918bd29b664 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 20 May 2026 07:10:36 +0200 Subject: [PATCH 1/6] feat: add rclone-crypt folder vault PoC Introduces a `folderVault` extension type so apps can transparently encrypt and decrypt the names and contents of folders flagged as vaults, plus the first implementation (`web-app-rclone-crypt`) that surfaces rclone-crypt compatible vaults under cleartext names. Wires vault-awareness into listing, AppWrapper, single-file and drag-drop upload, create-file, create-folder and delete. Adds a runtime-level unlock guard that mirrors the public-link flow, an in-memory passphrase store, empty-vault create-passphrase UX and a lock-vault context action. Covered by cucumber e2e scenarios that drive an rclone CLI fixture. --- dev/docker/opencloud.web.config.json | 3 +- packages/web-app-files/src/HandleUpload.ts | 140 ++++++++- .../src/components/AppBar/CreateAndUpload.vue | 28 +- .../files/useFileActionsCreateNewFile.ts | 85 ++++- .../files/useFileActionsCreateNewFolder.ts | 19 +- .../web-app-files/src/views/FilesDrop.vue | 3 + .../tests/unit/HandleUpload.spec.ts | 2 + packages/web-app-preview/src/App.vue | 8 +- .../l10n/translations.json | 1 + packages/web-app-rclone-crypt/package.json | 16 + .../web-app-rclone-crypt/src/crypto/engine.ts | 183 +++++++++++ .../src/extensions/folderVault.ts | 13 + .../src/extensions/lockVault.ts | 74 +++++ .../src/extensions/resourceIndicator.ts | 75 +++++ packages/web-app-rclone-crypt/src/index.ts | 73 +++++ .../web-app-rclone-crypt/src/resolveVault.ts | 73 +++++ .../src/views/UnlockVault.vue | 202 ++++++++++++ .../components/AppTemplates/AppWrapper.vue | 181 ++++++++++- .../actions/files/useFileActions.ts | 8 + .../helpers/useFileActionsDeleteResources.ts | 31 +- .../appDefaults/useAppFileHandling.ts | 53 +++- .../appDefaults/useAppFolderHandling.ts | 33 +- .../piniaStores/extensionRegistry/types.ts | 84 ++++- .../composables/piniaStores/folderVault.ts | 69 ++++ .../src/composables/piniaStores/index.ts | 1 + .../resources/useResourceIndicators.ts | 18 +- .../src/editor/composables/useTextEditor.ts | 14 +- packages/web-pkg/src/extensionPoints.ts | 14 +- packages/web-pkg/src/helpers/folderVault.ts | 150 +++++++++ packages/web-pkg/src/helpers/index.ts | 1 + .../src/services/folder/folderService.ts | 9 +- .../services/folder/loaders/loaderSpace.ts | 27 +- .../files/useFileActionsDelete.spec.ts | 2 +- .../web-runtime/src/container/bootstrap.ts | 3 + .../web-runtime/src/container/sse/files.ts | 21 +- .../web-runtime/src/container/sse/types.ts | 2 + packages/web-runtime/src/index.ts | 1 + packages/web-runtime/src/router/index.ts | 2 + .../src/router/setupVaultUnlockGuard.ts | 66 ++++ .../tests/unit/container/sse/files.spec.ts | 3 + .../tests/unit/container/sse/helpers.spec.ts | 3 + .../tests/unit/container/sse/shares.spec.ts | 3 + pnpm-lock.yaml | 63 ++++ .../features/rclone-crypt/readVault.feature | 65 ++++ tests/e2e/cucumber/steps/rcloneCrypt.ts | 296 ++++++++++++++++++ 45 files changed, 2173 insertions(+), 48 deletions(-) create mode 100644 packages/web-app-rclone-crypt/l10n/translations.json create mode 100644 packages/web-app-rclone-crypt/package.json create mode 100644 packages/web-app-rclone-crypt/src/crypto/engine.ts create mode 100644 packages/web-app-rclone-crypt/src/extensions/folderVault.ts create mode 100644 packages/web-app-rclone-crypt/src/extensions/lockVault.ts create mode 100644 packages/web-app-rclone-crypt/src/extensions/resourceIndicator.ts create mode 100644 packages/web-app-rclone-crypt/src/index.ts create mode 100644 packages/web-app-rclone-crypt/src/resolveVault.ts create mode 100644 packages/web-app-rclone-crypt/src/views/UnlockVault.vue create mode 100644 packages/web-pkg/src/composables/piniaStores/folderVault.ts create mode 100644 packages/web-pkg/src/helpers/folderVault.ts create mode 100644 packages/web-runtime/src/router/setupVaultUnlockGuard.ts create mode 100644 tests/e2e/cucumber/features/rclone-crypt/readVault.feature create mode 100644 tests/e2e/cucumber/steps/rcloneCrypt.ts diff --git a/dev/docker/opencloud.web.config.json b/dev/docker/opencloud.web.config.json index 3838f4896d..f60a2e6fde 100644 --- a/dev/docker/opencloud.web.config.json +++ b/dev/docker/opencloud.web.config.json @@ -18,6 +18,7 @@ "activities", "preview", "mail", - "contacts" + "contacts", + "rclone-crypt" ] } diff --git a/packages/web-app-files/src/HandleUpload.ts b/packages/web-app-files/src/HandleUpload.ts index a12442d145..4f58897cdc 100644 --- a/packages/web-app-files/src/HandleUpload.ts +++ b/packages/web-app-files/src/HandleUpload.ts @@ -8,6 +8,8 @@ import { Resource, SpaceResource } from '@opencloud-eu/web-client' import { urlJoin } from '@opencloud-eu/web-client' import { UploadResourceConflict } from './helpers/resource' import { + ExtensionRegistry, + FolderVaultEngine, MessageStore, ResourcesStore, SpacesStore, @@ -16,7 +18,8 @@ import { formatFileSize, OcUppyFile, OcUppyMeta, - OcUppyBody + OcUppyBody, + resolveFolderVault } from '@opencloud-eu/web-pkg' import { locationSpacesGeneric, UppyService } from '@opencloud-eu/web-pkg' import { isPersonalSpaceResource, isShareSpaceResource } from '@opencloud-eu/web-client' @@ -31,6 +34,7 @@ export interface HandleUploadOptions { messageStore: MessageStore spacesStore: SpacesStore resourcesStore: ResourcesStore + extensionRegistry: ExtensionRegistry uppyService: UppyService id?: string space?: Ref @@ -57,6 +61,7 @@ export class HandleUpload extends BasePlugin messageStore: MessageStore spacesStore: SpacesStore resourcesStore: ResourcesStore + extensionRegistry: ExtensionRegistry uppyService: UppyService quotaCheckEnabled: boolean directoryTreeCreateEnabled: boolean @@ -76,6 +81,7 @@ export class HandleUpload extends BasePlugin this.messageStore = opts.messageStore this.spacesStore = opts.spacesStore this.resourcesStore = opts.resourcesStore + this.extensionRegistry = opts.extensionRegistry this.uppyService = opts.uppyService this.quotaCheckEnabled = opts.quotaCheckEnabled ?? true @@ -297,7 +303,8 @@ export class HandleUpload extends BasePlugin async createDirectoryTree( filesToUpload: OcUppyFile[], uploadFolder: Resource, - mergedFolders: string[] = [] + mergedFolders: string[] = [], + vaultEngine: FolderVaultEngine | null = null ): Promise<{ filesToUpload: OcUppyFile[]; folderFiles: OcUppyFile[] }> { const { webdav } = this.clientService const space = unref(this.space) @@ -366,8 +373,16 @@ export class HandleUpload extends BasePlugin this.uppyService.publish('addedForUpload', [uppyFile]) try { + // Inside a vault every path segment we create on the server must + // be ciphertext. The directoryTree itself stays in cleartext for + // tracking purposes; we only translate the path right before the + // MKCOL call. + const cleartextPath = urlJoin(currentFolderPath, path) + const mkcolPath = vaultEngine + ? await vaultEngine.encryptPath(cleartextPath) + : cleartextPath const folder = await webdav.createFolder(space, { - path: urlJoin(currentFolderPath, path), + path: mkcolPath, fetchFolder: isRoot // FIXME: remove once we get the fileId from the server here }) this.uppyService.publish('uploadSuccess', { @@ -429,6 +444,20 @@ export class HandleUpload extends BasePlugin const uploadFolder = this.getUploadFolder(uploadId) let filesToUpload = this.prepareFiles(files, uploadFolder) + // Vault-aware upload: when the target folder lives inside a vault, swap + // every file's content for its ciphertext and rewrite the upload endpoint + // to the encrypted server path before Uppy starts pushing bytes. Folder + // creation (MKCOL) inside createDirectoryTree is vault-aware too — it + // pulls the engine out of the same registry. + const vaultEngine = resolveFolderVault( + this.extensionRegistry, + unref(this.space), + uploadFolder?.path + ) + if (vaultEngine) { + filesToUpload = await this.applyVaultEncryption(filesToUpload, uploadFolder, vaultEngine) + } + if (!this.directoryTreeCreateEnabled) { // if directory tree creation is disabled, we need to remove all folder files // from the upload queue @@ -482,7 +511,12 @@ export class HandleUpload extends BasePlugin this.uppyService.publish('uploadStarted') let folderFiles: OcUppyFile[] = [] if (this.directoryTreeCreateEnabled) { - const result = await this.createDirectoryTree(filesToUpload, uploadFolder, mergedFolders) + const result = await this.createDirectoryTree( + filesToUpload, + uploadFolder, + mergedFolders, + vaultEngine + ) filesToUpload = result.filesToUpload folderFiles = result.folderFiles } @@ -503,6 +537,104 @@ export class HandleUpload extends BasePlugin this.uppyService.removeUploadFolder(uploadId) } + private async collectStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const out = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + out.set(chunk, offset) + offset += chunk.byteLength + } + return out + } + + /** + * Replace each file's content + name with their encrypted forms and rewrite + * the upload endpoint so Uppy/Tus pushes ciphertext to the encrypted + * server path. Sub-paths inside a drag-drop are walked segment-by-segment + * — every segment of `relativeFolder` ends up as ciphertext on the wire, + * even though we keep the original cleartext tree on `file.meta` for + * progress UI / directory creation tracking. + */ + private async applyVaultEncryption( + filesToUpload: OcUppyFile[], + uploadFolder: Resource, + vaultEngine: FolderVaultEngine + ): Promise { + const space = unref(this.space) + const encryptedFolderPath = await vaultEngine.encryptPath(uploadFolder.path) + + const updated: Record = {} + for (const file of filesToUpload) { + if (file.type === 'directory') { + // Folder entries don't get an HTTP payload of their own — the MKCOLs + // happen in createDirectoryTree (which is also vault-aware). Skip + // here so we don't try to encrypt a non-existent content stream. + continue + } + + // Walk the cleartext relativeFolder segment-by-segment so each part + // is encrypted independently (rclone-crypt's filename EME operates + // per segment) and join the ciphertext segments back into a path the + // server understands. + const clearRelative = file.meta.relativeFolder || '' + const encryptedRelativeSegments: string[] = [] + for (const segment of clearRelative.split('/').filter(Boolean)) { + encryptedRelativeSegments.push(await vaultEngine.encryptName(segment, uploadFolder.path)) + } + const encryptedRelativeFolder = encryptedRelativeSegments.join('/') + + const encryptedName = await vaultEngine.encryptName(file.name, uploadFolder.path) + + const plaintextBytes = new Uint8Array(await (file.data as Blob).arrayBuffer()) + const plainStream = new ReadableStream({ + start(controller) { + controller.enqueue(plaintextBytes) + controller.close() + } + }) + const cipherBytes = await this.collectStream(vaultEngine.encryptContent(plainStream)) + const cipherBlob = new Blob([cipherBytes as BlobPart], { + type: 'application/octet-stream' + }) + + const endpointFolder = urlJoin(encryptedFolderPath, encryptedRelativeFolder) + const endpointFolderUrl = space.getWebDavUrl({ + path: endpointFolder.split('/').map(encodeURIComponent).join('/') + }) + let endpoint = endpointFolderUrl + if (!this.uppy.getPlugin('Tus')) { + endpoint = urlJoin(endpoint, encodeURIComponent(encryptedName)) + } + + // Mutate in-place so existing meta (uploadId, spaceId, …) is preserved. + // We keep meta.relativeFolder in cleartext on purpose — directoryTree + // creation reads it to decide which folders to MKCOL and translates + // the path itself at the very last step. + file.data = cipherBlob + file.size = cipherBlob.size + file.name = encryptedName + file[this.getUploadPluginName()] = { endpoint } + file.meta = { + ...file.meta, + name: encryptedName, + tusEndpoint: endpoint + } + updated[file.id] = file + } + this.uppy.setState({ files: { ...this.uppy.getState().files, ...updated } }) + return filesToUpload + } + install() { this.uppy.on('files-added', this.handleUpload) } diff --git a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue index 523e471397..caac275134 100644 --- a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue +++ b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue @@ -29,6 +29,7 @@ import { ClipboardActions, isLocationPublicActive, useClipboardStore, + useExtensionRegistry, useMessages, useResourcesStore, useRoute, @@ -40,7 +41,14 @@ import { import { computed, onMounted, onBeforeUnmount, unref, watch } from 'vue' import { SpaceResource, isPublicSpaceResource } from '@opencloud-eu/web-client' -import { useService, useUpload, UppyService, UploadResult } from '@opencloud-eu/web-pkg' +import { + decryptResourceInPlace, + resolveFolderVault, + useService, + useUpload, + UppyService, + UploadResult +} from '@opencloud-eu/web-pkg' import { HandleUpload } from '../../HandleUpload' import { useGettext } from 'vue3-gettext' import { storeToRefs } from 'pinia' @@ -68,6 +76,7 @@ const { resources: clipboardResources, action: clipboardAction } = storeToRefs(c const resourcesStore = useResourcesStore() const { currentFolder } = storeToRefs(resourcesStore) +const extensionRegistry = useExtensionRegistry() const isPublicLocation = useActiveLocation(isLocationPublicActive, 'files-public-link') @@ -85,6 +94,7 @@ if (!uppyService.getPlugin('HandleUpload')) { spacesStore, messageStore, resourcesStore, + extensionRegistry, uppyService }) } @@ -141,10 +151,24 @@ const onUploadComplete = async (result: UploadResult) => { return } + // Vault-aware refresh: the cleartext currentFolder path means nothing to + // the server, so encrypt before listing and decrypt the children back to + // cleartext before they enter the store. Without this the upload would + // pop up with the encrypted blob name. + const clearPath = unref(currentFolder).path + const vaultEngine = resolveFolderVault(extensionRegistry, unref(computedSpace), clearPath) + const listPath = vaultEngine ? await vaultEngine.encryptPath(clearPath) : clearPath + const { children } = await clientService.webdav.listFiles(unref(computedSpace), { - path: unref(currentFolder).path + path: listPath }) + if (vaultEngine) { + for (const child of children) { + await decryptResourceInPlace(vaultEngine, child) + } + } + const existingIds = new Set(resourcesStore.resources.map((r) => r.id)) const newResources = children.filter((child) => !existingIds.has(child.id)) resourcesStore.upsertResources(newResources) diff --git a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts index 8fa9dccaed..b29b0cfbc0 100644 --- a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts +++ b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts @@ -6,20 +6,44 @@ import { computed, Ref, unref } from 'vue' import { useGettext } from 'vue3-gettext' import { ApplicationFileExtension, + decryptResourceInPlace, FileAction, FileActionOptions, resolveFileNameDuplicate, + resolveFolderVault, useAppsStore, useClientService, useEmbedMode, + useExtensionRegistry, useFileActions, useIsResourceNameValid, useMessages, useModals, useResourcesStore, + useRouter, useUserStore } from '@opencloud-eu/web-pkg' +async function collectStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const out = new Uint8Array(total) + let offset = 0 + for (const c of chunks) { + out.set(c, offset) + offset += c.byteLength + } + return out +} + export const useFileActionsCreateNewFile = ({ space }: { space?: Ref } = {}) => { const { showMessage, showErrorMessage } = useMessages() const userStore = useUserStore() @@ -30,9 +54,11 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref { resourcesStore.upsertResource(resource) - return openEditor(appFileExtension, unref(space), resource) + // Folder-typed new-menu entries (e.g. vault from rclone-crypt, notebooks + // from notes) may not register an editor route — when there's nothing to + // open we navigate into the freshly-created folder instead so the user + // ends up inside it. Anything that *does* have a route (apps like notes) + // keeps using openEditor. + const targetSpace = unref(space) + const routeName = appFileExtension.routeName || appFileExtension.app + if (appFileExtension.type === 'folder' && !router.hasRoute(routeName)) { + const driveAliasAndItem = targetSpace?.getDriveAliasAndItem(resource) + if (driveAliasAndItem) { + router.push({ + name: 'files-spaces-generic', + params: { driveAliasAndItem }, + query: resource.fileId ? { fileId: resource.fileId as string } : undefined + }) + return + } + } + + return openEditor(appFileExtension, targetSpace, resource) } const handler = ( @@ -80,6 +125,17 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref({ + start(controller) { + controller.close() + } + }) + ) + ) + : undefined resource = await (clientService.webdav as WebDAV).putFileContents(unref(space), { - path + path, + ...(content ? { content: content.buffer as ArrayBuffer } : {}) }) } + if (vaultEngine && resource) { + await decryptResourceInPlace(vaultEngine, resource) + } resourcesStore.upsertResource(resource) diff --git a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts index ab2947c778..e26d2526eb 100644 --- a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts +++ b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFolder.ts @@ -4,9 +4,12 @@ import { join } from 'path' import { computed, nextTick, Ref, unref } from 'vue' import { useGettext } from 'vue3-gettext' import { + decryptResourceInPlace, FileAction, resolveFileNameDuplicate, + resolveFolderVault, useClientService, + useExtensionRegistry, useIsResourceNameValid, useMessages, useModals, @@ -24,14 +27,28 @@ export const useFileActionsCreateNewFolder = ({ space }: { space?: Ref { folderName = folderName.trimEnd() try { - const path = join(unref(currentFolder).path, folderName) + // Vault-aware: when the current folder sits inside a vault, the + // cleartext folder name has to be encrypted before hitting webdav, and + // the returned resource decrypted before it ends up in the store. + const cleartextParentPath = unref(currentFolder).path + const vaultEngine = resolveFolderVault( + extensionRegistry, + unref(space), + cleartextParentPath + ) + const cleartextPath = join(cleartextParentPath, folderName) + const path = vaultEngine ? await vaultEngine.encryptPath(cleartextPath) : cleartextPath const resource = await clientService.webdav.createFolder(unref(space), { path }) + if (vaultEngine) { + await decryptResourceInPlace(vaultEngine, resource) + } // FIXME: move to buildResource as soon as it has space context if (isShareSpaceResource(unref(space))) { diff --git a/packages/web-app-files/src/views/FilesDrop.vue b/packages/web-app-files/src/views/FilesDrop.vue index 08b2deb88e..edf42382bd 100644 --- a/packages/web-app-files/src/views/FilesDrop.vue +++ b/packages/web-app-files/src/views/FilesDrop.vue @@ -57,6 +57,7 @@ import { useSpacesStore, useThemeStore, useUserStore, + useExtensionRegistry, useResourcesStore } from '@opencloud-eu/web-pkg' import ResourceUpload from '../components/AppBar/Upload/ResourceUpload.vue' @@ -107,6 +108,7 @@ export default defineComponent({ useUpload({ uppyService }) const resourcesStore = useResourcesStore() + const extensionRegistry = useExtensionRegistry() const { currentTheme } = storeToRefs(themeStore) const themeSlogan = computed(() => unref(currentTheme).slogan) @@ -125,6 +127,7 @@ export default defineComponent({ spacesStore, messageStore, resourcesStore, + extensionRegistry, uppyService, quotaCheckEnabled: false, directoryTreeCreateEnabled: false, diff --git a/packages/web-app-files/tests/unit/HandleUpload.spec.ts b/packages/web-app-files/tests/unit/HandleUpload.spec.ts index be3c5e9df7..63a987772b 100644 --- a/packages/web-app-files/tests/unit/HandleUpload.spec.ts +++ b/packages/web-app-files/tests/unit/HandleUpload.spec.ts @@ -11,6 +11,7 @@ import { useUserStore, useMessages, useSpacesStore, + useExtensionRegistry, useResourcesStore, OcUppyFile, OcUppyMeta, @@ -403,6 +404,7 @@ const getWrapper = ({ messageStore: useMessages(), spacesStore: useSpacesStore(), resourcesStore: useResourcesStore(), + extensionRegistry: useExtensionRegistry(), space: ref(mock()), uppyService, conflictHandlingEnabled, diff --git a/packages/web-app-preview/src/App.vue b/packages/web-app-preview/src/App.vue index 04f15139a3..3f655f55f2 100644 --- a/packages/web-app-preview/src/App.vue +++ b/packages/web-app-preview/src/App.vue @@ -276,7 +276,13 @@ const loadPreviewImage = async (mediaFile: MediaFile) => { loadPreviewImageController = new AbortController() try { - if (mediaFile.isImage) { + // Vault resources (and anything else marked without a server-side + // preview) can't be thumbnailed by the server, so skip the preview + // service entirely and fetch the full image instead. `getUrlForResource` + // is vault-aware and returns a blob URL with cleartext bytes. + const useFullImage = mediaFile.isImage && !mediaFile.resource.hasPreview?.() + + if (mediaFile.isImage && !useFullImage) { mediaFile.url = await previewService.loadPreview( { space: unref(space), diff --git a/packages/web-app-rclone-crypt/l10n/translations.json b/packages/web-app-rclone-crypt/l10n/translations.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/packages/web-app-rclone-crypt/l10n/translations.json @@ -0,0 +1 @@ +{} diff --git a/packages/web-app-rclone-crypt/package.json b/packages/web-app-rclone-crypt/package.json new file mode 100644 index 0000000000..48c6f2ec56 --- /dev/null +++ b/packages/web-app-rclone-crypt/package.json @@ -0,0 +1,16 @@ +{ + "name": "rclone-crypt", + "version": "0.0.0", + "private": true, + "description": "OpenCloud Web e2ee plugin: read rclone-crypt encrypted folders (PoC)", + "license": "AGPL-3.0", + "dependencies": { + "@fyears/rclone-crypt": "^0.0.7" + }, + "peerDependencies": { + "@opencloud-eu/web-client": "workspace:*", + "@opencloud-eu/web-pkg": "workspace:*", + "vue": "^3.5.0", + "vue3-gettext": "^4.0.0-beta.1" + } +} diff --git a/packages/web-app-rclone-crypt/src/crypto/engine.ts b/packages/web-app-rclone-crypt/src/crypto/engine.ts new file mode 100644 index 0000000000..c7142d0742 --- /dev/null +++ b/packages/web-app-rclone-crypt/src/crypto/engine.ts @@ -0,0 +1,183 @@ +import { Cipher } from '@fyears/rclone-crypt' +import { FolderVaultEngine } from '@opencloud-eu/web-pkg' + +// rclone-crypt's filename encryption (EME) is deterministic, so once the key +// is derived we keep one Cipher instance per (space, vault, password) tuple. +// Key derivation (scrypt) is expensive; the cache avoids re-running it on +// every folder listing. +const cipherCache = new Map>() + +function cacheKey(spaceId: string, vaultRoot: string, password: string): string { + return `${spaceId}::${vaultRoot}::${password}` +} + +async function getCipher( + spaceId: string, + vaultRoot: string, + password: string +): Promise { + const key = cacheKey(spaceId, vaultRoot, password) + let cipherPromise = cipherCache.get(key) + if (!cipherPromise) { + cipherPromise = (async () => { + const cipher = new Cipher('base32') + // Empty salt makes the cipher fall back to rclone's default salt — this + // matches the behaviour of `rclone config` when no password2 is set. + await cipher.key(password, '') + return cipher + })() + cipherCache.set(key, cipherPromise) + } + return cipherPromise +} + +function vaultRelative(vaultRoot: string, fullPath: string): string { + if (!fullPath.startsWith(vaultRoot)) { + return '' + } + return fullPath.slice(vaultRoot.length).replace(/^\/+/, '').replace(/\/+$/, '') +} + +function joinUnderVault(vaultRoot: string, relative: string): string { + if (!relative) { + return vaultRoot + } + return `${vaultRoot}/${relative}` +} + +export function createEngine( + spaceId: string, + vaultRoot: string, + password: string +): FolderVaultEngine { + const cipherPromise = getCipher(spaceId, vaultRoot, password) + + return { + vaultRoot, + isLocked: () => false, + async encryptPath(clearPath: string): Promise { + if (!clearPath.startsWith(vaultRoot)) { + // Path is outside our vault — leave it untouched. Returning the vault + // root here would silently rewrite unrelated resources onto the vault. + return clearPath + } + const relative = vaultRelative(vaultRoot, clearPath) + if (!relative) { + return vaultRoot + } + const cipher = await cipherPromise + const encrypted = await cipher.encryptFileName(relative) + return joinUnderVault(vaultRoot, encrypted) + }, + async decryptPath(encryptedPath: string): Promise { + if (!encryptedPath.startsWith(vaultRoot)) { + return encryptedPath + } + const relative = vaultRelative(vaultRoot, encryptedPath) + if (!relative) { + return vaultRoot + } + const cipher = await cipherPromise + const decrypted = await cipher.decryptFileName(relative) + return joinUnderVault(vaultRoot, decrypted) + }, + async decryptName(encryptedSegment: string, _parentClearPath: string): Promise { + if (!encryptedSegment) { + return encryptedSegment + } + const cipher = await cipherPromise + return cipher.decryptSegment(encryptedSegment) + }, + async encryptName(clearSegment: string, _parentClearPath: string): Promise { + if (!clearSegment) { + return clearSegment + } + const cipher = await cipherPromise + return cipher.encryptSegment(clearSegment) + }, + async verifyKey(sampleEncryptedSegment: string): Promise { + // rclone-crypt throws on bad password (PKCS#7 unpad fails, UTF-8 decode + // fails, or the decoded segment contains control chars). Treat any + // throw as "key doesn't match". + try { + const cipher = await cipherPromise + const decrypted = await cipher.decryptSegment(sampleEncryptedSegment) + // additional defensive check: empty result is suspicious + return decrypted.length > 0 + } catch { + return false + } + }, + encryptContent(plaintext: ReadableStream): ReadableStream { + // FIXME(poc-vault): mirrors decryptContent — buffer the whole input, + // call cipher.encryptData, emit one chunk. Same upgrade path applies. + return new ReadableStream({ + async start(controller) { + try { + const reader = plaintext.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const combined = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.byteLength + } + const cipher = await cipherPromise + // nonce omitted → lib generates a fresh random nonce per file + const ciphertext = await cipher.encryptData(combined, undefined) + controller.enqueue(ciphertext) + controller.close() + } catch (e) { + controller.error(e) + } + } + }) + }, + decryptContent(encrypted: ReadableStream): ReadableStream { + // FIXME(poc-vault): @fyears/rclone-crypt's `decryptData` operates on a + // single Uint8Array, so we buffer the whole input before decrypting and + // emit one chunk on the output. The rclone-crypt format itself is + // chunked (64 KiB blocks + per-block nonce + a 32-byte file header), so + // a future implementation — either an upstream stream API or a + // hand-rolled one over the salsa primitives — can swap this body for a + // pipe that decrypts block-by-block without breaking callers. + return new ReadableStream({ + async start(controller) { + try { + const reader = encrypted.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + // collect entire ciphertext + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const combined = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.byteLength + } + const cipher = await cipherPromise + const plaintext = await cipher.decryptData(combined) + controller.enqueue(plaintext) + controller.close() + } catch (e) { + controller.error(e) + } + } + }) + } + } +} diff --git a/packages/web-app-rclone-crypt/src/extensions/folderVault.ts b/packages/web-app-rclone-crypt/src/extensions/folderVault.ts new file mode 100644 index 0000000000..f90b1feb19 --- /dev/null +++ b/packages/web-app-rclone-crypt/src/extensions/folderVault.ts @@ -0,0 +1,13 @@ +import { FolderVaultExtension } from '@opencloud-eu/web-pkg' +import { claimsVaultPath, resolveVault } from '../resolveVault' + +export const folderVaultExtension: FolderVaultExtension = { + id: 'app.rclone-crypt.folder-vault', + type: 'folderVault', + resolve(space, path) { + return resolveVault(space, path) + }, + claimsPath(space, path) { + return claimsVaultPath(space, path) + } +} diff --git a/packages/web-app-rclone-crypt/src/extensions/lockVault.ts b/packages/web-app-rclone-crypt/src/extensions/lockVault.ts new file mode 100644 index 0000000000..269c79b0ba --- /dev/null +++ b/packages/web-app-rclone-crypt/src/extensions/lockVault.ts @@ -0,0 +1,74 @@ +import { computed, Ref, unref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { + ActionExtension, + FileAction, + FileActionOptions, + useFolderVaultStore, + useMessages, + useRouter +} from '@opencloud-eu/web-pkg' +import { Resource } from '@opencloud-eu/web-client' + +function isVaultRoot(resource: Resource | undefined): boolean { + if (!resource) return false + if (resource.type !== 'folder' && !resource.isFolder) return false + return resource.name?.endsWith('.vault') === true +} + +export const useLockVaultAction = (): Ref => { + const { $gettext } = useGettext() + const vaultStore = useFolderVaultStore() + const { showMessage } = useMessages() + const router = useRouter() + + return computed(() => ({ + name: 'lock-vault', + icon: 'lock', + iconFillType: 'line', + label: () => $gettext('Lock vault'), + category: 'tertiary', + handler: ({ resources }: FileActionOptions) => { + const resource = resources?.[0] + if (!resource || !isVaultRoot(resource)) return + const spaceId = resource.storageId as string + const vaultRoot = resource.path + vaultStore.clearSecret(spaceId, vaultRoot) + showMessage({ + title: $gettext('»%{vault}« was locked', { vault: resource.name }) + }) + // If the user happens to be sitting inside the freshly-locked vault, + // bounce them to the parent so they aren't staring at encrypted + // ciphertext names. From outside (clicked on the vault entry in the + // parent listing) the current route is fine — no redirect needed. + const currentPath = (unref(router.currentRoute).params?.driveAliasAndItem as + | string + | undefined) || '' + if (currentPath.includes(vaultRoot.replace(/^\//, ''))) { + const parent = vaultRoot.replace(/\/[^/]+$/, '') || '/' + router.push( + `/files/spaces/${currentPath.split('/')[0]}${parent === '/' ? '' : parent}` + ) + } + }, + isVisible: ({ resources }: FileActionOptions) => { + const resource = resources?.[0] + if (!resource || !isVaultRoot(resource)) return false + return vaultStore.isUnlocked(resource.storageId as string, resource.path) + }, + class: 'oc-files-actions-lock-vault' + })) +} + +export function lockVaultActionExtension(action: Ref): ActionExtension { + return { + id: 'app.rclone-crypt.lock-vault', + type: 'action', + // String literal matches `contextActionsExtensionPoint.id` in web-app-files + // — see other plugins that hook the same point. + extensionPointIds: ['global.files.context-actions'], + get action() { + return unref(action) + } + } as ActionExtension +} diff --git a/packages/web-app-rclone-crypt/src/extensions/resourceIndicator.ts b/packages/web-app-rclone-crypt/src/extensions/resourceIndicator.ts new file mode 100644 index 0000000000..b6db4d3f83 --- /dev/null +++ b/packages/web-app-rclone-crypt/src/extensions/resourceIndicator.ts @@ -0,0 +1,75 @@ +import { + ResourceIndicator, + ResourceIndicatorExtension, + useFolderVaultStore +} from '@opencloud-eu/web-pkg' +import { Resource } from '@opencloud-eu/web-client' + +// Find the `*.vault` segment in a path. Returns the cleartext root (e.g. +// `/myvault.vault`) when the resource sits inside or on a vault, else null. +function findVaultRoot(path: string | undefined): string | null { + if (!path) return null + const segments = path.split('/').filter(Boolean) + const idx = segments.findIndex((s) => s.endsWith('.vault')) + if (idx === -1) return null + return '/' + segments.slice(0, idx + 1).join('/') +} + +export const resourceIndicatorExtension: ResourceIndicatorExtension = { + id: 'app.rclone-crypt.indicator', + type: 'resourceIndicator', + extensionPointIds: ['global.files.resource-indicator'], + getResourceIndicators(resource: Resource): ResourceIndicator[] | void { + const vaultRoot = findVaultRoot(resource.path) + if (!vaultRoot) { + return + } + + const vaultStore = useFolderVaultStore() + const unlocked = vaultStore.isUnlocked(resource.storageId as string, vaultRoot) + + // The vault-root resource itself is what users see when browsing the + // parent folder — show open vs. closed lock so the unlock state is + // obvious without entering the vault first. + const isVaultRoot = resource.path === vaultRoot + + if (isVaultRoot) { + return [ + { + id: `vault-state-${resource.getDomSelector?.() ?? resource.id}`, + kind: 'icon', + accessibleDescription: unlocked + ? 'This vault is unlocked in your session' + : 'This vault is locked', + label: unlocked ? 'Vault unlocked' : 'Vault locked', + // Closed padlock = solid filled, open padlock = outline. The + // fill/line contrast is what users actually notice at this size; + // same icon family with different fill state alone is too subtle. + icon: unlocked ? 'lock-unlock' : 'lock-2', + fillType: unlocked ? 'line' : 'fill', + category: 'system', + type: unlocked ? 'vault-unlocked' : 'vault-locked' + } + ] + } + + // Resources inside a vault: only annotate when the vault is actually + // unlocked (otherwise the user wouldn't be here in the first place — the + // unlock guard intercepts before any listing renders). + if (!unlocked) { + return + } + return [ + { + id: `vault-unlocked-${resource.getDomSelector?.() ?? resource.id}`, + kind: 'icon', + accessibleDescription: 'This item is inside an unlocked vault', + label: 'Vault unlocked', + icon: 'lock-unlock', + fillType: 'line', + category: 'system', + type: 'vault-unlocked' + } + ] + } +} diff --git a/packages/web-app-rclone-crypt/src/index.ts b/packages/web-app-rclone-crypt/src/index.ts new file mode 100644 index 0000000000..5c5e4a98ee --- /dev/null +++ b/packages/web-app-rclone-crypt/src/index.ts @@ -0,0 +1,73 @@ +import { useGettext } from 'vue3-gettext' +import { onBeforeUnmount, ref } from 'vue' +import { + ApplicationInformation, + defineWebApplication, + Extension, + useEventBus, + useFolderVaultStore +} from '@opencloud-eu/web-pkg' +import { Resource } from '@opencloud-eu/web-client' +import translations from '../l10n/translations.json' +import { folderVaultExtension } from './extensions/folderVault' +import { resourceIndicatorExtension } from './extensions/resourceIndicator' +import { lockVaultActionExtension, useLockVaultAction } from './extensions/lockVault' +import UnlockVault from './views/UnlockVault.vue' + +export default defineWebApplication({ + setup() { + const { $gettext } = useGettext() + + const appId = 'rclone-crypt' + + const appInfo: ApplicationInformation = { + name: $gettext('Rclone Crypt'), + id: appId, + icon: 'folder-lock', + iconFillType: 'line', + color: 'var(--oc-role-secondary)', + extensions: [ + { + extension: 'vault', + type: 'folder', + newFileMenu: { + menuTitle() { + return $gettext('Vault') + } + } + } + ] + } + + const lockVaultAction = useLockVaultAction() + const extensions = ref([ + folderVaultExtension, + resourceIndicatorExtension, + lockVaultActionExtension(lockVaultAction) + ]) + + const routes = [ + { + name: 'rclone-crypt-unlock', + // App routes are mounted under `//`, so this resolves to + // `/rclone-crypt/unlock`. + path: '/unlock', + component: UnlockVault, + meta: { + // hybrid so the same route works inside public links — the runtime's + // public-link auth guard runs first and prompts for the link + // password before we get to the vault passphrase. + authContext: 'hybrid', + title: $gettext('Unlock vault') + } + } + ] + + return { + appInfo, + translations, + extensions, + routes + } + } +}) diff --git a/packages/web-app-rclone-crypt/src/resolveVault.ts b/packages/web-app-rclone-crypt/src/resolveVault.ts new file mode 100644 index 0000000000..06ea115e93 --- /dev/null +++ b/packages/web-app-rclone-crypt/src/resolveVault.ts @@ -0,0 +1,73 @@ +import { FolderVaultClaim, FolderVaultEngine, useFolderVaultStore } from '@opencloud-eu/web-pkg' +import { + isPersonalSpaceResource, + isProjectSpaceResource, + SpaceResource +} from '@opencloud-eu/web-client' +import { createEngine } from './crypto/engine' + +// Identify the vault root from a clear-text path: the first segment ending +// in `.vault` marks the root. Anything outside such a segment is not a +// vault from this plugin's point of view. +function findVaultRoot(path: string): string | null { + const segments = path.split('/').filter(Boolean) + const idx = segments.findIndex((s) => s.endsWith('.vault')) + if (idx === -1) { + return null + } + return '/' + segments.slice(0, idx + 1).join('/') +} + +function isSupportedSpace(space: SpaceResource): boolean { + // PoC: only personal and project spaces. Public links, shares, trash etc. + // are out of scope for the first read iteration. + return isPersonalSpaceResource(space) || isProjectSpaceResource(space) +} + +/** + * Returns the vault root + the route the runtime should push to in order + * to unlock this vault. Used by the UI to detect "locked vault, please + * route the user to the plugin's unlock page" — even when `resolveVault` + * itself returns null because no passphrase is in the store yet. + */ +export function claimsVaultPath(space: SpaceResource, path: string): FolderVaultClaim | null { + if (!isSupportedSpace(space) || !path) { + return null + } + const vaultRoot = findVaultRoot(path) + if (!vaultRoot) { + return null + } + return { + vaultRoot, + unlockRoute: { + name: 'rclone-crypt-unlock', + query: { + spaceId: space.id as string, + vaultRoot + } + } + } +} + +/** + * Build a usable engine for (space, path) — or return null if the vault is + * not unlocked yet. The plugin's unlock page is responsible for writing the + * passphrase into the folder-vault store; once that's there, this function + * picks it up. + */ +export function resolveVault(space: SpaceResource, path: string): FolderVaultEngine | null { + if (!isSupportedSpace(space) || !path) { + return null + } + const vaultRoot = findVaultRoot(path) + if (!vaultRoot) { + return null + } + const vaultStore = useFolderVaultStore() + const password = vaultStore.getSecret(space.id as string, vaultRoot) + if (!password) { + return null + } + return createEngine(space.id as string, vaultRoot, password) +} diff --git a/packages/web-app-rclone-crypt/src/views/UnlockVault.vue b/packages/web-app-rclone-crypt/src/views/UnlockVault.vue new file mode 100644 index 0000000000..7819a6869f --- /dev/null +++ b/packages/web-app-rclone-crypt/src/views/UnlockVault.vue @@ -0,0 +1,202 @@ + + + diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue index 88d757eca1..b1d7e50ca1 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -47,6 +47,7 @@ import { useConfigStore, useResourcesStore, FileContentOptions, + FileContext, useFileActionsDownloadFile, FileActionOptions, FileAction, @@ -79,7 +80,12 @@ import { HttpError } from '@opencloud-eu/web-client' import { dirname } from 'path' import { useFileActionsOpenWithApp } from '../../composables' import { UnsavedChangesModal } from '../Modals' -import { formatFileSize, getSharedDriveItem } from '../../helpers' +import { + decryptResourceInPlace, + formatFileSize, + getSharedDriveItem, + resolveFolderVault +} from '../../helpers' import toNumber from 'lodash-es/toNumber' import { useIsMobile } from '@opencloud-eu/design-system/composables' import { storeToRefs } from 'pinia' @@ -142,7 +148,8 @@ const currentContent = ref() let deleteResourceEventToken = '' let appOnDeleteResourceCallback: (() => void) | null = null -const { registerExtensions, unregisterExtensions, requestExtensions } = useExtensionRegistry() +const extensionRegistry = useExtensionRegistry() +const { registerExtensions, unregisterExtensions, requestExtensions } = extensionRegistry const topBarExtensionId = 'app.app-wrapper.app-top-bar' const appBarExtension = computed(() => { if (unref(loading) || unref(loadingError) || !unref(resource)) { @@ -277,6 +284,26 @@ const addMissingDriveAliasAndItem = async () => { }) } +async function collectStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const out = new Uint8Array(total) + let offset = 0 + for (const c of chunks) { + out.set(c, offset) + offset += c.byteLength + } + return out +} + const loadResourceTask = useTask(function* (signal) { try { if (!unref(driveAliasAndItem)) { @@ -284,6 +311,17 @@ const loadResourceTask = useTask(function* (signal) { } space.value = unref(unref(currentFileContext).space) const fileInfo = yield getFileInfo(unref(currentFileContext), { signal }) + // Decrypt name/path/extension when the resource lives inside a vault, so + // every consumer of `resource.value` (top bar, side bar, document title, + // resource list…) sees cleartext. + const vaultEngine = resolveFolderVault( + extensionRegistry, + unref(space), + unref(unref(currentFileContext).item) + ) + if (vaultEngine) { + yield* call(decryptResourceInPlace(vaultEngine, fileInfo)) + } resource.value = fileInfo if (isShareSpaceResource(unref(space))) { @@ -354,18 +392,102 @@ const loadFileTask = useTask(function* (signal) { ) if (unref(hasProp('currentContent'))) { + // Vault-aware content handling. We do all of this above the webdav + // client: ask the server for the raw ciphertext blob using its real + // (encrypted) path, run it through the engine's decrypt stream, and + // hand the embedded app whatever response type it expected. The webdav + // primitives stay completely unaware of vaults. + const vaultEngine = resolveFolderVault( + extensionRegistry, + unref(space), + unref(resource)?.path + ) + const originalResponseType = fileContentOptions?.responseType + const fetchOptions = vaultEngine + ? { ...fileContentOptions, responseType: 'arraybuffer' as const } + : fileContentOptions + + // The webdav call needs the encrypted server-side path. resource.path is + // already cleartext at this point (we decrypted it in loadResourceTask), + // so we re-encrypt for this single request and leave the context the + // rest of the UI sees untouched. + const baseCtx = unref(currentFileContext) + const fetchCtx = vaultEngine + ? { ...baseCtx, item: yield* call(vaultEngine.encryptPath(unref(baseCtx.item))) } + : baseCtx + const fileContentsResponse = yield* call( - getFileContents(currentFileContext, { ...fileContentOptions, signal }) + getFileContents(fetchCtx, { ...fetchOptions, signal }) ) - serverContent.value = currentContent.value = fileContentsResponse.body + let body = fileContentsResponse.body + + if (vaultEngine) { + const encryptedBytes = new Uint8Array(body as ArrayBuffer) + const encryptedStream = new ReadableStream({ + start(controller) { + controller.enqueue(encryptedBytes) + controller.close() + } + }) + const plaintextStream = vaultEngine.decryptContent(encryptedStream) + const plaintext: Uint8Array = yield* call(collectStream(plaintextStream)) + + if (!originalResponseType || originalResponseType === 'text') { + body = new TextDecoder().decode(plaintext) + } else if (originalResponseType === 'blob') { + body = new Blob([plaintext as BlobPart]) + } else { + body = plaintext.buffer + } + } + + serverContent.value = currentContent.value = body currentETag.value = fileContentsResponse.headers['OC-ETag'] } if (unref(hasProp('url'))) { - url.value = yield getUrlForResource(unref(space), unref(resource), { - ...urlForResourceOptions, - signal - }) + // Vault-aware preview/download URL: getUrlForResource would otherwise + // hand the app a URL that downloads the *encrypted* blob from the + // server — broken for preview, mediaviewer, pdf-viewer etc. For vault + // resources we fetch ciphertext ourselves, run it through the engine + // and expose the cleartext as an in-memory blob URL. + const urlVaultEngine = resolveFolderVault( + extensionRegistry, + unref(space), + unref(resource)?.path + ) + if (urlVaultEngine) { + const baseCtx = unref(currentFileContext) + const fetchCtx = { + ...baseCtx, + item: yield* call(urlVaultEngine.encryptPath(unref(baseCtx.item))) + } + const cipherResponse = yield* call( + getFileContents(fetchCtx, { + ...fileContentOptions, + responseType: 'arraybuffer', + signal + }) + ) + const encryptedBytes = new Uint8Array(cipherResponse.body as ArrayBuffer) + const encryptedStream = new ReadableStream({ + start(controller) { + controller.enqueue(encryptedBytes) + controller.close() + } + }) + const plaintextStream = urlVaultEngine.decryptContent(encryptedStream) + const plaintext: Uint8Array = yield* call(collectStream(plaintextStream)) + const blob = new Blob([plaintext as BlobPart], { + type: unref(resource)?.mimeType || 'application/octet-stream' + }) + url.value = URL.createObjectURL(blob) + } else { + url.value = yield getUrlForResource(unref(space), unref(resource), { + ...urlForResourceOptions, + signal + }) + } } } catch (e) { console.error(e) @@ -427,10 +549,49 @@ const autosavePopup = () => { const saveFileTask = useTask(function* () { const newContent = unref(currentContent) try { - const putFileContentsResponse = yield putFileContents(currentFileContext, { - content: newContent as string, + // Vault-aware save: when the resource is inside a vault, the cleartext + // we have in memory has to be encrypted, the put has to target the + // encrypted server path, and the response describing the freshly stored + // blob has to be decrypted back to cleartext before it enters the store. + const vaultEngine = resolveFolderVault( + extensionRegistry, + unref(space), + unref(resource)?.path + ) + + let putCtx: FileContext | typeof currentFileContext = currentFileContext + let putContent: string | ArrayBuffer = newContent as string + + if (vaultEngine) { + const baseCtx = unref(currentFileContext) + const plainBytes = + newContent instanceof Uint8Array + ? (newContent as Uint8Array) + : newContent instanceof ArrayBuffer + ? new Uint8Array(newContent as ArrayBuffer) + : new TextEncoder().encode(newContent as string) + const plainStream = new ReadableStream({ + start(controller) { + controller.enqueue(plainBytes) + controller.close() + } + }) + const encryptedStream = vaultEngine.encryptContent(plainStream) + const encryptedBytes: Uint8Array = yield* call(collectStream(encryptedStream)) + putContent = encryptedBytes.buffer.slice( + encryptedBytes.byteOffset, + encryptedBytes.byteOffset + encryptedBytes.byteLength + ) as ArrayBuffer + putCtx = { ...baseCtx, item: yield* call(vaultEngine.encryptPath(unref(baseCtx.item))) } + } + + const putFileContentsResponse = yield putFileContents(putCtx, { + content: putContent, previousEntityTag: unref(currentETag) }) + if (vaultEngine) { + yield* call(decryptResourceInPlace(vaultEngine, putFileContentsResponse)) + } serverContent.value = newContent currentETag.value = putFileContentsResponse.etag resourcesStore.upsertResource(putFileContentsResponse) diff --git a/packages/web-pkg/src/composables/actions/files/useFileActions.ts b/packages/web-pkg/src/composables/actions/files/useFileActions.ts index 107b332ae5..145584b99c 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActions.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActions.ts @@ -125,6 +125,14 @@ export const useFileActions = () => { return false } + // An app may register a file/folder extension purely to contribute + // icon mapping or the new-file menu, without owning a route. In + // that case we skip the editor action so the default action (e.g. + // folder navigation) can take over. + if (!router.hasRoute(fileExtension.routeName || fileExtension.app)) { + return false + } + if (resources[0].extension && fileExtension.extension) { return resources[0].extension.toLowerCase() === fileExtension.extension.toLowerCase() } diff --git a/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts index a1682da0fd..5c3cbfaf34 100644 --- a/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts +++ b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts @@ -17,8 +17,10 @@ import { useModals, useSpacesStore, useConfigStore, + useExtensionRegistry, useResourcesStore } from '../../piniaStores' +import { resolveFolderVault } from '../../../helpers/folderVault' import { storeToRefs } from 'pinia' import { useDeleteWorker } from '../../webWorkers' import { useEventBus } from '../../eventBus' @@ -45,6 +47,27 @@ export const useFileActionsDeleteResources = () => { const resourcesStore = useResourcesStore() const { currentFolder } = storeToRefs(resourcesStore) + const extensionRegistry = useExtensionRegistry() + + // Resources surface in the UI with their cleartext paths after vault-aware + // decryption, but the delete worker is a vanilla webdav client that knows + // nothing about vaults. Translate any path that sits inside a vault back + // to its encrypted form before handing the resource off — the original + // (cleartext) instance stays in the store untouched for UI state. + const translatePathsForDelete = async ( + space: SpaceResource, + resources: Resource[] + ): Promise => { + return Promise.all( + resources.map(async (r) => { + const engine = resolveFolderVault(extensionRegistry, space, r.path) + if (!engine) return r + const encryptedPath = await engine.encryptPath(r.path) + if (encryptedPath === r.path) return r + return { ...r, path: encryptedPath } as Resource + }) + ) + } const resourcesToDelete = ref([]) @@ -226,9 +249,13 @@ export const useFileActionsDeleteResources = () => { const originalCurrentFolderId = unref(currentFolder)?.id return Object.values(resourceSpaceMapping).map( - ({ space: spaceForDeletion, resources: resourcesForDeletion }) => { + async ({ space: spaceForDeletion, resources: resourcesForDeletion }) => { + const workerResources = await translatePathsForDelete( + spaceForDeletion, + resourcesForDeletion + ) startWorker( - { topic: 'fileListDelete', space: spaceForDeletion, resources: resourcesForDeletion }, + { topic: 'fileListDelete', space: spaceForDeletion, resources: workerResources }, async ({ successful, failed }) => { if (successful.length) { showSuccessMessage({ diff --git a/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts b/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts index 15721e24fb..03f014681b 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts @@ -8,7 +8,8 @@ import { FileResource, SpaceResource } from '@opencloud-eu/web-client' import { useClientService } from '../clientService' import { ListFilesOptions } from '@opencloud-eu/web-client/webdav' import { WebDAV } from '@opencloud-eu/web-client/webdav' -import { useUserStore } from '../piniaStores' +import { useExtensionRegistry, useUserStore } from '../piniaStores' +import { resolveFolderVault } from '../../helpers/folderVault' interface AppFileHandlingOptions { clientService: ClientService @@ -31,7 +32,7 @@ export interface AppFileHandlingResult { getFileContents(fileContext: MaybeRef, options?: FileContentOptions): Promise putFileContents( fileContext: MaybeRef, - putFileOptions: { content?: string } & Record + putFileOptions: { content?: string | ArrayBuffer } & Record ): Promise } @@ -40,12 +41,54 @@ export function useAppFileHandling({ }: AppFileHandlingOptions): AppFileHandlingResult { clientService = clientService || useClientService() const userStore = useUserStore() + const extensionRegistry = useExtensionRegistry() - const getUrlForResource = ( + const getUrlForResource = async ( space: SpaceResource, resource: Resource, options?: UrlForResourceOptions - ) => { + ): Promise => { + // For vault resources, the server-side blob is ciphertext — neither a + // direct download URL nor a thumbnail can render the actual image. Fetch + // the encrypted blob, run it through the engine, and expose the cleartext + // bytes as an in-memory blob URL the embedded app can consume directly. + const vaultEngine = resolveFolderVault(extensionRegistry, space, resource?.path) + if (vaultEngine) { + const encryptedPath = await vaultEngine.encryptPath(resource.path) + const response = await clientService.webdav.getFileContents( + space, + { path: encryptedPath }, + { responseType: 'arraybuffer', signal: options?.signal } + ) + const encryptedBytes = new Uint8Array(response.body as ArrayBuffer) + const encryptedStream = new ReadableStream({ + start(controller) { + controller.enqueue(encryptedBytes) + controller.close() + } + }) + const plaintextStream = vaultEngine.decryptContent(encryptedStream) + const reader = plaintextStream.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + total += value.byteLength + } + const plaintext = new Uint8Array(total) + let offset = 0 + for (const c of chunks) { + plaintext.set(c, offset) + offset += c.byteLength + } + const blob = new Blob([plaintext as BlobPart], { + type: resource.mimeType || 'application/octet-stream' + }) + return URL.createObjectURL(blob) + } return clientService.webdav.getFileUrl(space, resource, { username: userStore.user?.onPremisesSamAccountName, ...options @@ -91,7 +134,7 @@ export function useAppFileHandling({ const putFileContents = ( fileContext: MaybeRef, - options: { content?: string; signal?: AbortSignal } & Record + options: { content?: string | ArrayBuffer; signal?: AbortSignal } & Record ) => { return clientService.webdav.putFileContents(unref(unref(fileContext).space), { path: unref(unref(fileContext).item), diff --git a/packages/web-pkg/src/composables/appDefaults/useAppFolderHandling.ts b/packages/web-pkg/src/composables/appDefaults/useAppFolderHandling.ts index ec0bda9644..3417757d6e 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppFolderHandling.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppFolderHandling.ts @@ -9,10 +9,11 @@ import { useFileRouteReplace } from '../router/useFileRouteReplace' import { DavProperty } from '@opencloud-eu/web-client/webdav' import { useAuthService } from '../authContext/useAuthService' import { isMountPointSpaceResource } from '@opencloud-eu/web-client' -import { useResourcesStore, useSpacesStore } from '../piniaStores' +import { useExtensionRegistry, useResourcesStore, useSpacesStore } from '../piniaStores' import { storeToRefs } from 'pinia' import { useRouteQuery } from '../router' import { useSearch } from '../search' +import { decryptResourceInPlace, resolveFolderVault } from '../../helpers/folderVault' interface AppFolderHandlingOptions { currentRoute: Ref @@ -40,6 +41,7 @@ export function useAppFolderHandling({ const currentRouteQuery = useRouteQuery('contextRouteQuery') const resourcesStore = useResourcesStore() + const extensionRegistry = useExtensionRegistry() const { activeResources } = storeToRefs(resourcesStore) const loadFolderForFileContext = async (context: MaybeRef) => { @@ -79,9 +81,20 @@ export function useAppFolderHandling({ resourcesStore.clearResourceList() const space = unref(context.space) - const pathResource = await getFileInfo(context, { + // FIXME(poc-vault): this app-open path duplicates a chunk of the + // loaderSpace flow. The vault-decrypt steps below should move into a + // shared layer once we lift vault-awareness out of every caller. + const vaultEngine = resolveFolderVault(extensionRegistry, space, unref(context.item)) + const baseCtx = unref(context) + const fetchCtx = vaultEngine + ? { ...baseCtx, item: await vaultEngine.encryptPath(unref(baseCtx.item)) } + : context + const pathResource = await getFileInfo(fetchCtx, { davProperties: [DavProperty.FileId] }) + if (vaultEngine) { + await decryptResourceInPlace(vaultEngine, pathResource) + } replaceInvalidFileRoute({ space, resource: pathResource, @@ -95,15 +108,27 @@ export function useAppFolderHandling({ if (isSpaceRoot) { const resource = await getFileInfo(context) + if (vaultEngine) { + await decryptResourceInPlace(vaultEngine, resource) + } resourcesStore.initResourceList({ currentFolder: resource, resources: [resource] }) isFolderLoading.value = false return } - const path = dirname(pathResource.path) + const cleartextParentPath = dirname(pathResource.path) + const listPath = vaultEngine + ? await vaultEngine.encryptPath(cleartextParentPath) + : cleartextParentPath const { resource, children } = await webdav.listFiles(space, { - path + path: listPath }) + if (vaultEngine) { + await decryptResourceInPlace(vaultEngine, resource) + for (const child of children) { + await decryptResourceInPlace(vaultEngine, child) + } + } if (resource.type === 'file') { resourcesStore.initResourceList({ diff --git a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts index 550f3e486d..bb83caf774 100644 --- a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts +++ b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts @@ -1,10 +1,12 @@ import { Action } from '../../actions' import { SearchProvider, SideBarPanel } from '../../../components' import { AppNavigationItem } from '../../../apps' -import { Item } from '@opencloud-eu/web-client' +import { Item, Resource, SpaceResource } from '@opencloud-eu/web-client' import { FolderView } from '../../../ui' import { Component, Slot } from 'vue' +import { RouteLocationNamedRaw } from 'vue-router' import { StringUnionOrAnyString } from '../../../utils' +import type { ResourceIndicator } from '../../resources/useResourceIndicators' export type ExtensionType = StringUnionOrAnyString< | 'action' @@ -16,6 +18,8 @@ export type ExtensionType = StringUnionOrAnyString< | 'sidebarPanel' | 'accountExtension' | 'floatingActionButton' + | 'folderVault' + | 'resourceIndicator' > export type Extension = { @@ -94,6 +98,84 @@ export interface AppMenuItemExtension extends Extension { url?: string } +/** + * Folder vault engine. Implementations decrypt resource names that come back + * from the server and encrypt clear-text paths that are sent to the server. + */ +export interface FolderVaultEngine { + /** Translate a clear-text path into its server-side encrypted form. */ + encryptPath: (clearPath: string) => Promise + /** Translate a server-side encrypted path back into its clear-text form. */ + decryptPath: (encryptedPath: string) => Promise + /** Decrypt a single path segment (e.g. a Resource.name). */ + decryptName: (encryptedSegment: string, parentClearPath: string) => Promise + /** Encrypt a single cleartext segment to its on-server form. */ + encryptName: (clearSegment: string, parentClearPath: string) => Promise + /** + * Pipe a stream of encrypted bytes through the engine and get back a + * stream of cleartext bytes. The engine is free to process the input + * chunk-by-chunk or to buffer everything internally — callers must not + * rely on either behaviour. + */ + decryptContent: (encrypted: ReadableStream) => ReadableStream + /** + * Symmetric counterpart to decryptContent: pipe cleartext through the + * engine and get back the encrypted byte stream that should land on the + * server. + */ + encryptContent: (plaintext: ReadableStream) => ReadableStream + /** Clear-text path of the vault root, e.g. `/myvault.vault`. */ + vaultRoot: string + /** Whether the vault is currently locked. PoC: always false. */ + isLocked: () => boolean + /** + * Try to decrypt a sample encrypted segment to verify the key actually + * matches the data on the server. Returns true if the decryption looks + * like cleartext, false if it errored out or produced garbage. + * Empty vaults can't be verified — callers should treat that case as + * "trust the key" since there's nothing to disagree with yet. + */ + verifyKey: (sampleEncryptedSegment: string) => Promise +} + +export interface FolderVaultClaim { + /** Clear-text root of the claimed vault (e.g. `/myvault.vault`). */ + vaultRoot: string + /** + * Optional route the UI should navigate to in order to unlock the vault + * (passphrase prompt, hardware-token flow, …). The route handler is + * expected to populate the folder-vault store and redirect back to + * `query.redirectUrl` once it's done. If omitted, the vault is treated as + * permanently locked from this layer's point of view. + */ + unlockRoute?: RouteLocationNamedRaw +} + +export interface FolderVaultExtension extends Extension { + type: 'folderVault' + /** + * Resolve a vault engine for (space, path). Return null if this extension is + * not responsible for the given location, or if it is but no usable + * unlock state is available (the UI will surface this via claimsPath). + */ + resolve: (space: SpaceResource, path: string) => FolderVaultEngine | null + /** + * Indicate whether this extension manages the given (space, path) at all, + * regardless of unlock state. Lets the UI redirect a locked vault to the + * extension-defined unlock UI even when `resolve` returns null. + */ + claimsPath: (space: SpaceResource, path: string) => FolderVaultClaim | null +} + +export interface ResourceIndicatorExtension extends Extension { + type: 'resourceIndicator' + /** + * Return zero or more status indicators for the resource. Return void/empty + * if this extension does not want to render anything for it. + */ + getResourceIndicators: (resource: Resource) => ResourceIndicator[] | void +} + export type ExtensionPoint = { id: string extensionType: ExtensionType diff --git a/packages/web-pkg/src/composables/piniaStores/folderVault.ts b/packages/web-pkg/src/composables/piniaStores/folderVault.ts new file mode 100644 index 0000000000..6c58a21873 --- /dev/null +++ b/packages/web-pkg/src/composables/piniaStores/folderVault.ts @@ -0,0 +1,69 @@ +import { defineStore } from 'pinia' +import { ref, unref } from 'vue' + +/** + * Holds the unlock secrets for vaults across the running session. Lives in + * memory only on purpose — page reload requires re-unlocking, which matches + * what most users expect from a passphrase-protected vault. Plugins (e.g. + * `web-app-rclone-crypt`) write into this store from their unlock UI, and + * `resolveVault()` inside the same plugin reads from it again to construct + * an engine. + * + * The "secret" payload is opaque to web-pkg — each extension decides what to + * stash there (a passphrase, a derived key, an OAuth token, …). + */ +export const useFolderVaultStore = defineStore('folder-vault', () => { + const secrets = ref>(new Map()) + + const buildKey = (spaceId: string, vaultRoot: string) => `${spaceId}::${vaultRoot}` + + const getSecret = (spaceId: string, vaultRoot: string): T | undefined => { + return unref(secrets).get(buildKey(spaceId, vaultRoot)) as T | undefined + } + + const setSecret = (spaceId: string, vaultRoot: string, secret: unknown) => { + unref(secrets).set(buildKey(spaceId, vaultRoot), secret) + // re-assign so Vue tracks the change + secrets.value = new Map(unref(secrets)) + } + + const clearSecret = (spaceId: string, vaultRoot: string) => { + unref(secrets).delete(buildKey(spaceId, vaultRoot)) + secrets.value = new Map(unref(secrets)) + } + + /** + * Drop every cached secret whose vault root sits at — or below — `pathPrefix` + * inside the given space. Called when a resource gets deleted: if the vault + * itself or any of its ancestors is gone, the cached passphrase is now + * stale and would unlock content that no longer exists (or, worse, content + * a different user re-created at the same name later). + */ + const clearSecretsUnder = (spaceId: string, pathPrefix: string) => { + if (!pathPrefix) return + // Normalise: treat "/foo" as covering "/foo" and "/foo/anything", but not + // "/foobar". + const prefix = pathPrefix.replace(/\/+$/, '') + const ownedKeyPrefix = `${spaceId}::` + let changed = false + for (const key of Array.from(unref(secrets).keys())) { + if (!key.startsWith(ownedKeyPrefix)) continue + const vaultRoot = key.slice(ownedKeyPrefix.length) + if (vaultRoot === prefix || vaultRoot.startsWith(`${prefix}/`)) { + unref(secrets).delete(key) + changed = true + } + } + if (changed) { + secrets.value = new Map(unref(secrets)) + } + } + + const isUnlocked = (spaceId: string, vaultRoot: string) => { + return unref(secrets).has(buildKey(spaceId, vaultRoot)) + } + + return { getSecret, setSecret, clearSecret, clearSecretsUnder, isUnlocked } +}) + +export type FolderVaultStore = ReturnType diff --git a/packages/web-pkg/src/composables/piniaStores/index.ts b/packages/web-pkg/src/composables/piniaStores/index.ts index 40e078f131..435991a855 100644 --- a/packages/web-pkg/src/composables/piniaStores/index.ts +++ b/packages/web-pkg/src/composables/piniaStores/index.ts @@ -5,6 +5,7 @@ export * from './capabilities' export * from './clipboard' export * from './config' export * from './extensionRegistry' +export * from './folderVault' export * from './groupware' export * from './messages' export * from './modals' diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index dfab943e8c..ade25aff44 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -9,8 +9,14 @@ import { SpaceResource } from '@opencloud-eu/web-client' import { useInterceptModifierClick } from '../keyboardActions' -import { useResourcesStore, useSideBar, useUserStore } from '../piniaStores' +import { + useExtensionRegistry, + useResourcesStore, + useSideBar, + useUserStore +} from '../piniaStores' import { IconFillType } from '../../helpers' +import { resourceIndicatorExtensionPoint } from '../../extensionPoints' export type ResourceIndicatorCategory = 'system' | 'sharing' | 'space' @@ -44,6 +50,7 @@ export const useResourceIndicators = () => { const { openSideBarPanel } = useSideBar() const resourcesStore = useResourcesStore() const userStore = useUserStore() + const extensionRegistry = useExtensionRegistry() const isUserShare = (shareTypes: number[]) => { return ShareTypes.containsAnyValue(ShareTypes.authenticated, shareTypes ?? []) @@ -259,6 +266,15 @@ export const useResourceIndicators = () => { } } + for (const extension of extensionRegistry.requestExtensions( + resourceIndicatorExtensionPoint + )) { + const extensionIndicators = extension.getResourceIndicators(resource) + if (extensionIndicators) { + indicators.push(...extensionIndicators) + } + } + return indicators } diff --git a/packages/web-pkg/src/editor/composables/useTextEditor.ts b/packages/web-pkg/src/editor/composables/useTextEditor.ts index 4286dbd5c6..249e630306 100644 --- a/packages/web-pkg/src/editor/composables/useTextEditor.ts +++ b/packages/web-pkg/src/editor/composables/useTextEditor.ts @@ -115,8 +115,18 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { const destroy = (): void => { if (debounceTimer) { clearTimeout(debounceTimer) - if (options.onUpdate && editor.value) { - options.onUpdate(strategy.serialize(editor.value)) + // The editor's view may already be torn down by tiptap's own + // onBeforeUnmount hook by the time we run, depending on hook + // ordering. In that case `serialize` blows up reading a null schema. + // Flush the pending update if it's still safe, otherwise drop it — + // throwing here would break the rest of Vue's teardown. + const inst = editor.value as unknown as { view?: { state?: unknown } } | null + if (options.onUpdate && editor.value && inst?.view?.state) { + try { + options.onUpdate(strategy.serialize(editor.value)) + } catch (e) { + console.warn('[useTextEditor] dropped pending update during destroy', e) + } } debounceTimer = null } diff --git a/packages/web-pkg/src/extensionPoints.ts b/packages/web-pkg/src/extensionPoints.ts index a5529b1b36..6a70da8b76 100644 --- a/packages/web-pkg/src/extensionPoints.ts +++ b/packages/web-pkg/src/extensionPoints.ts @@ -1,4 +1,8 @@ -import { CustomComponentExtension, ExtensionPoint } from './composables' +import { + CustomComponentExtension, + ExtensionPoint, + ResourceIndicatorExtension +} from './composables' import { computed } from 'vue' export const fileSideBarSpaceDetailsTableExtensionPoint: ExtensionPoint = @@ -7,8 +11,14 @@ export const fileSideBarSpaceDetailsTableExtensionPoint: ExtensionPoint = { + id: 'global.files.resource-indicator', + extensionType: 'resourceIndicator', + multiple: true +} + export const extensionPoints = () => { return computed[]>(() => { - return [fileSideBarSpaceDetailsTableExtensionPoint] + return [fileSideBarSpaceDetailsTableExtensionPoint, resourceIndicatorExtensionPoint] }) } diff --git a/packages/web-pkg/src/helpers/folderVault.ts b/packages/web-pkg/src/helpers/folderVault.ts new file mode 100644 index 0000000000..09ffce74c2 --- /dev/null +++ b/packages/web-pkg/src/helpers/folderVault.ts @@ -0,0 +1,150 @@ +import { unref } from 'vue' +import { extractExtensionFromFile, Resource, SpaceResource } from '@opencloud-eu/web-client' +import { DavPermission } from '@opencloud-eu/web-client/webdav' +import { + ExtensionRegistry, + FolderVaultClaim, + FolderVaultEngine, + FolderVaultExtension +} from '../composables/piniaStores/extensionRegistry' + +// FIXME(poc-vault): this lookup currently lives next to the loaders and the +// AppWrapper. Once we lift vault-aware translation onto a higher layer (e.g. +// a webdav/client decorator or a folderService decorator), callers stop +// needing to resolve the engine themselves. +export function resolveFolderVault( + extensionRegistry: ExtensionRegistry, + space: SpaceResource, + path: string | undefined +): FolderVaultEngine | null { + if (!space || !path) { + return null + } + const extensions = unref(extensionRegistry.extensions) + .flatMap((ref) => unref(ref)) + .filter((e): e is FolderVaultExtension => e.type === 'folderVault') + for (const ext of extensions) { + const engine = ext.resolve(space, path) + if (engine) { + return engine + } + } + return null +} + +/** + * Walks the registered folder-vault extensions and returns the first one + * that claims the given (space, path) — independent of unlock state. Callers + * use this to decide whether to render an "unlock first" redirect for a + * vault whose `resolve()` returned null (i.e. the extension owns the path + * but currently can't produce an engine). + */ +export function getVaultClaim( + extensionRegistry: ExtensionRegistry, + space: SpaceResource, + path: string | undefined +): FolderVaultClaim | null { + if (!space || !path) { + return null + } + const extensions = unref(extensionRegistry.extensions) + .flatMap((ref) => unref(ref)) + .filter((e): e is FolderVaultExtension => e.type === 'folderVault') + for (const ext of extensions) { + const claim = ext.claimsPath(space, path) + if (claim) { + return claim + } + } + return null +} + +// Tiny extension → mime-type lookup so vault resources show up with the +// right mime once we've recovered their cleartext extension. Mirrors the +// shapes the preview / mediaviewer / file-icon code checks against. +const MIME_BY_EXTENSION: Record = { + txt: 'text/plain', + md: 'text/markdown', + markdown: 'text/markdown', + html: 'text/html', + htm: 'text/html', + css: 'text/css', + csv: 'text/csv', + json: 'application/json', + xml: 'application/xml', + yml: 'text/yaml', + yaml: 'text/yaml', + pdf: 'application/pdf', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + mp3: 'audio/mpeg', + ogg: 'audio/ogg', + wav: 'audio/wav', + flac: 'audio/flac', + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + zip: 'application/zip', + epub: 'application/epub+zip' +} + +function guessMimeType(extension: string | undefined): string | undefined { + if (!extension) return undefined + return MIME_BY_EXTENSION[extension.toLowerCase()] +} + +/** + * Rewrite a Resource so its name / path / webDavPath / extension reflect the + * cleartext form. The server only knows the encrypted blob name, so anything + * that surfaces a name in the UI (file lists, app top bar, side bar, browser + * tab title) needs this to read like the user expects. + */ +export async function decryptResourceInPlace( + engine: FolderVaultEngine, + r: Resource | undefined | null +): Promise { + if (!r?.path) { + return r + } + const decryptedPath = await engine.decryptPath(r.path) + if (decryptedPath === r.path) { + return r + } + const segments = decryptedPath.split('/').filter(Boolean) + const encryptedSuffix = r.path + r.path = decryptedPath + r.name = segments[segments.length - 1] ?? r.name + if (r.webDavPath && r.webDavPath.endsWith(encryptedSuffix)) { + // webDavPath has shape ///. Swap the encrypted + // suffix for the decrypted one without parsing the dav-root prefix. + r.webDavPath = + r.webDavPath.slice(0, r.webDavPath.length - encryptedSuffix.length) + decryptedPath + } + // Re-derive the file extension from the cleartext name. The server only + // knows the encrypted blob name (no extension), so editor/app pickers + // would otherwise not match anything. + if (r.type !== 'folder' && !r.isFolder) { + r.extension = extractExtensionFromFile(r) + // The server reports the *ciphertext* mime-type for the encrypted blob + // (typically application/octet-stream). Preview/media apps usually + // gate on mimeType in addition to extension, so override it with a + // guess derived from the cleartext extension. Keeps preview, audio + // and image apps happy. + const guessed = guessMimeType(r.extension) + if (guessed) { + r.mimeType = guessed + } + } + // Sharing a vault entry would expose ciphertext blobs to other users with + // no way to read them. Strip the Shareable permission so canShare() + // returns false everywhere in the UI (action buttons, sidebar, etc.). + if (r.permissions) { + r.permissions = r.permissions.replace(DavPermission.Shareable, '') + } + return r +} diff --git a/packages/web-pkg/src/helpers/index.ts b/packages/web-pkg/src/helpers/index.ts index f0cb7d5585..cbee52bf4b 100644 --- a/packages/web-pkg/src/helpers/index.ts +++ b/packages/web-pkg/src/helpers/index.ts @@ -9,6 +9,7 @@ export * from './breadcrumbs' export * from './clipboardActions' export * from './datetime' export * from './download' +export * from './folderVault' export * from './filesize' export * from './fuse' export * from './locale' diff --git a/packages/web-pkg/src/services/folder/folderService.ts b/packages/web-pkg/src/services/folder/folderService.ts index b23a03c796..7c86283cf9 100644 --- a/packages/web-pkg/src/services/folder/folderService.ts +++ b/packages/web-pkg/src/services/folder/folderService.ts @@ -16,7 +16,9 @@ import { SharesStore, useSharesStore, useAuthService, - AuthServiceInterface + AuthServiceInterface, + useExtensionRegistry, + ExtensionRegistry } from '../../composables' import { unref } from 'vue' import { ClientService } from '../../services' @@ -42,6 +44,7 @@ export type TaskContext = { resourcesStore: ResourcesStore sharesStore: SharesStore authService: AuthServiceInterface + extensionRegistry: ExtensionRegistry } export interface FolderLoader { @@ -74,6 +77,7 @@ export class FolderService { const resourcesStore = useResourcesStore() const sharesStore = useSharesStore() const authService = useAuthService() + const extensionRegistry = useExtensionRegistry() const loader = this.loaders.find((l) => l.isEnabled() && l.isActive(unref(router))) if (!loader) { @@ -91,7 +95,8 @@ export class FolderService { resourcesStore, sharesStore, router, - authService + authService, + extensionRegistry } try { yield loader.getTask(context).perform(...args) diff --git a/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts b/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts index 9ba1c35eea..ae0ab7dde7 100644 --- a/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts +++ b/packages/web-pkg/src/services/folder/loaders/loaderSpace.ts @@ -16,6 +16,7 @@ import { DriveItem } from '@opencloud-eu/web-client/graph/generated' import { isLocationSpacesActive, isLocationPublicActive } from '../../../router' import { getSharedDriveItem, setCurrentUserShareSpacePermissions } from '../../../helpers' import { useFileRouteReplace } from '../../../composables' +import { decryptResourceInPlace, resolveFolderVault } from '../../../helpers/folderVault' import { DavProperties, DavProperty } from '@opencloud-eu/web-client/webdav' export class FolderLoaderSpace implements FolderLoader { @@ -42,7 +43,8 @@ export class FolderLoaderSpace implements FolderLoader { authService, spacesStore, sharesStore, - configStore + configStore, + extensionRegistry } = context const { webdav, graphAuthenticated: graphClient } = clientService const { replaceInvalidFileRoute } = useFileRouteReplace({ router }) @@ -63,10 +65,31 @@ export class FolderLoaderSpace implements FolderLoader { // needed for public links for make previews work davProperties.push(DavProperty.DownloadURL) } + + // FIXME(poc-vault): vault-aware path encryption belongs on a higher + // layer (folderService / a FolderLoader decorator). Keeping it inline + // here for the rclone-crypt PoC. + const vaultEngine = resolveFolderVault(extensionRegistry, space, path) + const effectivePath = vaultEngine ? yield* call(vaultEngine.encryptPath(path)) : path + // eslint-disable-next-line prefer-const let { resource: currentFolder, children: resources } = yield* call( - webdav.listFiles(space, { path, fileId }, { signal: signal1, davProperties }) + webdav.listFiles( + space, + { path: effectivePath, fileId }, + { signal: signal1, davProperties } + ) ) + + // FIXME(poc-vault): decrypt server-side names before anything else + // touches the resources, so the rest of the loader sees clear text. + if (vaultEngine) { + yield* call(decryptResourceInPlace(vaultEngine, currentFolder)) + for (const child of resources) { + yield* call(decryptResourceInPlace(vaultEngine, child)) + } + } + // if current folder has no id (= singe file public link) we must not correct the route if (currentFolder.id) { replaceInvalidFileRoute({ space, resource: currentFolder, path, fileId }) diff --git a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsDelete.spec.ts b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsDelete.spec.ts index b03a7d5f87..27e0a0e071 100644 --- a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsDelete.spec.ts +++ b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsDelete.spec.ts @@ -163,7 +163,7 @@ function getWrapper({ setup = () => undefined }: { deletePermanent?: boolean - filesListDeleteMock?: (resources: Resource[]) => void[] + filesListDeleteMock?: (resources: Resource[]) => Promise[] displayDialogMock?: (...args: unknown[]) => unknown setup?: () => unknown } = {}) { diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index fa03f5feaa..866add6f59 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -849,6 +849,7 @@ export const registerSSEEventListeners = ({ configStore, userStore, authStore, + extensionRegistry, router }: { language: Language @@ -861,6 +862,7 @@ export const registerSSEEventListeners = ({ configStore: ConfigStore userStore: UserStore authStore: AuthStore + extensionRegistry: ExtensionRegistry router: Router }): void => { const resourceQueue = new PQueue({ @@ -883,6 +885,7 @@ export const registerSSEEventListeners = ({ configStore, clientService, previewService, + extensionRegistry, language, router, resourceQueue, diff --git a/packages/web-runtime/src/container/sse/files.ts b/packages/web-runtime/src/container/sse/files.ts index 0f2386c557..29807d0741 100644 --- a/packages/web-runtime/src/container/sse/files.ts +++ b/packages/web-runtime/src/container/sse/files.ts @@ -1,7 +1,9 @@ import { createFileRouteOptions, + decryptResourceInPlace, ImageDimension, - isItemInCurrentFolder + isItemInCurrentFolder, + resolveFolderVault } from '@opencloud-eu/web-pkg' import { SSEEventOptions } from './types' @@ -76,6 +78,7 @@ export const onSSEProcessingFinishedEvent = async ({ resourcesStore, spacesStore, clientService, + extensionRegistry, resourceQueue, previewService }: SSEEventOptions) => { @@ -101,14 +104,19 @@ export const onSSEProcessingFinishedEvent = async ({ } return resourceQueue.add(async () => { - const { resource } = await clientService.webdav.listFiles(space, { + const { resource: fetched } = await clientService.webdav.listFiles(space, { path: '', fileId: sseData.itemid }) // check again for the current folder in case the user has navigated away in the meantime if (isItemInCurrentFolder({ resourcesStore, parentFolderId: sseData.parentitemid })) { - resourcesStore.upsertResource(resource) + const currentFolderPath = resourcesStore.currentFolder?.path + const vaultEngine = resolveFolderVault(extensionRegistry, space, currentFolderPath) + if (vaultEngine) { + await decryptResourceInPlace(vaultEngine, fetched) + } + resourcesStore.upsertResource(fetched) } }) } @@ -128,6 +136,13 @@ export const onSSEProcessingFinishedEvent = async ({ path: '', fileId: sseData.itemid }) + // The webdav response describes the encrypted server blob; if the existing + // resource is inside a vault, decrypt the incoming update before merging it + // back into the store so the editor / file list stay on cleartext data. + const vaultEngine = resolveFolderVault(extensionRegistry, space, resource.path) + if (vaultEngine) { + await decryptResourceInPlace(vaultEngine, updatedResource) + } resourcesStore.upsertResource(updatedResource) const preview = await previewService.loadPreview({ diff --git a/packages/web-runtime/src/container/sse/types.ts b/packages/web-runtime/src/container/sse/types.ts index eb7e65a833..72d20860b0 100644 --- a/packages/web-runtime/src/container/sse/types.ts +++ b/packages/web-runtime/src/container/sse/types.ts @@ -3,6 +3,7 @@ import { AuthStore, ClientService, ConfigStore, + ExtensionRegistry, MessageStore, PreviewService, ResourcesStore, @@ -36,6 +37,7 @@ export interface SSEEventOptions { authStore: AuthStore clientService: ClientService previewService: PreviewService + extensionRegistry: ExtensionRegistry router: Router language: Language resourceQueue: PQueue diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index 4f1de83c86..c43ba12e6e 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -212,6 +212,7 @@ export const bootstrapApp = async (configurationPath: string, appsReadyCallback: previewService, configStore, authStore, + extensionRegistry, router }) } diff --git a/packages/web-runtime/src/router/index.ts b/packages/web-runtime/src/router/index.ts index daba38ed2b..2f9a3c4fa5 100644 --- a/packages/web-runtime/src/router/index.ts +++ b/packages/web-runtime/src/router/index.ts @@ -7,6 +7,7 @@ import ResolvePublicLinkPage from '../pages/resolvePublicLink.vue' import ResolvePrivateLinkPage from '../pages/resolvePrivateLink.vue' import { setupRouterHooks } from './setupRouter' import { setupAuthGuard } from './setupAuthGuard' +import { setupVaultUnlockGuard } from './setupVaultUnlockGuard' import { patchRouter } from './patchCleanPath' import { routeNames } from './names' import { @@ -172,3 +173,4 @@ export const router = patchRouter( setupRouterHooks(router) setupAuthGuard(router) +setupVaultUnlockGuard(router) diff --git a/packages/web-runtime/src/router/setupVaultUnlockGuard.ts b/packages/web-runtime/src/router/setupVaultUnlockGuard.ts new file mode 100644 index 0000000000..568f291f70 --- /dev/null +++ b/packages/web-runtime/src/router/setupVaultUnlockGuard.ts @@ -0,0 +1,66 @@ +import { Router } from 'vue-router' +import { watch } from 'vue' +import { + getVaultClaim, + resolveFolderVault, + useExtensionRegistry, + useSpacesStore +} from '@opencloud-eu/web-pkg' + +/** + * Global navigation guard that intercepts any navigation aimed at a path + * inside a folder-vault that's been claimed by a plugin but is not unlocked + * yet. The plugin-defined unlock route gets the user's intended URL via + * `redirectUrl` and pushes back there once unlocking succeeds. + * + * Runs after `setupAuthGuard`, so by the time we get here the auth context + * is ready and the spaces store is populated (or we're navigating to a + * route that doesn't need user-context anyway — public links etc. resolve + * their own context first, and the vault guard kicks in afterwards if the + * target lives inside a vault). + */ +export const setupVaultUnlockGuard = (router: Router) => { + router.beforeEach(async (to) => { + const driveAliasAndItem = to.params?.driveAliasAndItem as string | undefined + if (!driveAliasAndItem) return true + if (to.name === 'rclone-crypt-unlock') return true + + const spacesStore = useSpacesStore() + const extensionRegistry = useExtensionRegistry() + + // On a hard reload this guard fires while the spaces store is still + // initialising; without waiting we'd see an empty spaces list, fail to + // match the target drive, and let the user into a vault path without an + // unlock prompt. Wait until `spacesInitialized` flips before deciding. + if (!spacesStore.spacesInitialized) { + await new Promise((resolve) => { + const stop = watch( + () => spacesStore.spacesInitialized, + (initialised) => { + if (initialised) { + stop() + resolve() + } + }, + { immediate: true } + ) + }) + } + + const space = spacesStore.spaces.find((s) => + driveAliasAndItem === s.driveAlias || driveAliasAndItem.startsWith(`${s.driveAlias}/`) + ) + if (!space) return true + const path = '/' + driveAliasAndItem.slice(space.driveAlias.length).replace(/^\/+/, '') + + const engine = resolveFolderVault(extensionRegistry, space, path) + if (engine) return true + const claim = getVaultClaim(extensionRegistry, space, path) + if (!claim?.unlockRoute) return true + + return { + ...claim.unlockRoute, + query: { ...(claim.unlockRoute.query || {}), redirectUrl: to.fullPath } + } + }) +} diff --git a/packages/web-runtime/tests/unit/container/sse/files.spec.ts b/packages/web-runtime/tests/unit/container/sse/files.spec.ts index 225964a7ec..1fecf35ffb 100644 --- a/packages/web-runtime/tests/unit/container/sse/files.spec.ts +++ b/packages/web-runtime/tests/unit/container/sse/files.spec.ts @@ -3,6 +3,7 @@ import { PreviewService, useAuthStore, useConfigStore, + useExtensionRegistry, useMessages, useResourcesStore, useSharesStore, @@ -444,6 +445,7 @@ const getMocks = ({ const sharesStore = useSharesStore() const configStore = useConfigStore() const authStore = useAuthStore() + const extensionRegistry = useExtensionRegistry() const clientService = mockDeep({ initiatorId: 'local1' }) const previewService = mockDeep() const router = mockDeep() @@ -463,6 +465,7 @@ const getMocks = ({ authStore, clientService, previewService, + extensionRegistry, resourceQueue, language } diff --git a/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts b/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts index c66e9ccd8a..1388b977f5 100644 --- a/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts +++ b/packages/web-runtime/tests/unit/container/sse/helpers.spec.ts @@ -5,6 +5,7 @@ import { PreviewService, useAuthStore, useConfigStore, + useExtensionRegistry, useMessages, useResourcesStore, useSharesStore, @@ -65,6 +66,7 @@ const getMocks = ({ const configStore = useConfigStore() const sharesStore = useSharesStore() const authStore = useAuthStore() + const extensionRegistry = useExtensionRegistry() const clientService = mockDeep() const previewService = mockDeep() const router = mockDeep() @@ -82,6 +84,7 @@ const getMocks = ({ authStore, clientService, previewService, + extensionRegistry, resourceQueue, language } diff --git a/packages/web-runtime/tests/unit/container/sse/shares.spec.ts b/packages/web-runtime/tests/unit/container/sse/shares.spec.ts index 50a27aaa4c..877f4dbe84 100644 --- a/packages/web-runtime/tests/unit/container/sse/shares.spec.ts +++ b/packages/web-runtime/tests/unit/container/sse/shares.spec.ts @@ -4,6 +4,7 @@ import { PreviewService, useAuthStore, useConfigStore, + useExtensionRegistry, useMessages, useResourcesStore, useSharesStore, @@ -703,6 +704,7 @@ const getMocks = ({ userStore.user = mockDeep({ id: '1' }) const authStore = useAuthStore() const sharesStore = useSharesStore() + const extensionRegistry = useExtensionRegistry() const clientService = mockDeep({ initiatorId: 'local1' }) const previewService = mockDeep() @@ -742,6 +744,7 @@ const getMocks = ({ authStore, clientService, previewService, + extensionRegistry, resourceQueue, language } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a21712cf8c..2695a0ba78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -706,6 +706,24 @@ importers: specifier: workspace:* version: link:../web-test-helpers + packages/web-app-rclone-crypt: + dependencies: + '@fyears/rclone-crypt': + specifier: ^0.0.7 + version: 0.0.7 + '@opencloud-eu/web-client': + specifier: workspace:* + version: link:../web-client + '@opencloud-eu/web-pkg': + specifier: workspace:* + version: link:../web-pkg + vue: + specifier: ^3.5.0 + version: 3.5.34(typescript@6.0.3) + vue3-gettext: + specifier: ^4.0.0-beta.1 + version: 4.0.0-beta.1(@vue/compiler-sfc@3.5.34)(vue@3.5.34(typescript@6.0.3)) + packages/web-app-search: dependencies: '@opencloud-eu/design-system': @@ -1767,6 +1785,12 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fyears/eme@0.0.3': + resolution: {integrity: sha512-DOiTLGdNHBaR+SONltsJSRzbHvD0HgndrO2eOL+gOMOsOCNtyIo8e/OgmIXP0AHRw1gescmXl2xn/xK9H2gpZA==} + + '@fyears/rclone-crypt@0.0.7': + resolution: {integrity: sha512-WHdoBtiKUdgNoyjJz//2cpldBpp9FjkHv4weUDIGdUZW6oMQq/hDiDjz3ICKuK5i5kXuVdYmaoruVpnRcHtwMw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1867,6 +1891,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@0.5.3': + resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} @@ -3146,6 +3177,9 @@ packages: base-64@1.0.0: resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base32768@3.0.1: + resolution: {integrity: sha512-dNGY49X0IKN1kDl9y/6sii1Vced+f+4uAqOeRz/PshjNdPwSD+ntnHOg/YgDbLSZetp94d/XxGdpfbXDKv8BVQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4779,6 +4813,9 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + pkcs7-padding@0.1.1: + resolution: {integrity: sha512-tM/sUOL5FdH7x6gSLHTNDFi1bAut/EXGFp/Ih8uRkVd2IdXwb5QWnxBgQXn6buadj1pLi3CYKCnrxb+XoTz+Ww==} + pkg-dir@5.0.0: resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} engines: {node: '>=10'} @@ -5015,6 +5052,9 @@ packages: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} + rfc4648@1.5.4: + resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -6418,6 +6458,19 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@fyears/eme@0.0.3': + dependencies: + '@noble/ciphers': 0.5.3 + + '@fyears/rclone-crypt@0.0.7': + dependencies: + '@fyears/eme': 0.0.3 + '@noble/ciphers': 0.5.3 + '@noble/hashes': 1.8.0 + base32768: 3.0.1 + pkcs7-padding: 0.1.1 + rfc4648: 1.5.4 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -6564,6 +6617,10 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@noble/ciphers@0.5.3': {} + + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} '@nodable/entities@2.1.0': {} @@ -7769,6 +7826,8 @@ snapshots: base-64@1.0.0: {} + base32768@3.0.1: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.31: {} @@ -9488,6 +9547,8 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + pkcs7-padding@0.1.1: {} + pkg-dir@5.0.0: dependencies: find-up: 5.0.0 @@ -9765,6 +9826,8 @@ snapshots: retry@0.13.1: {} + rfc4648@1.5.4: {} + rfdc@1.4.1: {} ripemd160@2.0.3: diff --git a/tests/e2e/cucumber/features/rclone-crypt/readVault.feature b/tests/e2e/cucumber/features/rclone-crypt/readVault.feature new file mode 100644 index 0000000000..28ad0a914e --- /dev/null +++ b/tests/e2e/cucumber/features/rclone-crypt/readVault.feature @@ -0,0 +1,65 @@ +Feature: Read an rclone-crypt encrypted vault + As a user with an existing rclone-crypt-encrypted folder on the server + I want to browse it in OpenCloud Web with cleartext names + So that I can work with my encrypted files without decrypting them manually + + @rclone-crypt + Scenario: Browse a vault and see decrypted folder/file names + Given "Admin" creates an rclone-crypt vault "myvault.vault" in personal space with the following content + | path | content | + | hello.txt | hello world | + | sub/nested.txt | nested file content | + When "Admin" logs in + And "Admin" navigates to the personal space page + Then following resources should be displayed in the files list for user "Admin" + | resource | + | myvault.vault | + When "Admin" enters the vault "myvault.vault" with passphrase "foobar" + Then following resources should be displayed in the files list for user "Admin" + | resource | + | hello.txt | + | sub | + When "Admin" opens folder "sub" + Then following resources should be displayed in the files list for user "Admin" + | resource | + | nested.txt | + When "Admin" opens the following file in texteditor + | resource | + | nested.txt | + Then "Admin" should see the text editor content "nested file content" + + When "Admin" replaces the text editor content with "rewritten through the vault" + And "Admin" saves the text editor file + Then the rclone-crypt vault "myvault.vault" file "sub/nested.txt" should decrypt to "rewritten through the vault" + + @rclone-crypt + Scenario: Upload a file into a vault encrypts it on the server + Given "Admin" creates an rclone-crypt vault "myvault.vault" in personal space with the following content + | path | content | + | hello.txt | hello world | + | sub/nested.txt | nested file content | + When "Admin" logs in + And "Admin" navigates to the personal space page + And "Admin" enters the vault "myvault.vault" with passphrase "foobar" + And "Admin" opens folder "sub" + And "Admin" uploads a file named "uploaded.txt" with content "freshly uploaded content" via the upload button + Then following resources should be displayed in the files list for user "Admin" + | resource | + | nested.txt | + | uploaded.txt | + And the rclone-crypt vault "myvault.vault" file "sub/uploaded.txt" should decrypt to "freshly uploaded content" + + @rclone-crypt + Scenario: Drag-drop a directory tree into a vault + Given "Admin" creates an rclone-crypt vault "myvault.vault" in personal space with the following content + | path | content | + | hello.txt | hello world | + When "Admin" logs in + And "Admin" navigates to the personal space page + And "Admin" enters the vault "myvault.vault" with passphrase "foobar" + And "Admin" drag-drop uploads the following directory tree + | path | content | + | mybundle/inner.txt | inner content | + | mybundle/deeper/nested.txt | deeper content | + Then the rclone-crypt vault "myvault.vault" file "mybundle/inner.txt" should decrypt to "inner content" + And the rclone-crypt vault "myvault.vault" file "mybundle/deeper/nested.txt" should decrypt to "deeper content" diff --git a/tests/e2e/cucumber/steps/rcloneCrypt.ts b/tests/e2e/cucumber/steps/rcloneCrypt.ts new file mode 100644 index 0000000000..d6c021b003 --- /dev/null +++ b/tests/e2e/cucumber/steps/rcloneCrypt.ts @@ -0,0 +1,296 @@ +import { Given, DataTable, Then, When } from '@cucumber/cucumber' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect } from '@playwright/test' +import { World } from '../environment' +import { config } from '../../config' +import { dragDropFiles } from '../../support/utils/dragDrop' + +// Builds an rclone-crypt encrypted vault directly on the OpenCloud backend +// using the rclone CLI. The web-app-rclone-crypt plugin should then render +// the cleartext names back in the UI. +// +// Password / encoding settings match the PoC defaults hardcoded in +// `packages/web-app-rclone-crypt/src/resolveVault.ts`: password "foobar", +// no salt, base32 filename encoding. +const VAULT_PASSWORD = 'foobar' + +function runRclone(args: string[]): string { + const result = spawnSync('rclone', args, { encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error( + `rclone ${args.join(' ')} failed (exit ${result.status}):\n` + + `stdout: ${result.stdout}\nstderr: ${result.stderr}` + ) + } + return result.stdout +} + +Given( + '{string} creates an rclone-crypt vault {string} in personal space with the following content', + async function ( + this: World, + stepUser: string, + vaultName: string, + stepTable: DataTable + ): Promise { + if (!vaultName.endsWith('.vault')) { + throw new Error( + `vault name "${vaultName}" must end with .vault — the plugin uses that suffix to detect vaults` + ) + } + + const user = this.usersEnvironment.getUser({ key: stepUser }) + const workdir = mkdtempSync(join(tmpdir(), 'rclone-crypt-fixture-')) + const configFile = join(workdir, 'rclone.conf') + + try { + const obscuredPassword = runRclone(['obscure', VAULT_PASSWORD]).trim() + const obscuredUserPassword = runRclone(['obscure', user.password]).trim() + + writeFileSync( + configFile, + [ + '[oc]', + 'type = webdav', + `url = ${config.baseUrl}/dav/files/${user.username}`, + 'vendor = other', + `user = ${user.username}`, + `pass = ${obscuredUserPassword}`, + '', + '[oc-crypt]', + 'type = crypt', + `remote = oc:${vaultName}`, + `password = ${obscuredPassword}`, + '' + ].join('\n') + ) + + const baseFlags = ['--config', configFile, '--no-check-certificate'] + + // Make reruns deterministic. + const purge = spawnSync('rclone', [...baseFlags, 'purge', `oc:${vaultName}`], { + encoding: 'utf8' + }) + // Ignore exit status — first run won't have anything to purge. + void purge + + runRclone([...baseFlags, 'mkdir', `oc-crypt:`]) + + for (const row of stepTable.hashes()) { + const localFile = join(workdir, `entry-${row.path.replace(/[^a-z0-9]/gi, '_')}`) + writeFileSync(localFile, row.content ?? '') + runRclone([...baseFlags, 'copyto', localFile, `oc-crypt:${row.path}`]) + } + } finally { + rmSync(workdir, { recursive: true, force: true }) + } + } +) + +Then( + 'the rclone-crypt vault {string} file {string} should decrypt to {string}', + async function ( + this: World, + vaultName: string, + pathInVault: string, + expectedContent: string + ): Promise { + const user = this.usersEnvironment.getUser({ key: 'Admin' }) + const workdir = mkdtempSync(join(tmpdir(), 'rclone-crypt-check-')) + const configFile = join(workdir, 'rclone.conf') + try { + const obscuredPassword = runRclone(['obscure', VAULT_PASSWORD]).trim() + const obscuredUserPassword = runRclone(['obscure', user.password]).trim() + writeFileSync( + configFile, + [ + '[oc]', + 'type = webdav', + `url = ${config.baseUrl}/dav/files/${user.username}`, + 'vendor = other', + `user = ${user.username}`, + `pass = ${obscuredUserPassword}`, + '', + '[oc-crypt]', + 'type = crypt', + `remote = oc:${vaultName}`, + `password = ${obscuredPassword}`, + '' + ].join('\n') + ) + const out = runRclone([ + '--config', + configFile, + '--no-check-certificate', + 'cat', + `oc-crypt:${pathInVault}` + ]) + expect(out.replace(/\n+$/, '')).toBe(expectedContent.replace(/\n+$/, '')) + } finally { + rmSync(workdir, { recursive: true, force: true }) + } + } +) + +When( + '{string} enters the vault {string} with passphrase {string}', + async function ( + this: World, + stepUser: string, + vaultName: string, + passphrase: string + ): Promise { + const { page } = this.actorsEnvironment.getActor({ key: stepUser }) + // Clicking the vault folder kicks the DriveResolver into a redirect to + // /rclone-crypt/unlock — we wait for the unlock URL instead of for a + // PROPFIND like the generic "opens folder" step does. + await Promise.all([ + page.waitForURL((url) => url.pathname.includes('/rclone-crypt/unlock'), { + timeout: 10_000 + }), + page.locator(`[data-test-resource-name="${vaultName}"]`).first().click() + ]) + await page.locator('input[type="password"]').fill(passphrase) + await Promise.all([ + page.waitForURL((url) => !url.pathname.includes('/rclone-crypt/unlock'), { + timeout: 10_000 + }), + page.locator('#vault-unlock-submit').click() + ]) + } +) + +Then( + '{string} should see the text editor content {string}', + async function (this: World, stepUser: string, expectedContent: string): Promise { + const { page } = this.actorsEnvironment.getActor({ key: stepUser }) + const editor = page.locator('.text-editor-provider .ProseMirror') + await editor.waitFor() + await expect(editor).toHaveText(expectedContent) + } +) + +When( + '{string} replaces the text editor content with {string}', + async function (this: World, stepUser: string, newContent: string): Promise { + const { page } = this.actorsEnvironment.getActor({ key: stepUser }) + const editor = page.locator('.text-editor-provider .ProseMirror') + await editor.waitFor() + // ProseMirror only flips the doc dirty on real input events. type() + // raises keydown but no beforeinput/input, which is what tiptap listens + // for — insertText() simulates IME input which ProseMirror picks up. + await editor.click() + await page.keyboard.press('ControlOrMeta+A') + await page.keyboard.press('Delete') + await page.keyboard.insertText(newContent) + await expect(editor).toHaveText(newContent) + } +) + +When( + '{string} drag-drop uploads the following directory tree', + async function (this: World, stepUser: string, stepTable: DataTable): Promise { + const { page } = this.actorsEnvironment.getActor({ key: stepUser }) + const workdir = mkdtempSync(join(tmpdir(), 'vault-folderupload-')) + try { + // Lay out the tree on disk; remember the top-level entries so we can + // hand them to the drag-drop helper (it walks directories itself). + const topLevel = new Set() + const expectedFiles: string[] = [] + for (const row of stepTable.hashes()) { + const filePath = join(workdir, row.path) + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, row.content ?? '') + const segments = row.path.split('/').filter(Boolean) + topLevel.add(segments[0]) + expectedFiles.push(row.path) + } + + const resources = Array.from(topLevel).map((name) => ({ + name, + path: join(workdir, name) + })) + + // dragDropFiles dispatches a drop event on #files-view with a + // DataTransfer containing the whole tree (including webkitRelativePath). + // Uppy / HandleUpload consume it like any other upload. + const respPromise = page.waitForResponse( + (resp) => + [201, 204].includes(resp.status()) && + ['POST', 'PUT', 'PATCH'].includes(resp.request().method()) + ) + await dragDropFiles(page, resources, '#files-view') + await respPromise + + // Wait for the deepest cleartext leaf to surface in the listing so we + // know the directoryTree creation + content uploads ran to completion. + const leaf = expectedFiles[0].split('/').filter(Boolean)[0] + await page + .locator(`[data-test-resource-name="${leaf}"]`) + .first() + .waitFor({ timeout: 15_000 }) + } finally { + rmSync(workdir, { recursive: true, force: true }) + } + } +) + +When( + '{string} uploads a file named {string} with content {string} via the upload button', + async function ( + this: World, + stepUser: string, + fileName: string, + content: string + ): Promise { + const { page } = this.actorsEnvironment.getActor({ key: stepUser }) + const workdir = mkdtempSync(join(tmpdir(), 'vault-upload-')) + const localFile = join(workdir, fileName) + writeFileSync(localFile, content) + try { + const respPromise = page.waitForResponse( + (resp) => + [201, 204].includes(resp.status()) && + ['POST', 'PUT', 'PATCH'].includes(resp.request().method()) + ) + // Same dance as the existing performUpload helper: open the FAB menu + // first so the hidden input element actually accepts files. + await page.locator('.oc-app-floating-action-button').click() + await page.locator('#files-file-upload-input').setInputFiles(localFile) + await respPromise + await page + .locator(`[data-test-resource-name="${fileName}"]`) + .waitFor({ timeout: 10_000 }) + } finally { + rmSync(workdir, { recursive: true, force: true }) + } + } +) + +When( + '{string} saves the text editor file', + async function (this: World, stepUser: string): Promise { + const { page } = this.actorsEnvironment.getActor({ key: stepUser }) + // Ctrl+S in the editor area is consumed by tiptap before it can bubble + // to the document-level keydown listener that drives saveFileTask. Open + // the top-bar action menu and click "Save" instead — same handler, just + // routed through the UI. + await page.locator('#oc-openfile-contextmenu-trigger').click() + // The dropdown rendering duplicates the action button (light + chrome + // variants), so address the one inside the dropdown. + const [putResponse] = await Promise.all([ + page.waitForResponse((resp) => resp.request().method() === 'PUT'), + page.locator('#oc-openfile-contextmenu #app-save-action').click() + ]) + if (!putResponse.ok()) { + throw new Error( + `PUT for save failed: ${putResponse.status()} ${putResponse.url()}\n` + + (await putResponse.text()) + ) + } + } +) + From cec7ee7849bcff87517131ef1e9eff62194e4a12 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 20 May 2026 07:13:14 +0200 Subject: [PATCH 2/6] feat(rclone-crypt): add unlock-vault context action --- .../src/extensions/lockVault.ts | 48 +++++++++++++++++++ packages/web-app-rclone-crypt/src/index.ts | 11 ++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/web-app-rclone-crypt/src/extensions/lockVault.ts b/packages/web-app-rclone-crypt/src/extensions/lockVault.ts index 269c79b0ba..d7f808e34b 100644 --- a/packages/web-app-rclone-crypt/src/extensions/lockVault.ts +++ b/packages/web-app-rclone-crypt/src/extensions/lockVault.ts @@ -72,3 +72,51 @@ export function lockVaultActionExtension(action: Ref): ActionExtensi } } as ActionExtension } + +export const useUnlockVaultAction = (): Ref => { + const { $gettext } = useGettext() + const vaultStore = useFolderVaultStore() + const router = useRouter() + + return computed(() => ({ + name: 'unlock-vault', + icon: 'lock-unlock', + iconFillType: 'line', + label: () => $gettext('Unlock vault'), + category: 'tertiary', + handler: ({ resources }: FileActionOptions) => { + const resource = resources?.[0] + if (!resource || !isVaultRoot(resource)) return + // Send the user through the unlock screen but bring them back to the + // current (parent) location, not into the vault. That keeps the + // surrounding listing in view — the vault entry just flips from locked + // to unlocked. Mirror image of the lock action. + const back = unref(router.currentRoute).fullPath + router.push({ + name: 'rclone-crypt-unlock', + query: { + spaceId: resource.storageId as string, + vaultRoot: resource.path, + redirectUrl: back + } + }) + }, + isVisible: ({ resources }: FileActionOptions) => { + const resource = resources?.[0] + if (!resource || !isVaultRoot(resource)) return false + return !vaultStore.isUnlocked(resource.storageId as string, resource.path) + }, + class: 'oc-files-actions-unlock-vault' + })) +} + +export function unlockVaultActionExtension(action: Ref): ActionExtension { + return { + id: 'app.rclone-crypt.unlock-vault', + type: 'action', + extensionPointIds: ['global.files.context-actions'], + get action() { + return unref(action) + } + } as ActionExtension +} diff --git a/packages/web-app-rclone-crypt/src/index.ts b/packages/web-app-rclone-crypt/src/index.ts index 5c5e4a98ee..86cc632e6c 100644 --- a/packages/web-app-rclone-crypt/src/index.ts +++ b/packages/web-app-rclone-crypt/src/index.ts @@ -11,7 +11,12 @@ import { Resource } from '@opencloud-eu/web-client' import translations from '../l10n/translations.json' import { folderVaultExtension } from './extensions/folderVault' import { resourceIndicatorExtension } from './extensions/resourceIndicator' -import { lockVaultActionExtension, useLockVaultAction } from './extensions/lockVault' +import { + lockVaultActionExtension, + unlockVaultActionExtension, + useLockVaultAction, + useUnlockVaultAction +} from './extensions/lockVault' import UnlockVault from './views/UnlockVault.vue' export default defineWebApplication({ @@ -40,10 +45,12 @@ export default defineWebApplication({ } const lockVaultAction = useLockVaultAction() + const unlockVaultAction = useUnlockVaultAction() const extensions = ref([ folderVaultExtension, resourceIndicatorExtension, - lockVaultActionExtension(lockVaultAction) + lockVaultActionExtension(lockVaultAction), + unlockVaultActionExtension(unlockVaultAction) ]) const routes = [ From 41daaf385cde6f413feaee97f71c31fdd59328b0 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 20 May 2026 12:58:37 +0200 Subject: [PATCH 3/6] test(rclone-crypt): rename readVault.feature to vault.feature --- .../rclone-crypt/{readVault.feature => vault.feature} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename tests/e2e/cucumber/features/rclone-crypt/{readVault.feature => vault.feature} (94%) diff --git a/tests/e2e/cucumber/features/rclone-crypt/readVault.feature b/tests/e2e/cucumber/features/rclone-crypt/vault.feature similarity index 94% rename from tests/e2e/cucumber/features/rclone-crypt/readVault.feature rename to tests/e2e/cucumber/features/rclone-crypt/vault.feature index 28ad0a914e..e7e0459f81 100644 --- a/tests/e2e/cucumber/features/rclone-crypt/readVault.feature +++ b/tests/e2e/cucumber/features/rclone-crypt/vault.feature @@ -1,6 +1,6 @@ -Feature: Read an rclone-crypt encrypted vault - As a user with an existing rclone-crypt-encrypted folder on the server - I want to browse it in OpenCloud Web with cleartext names +Feature: Work with an rclone-crypt encrypted vault + As a user with rclone-crypt-encrypted folders on the server + I want to browse, edit and upload through OpenCloud Web under cleartext names So that I can work with my encrypted files without decrypting them manually @rclone-crypt From 6ab39e9576f4f216a26e3ce01416d6c106c82ba0 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 20 May 2026 13:34:11 +0200 Subject: [PATCH 4/6] refactor(rclone-crypt): consume engine streams via streamTo* helpers Replaces the hand-rolled collectStream loops and single-emit ReadableStream wrappings in every vault encrypt/decrypt call site with two small helpers (streamToArrayBuffer, streamToBlob) plus Blob.stream() for input. The engine's streaming contract stays unchanged; only the call sites get tidier and the future "swap engine for real block streaming" lands without touching them. --- packages/web-app-files/src/HandleUpload.ts | 53 +++++------ .../files/useFileActionsCreateNewFile.ts | 33 +------ .../components/AppTemplates/AppWrapper.vue | 94 +++++++------------ .../appDefaults/useAppFileHandling.ts | 35 ++----- packages/web-pkg/src/helpers/index.ts | 1 + packages/web-pkg/src/helpers/streams.ts | 29 ++++++ 6 files changed, 97 insertions(+), 148 deletions(-) create mode 100644 packages/web-pkg/src/helpers/streams.ts diff --git a/packages/web-app-files/src/HandleUpload.ts b/packages/web-app-files/src/HandleUpload.ts index 4f58897cdc..aad103bf28 100644 --- a/packages/web-app-files/src/HandleUpload.ts +++ b/packages/web-app-files/src/HandleUpload.ts @@ -19,7 +19,8 @@ import { OcUppyFile, OcUppyMeta, OcUppyBody, - resolveFolderVault + resolveFolderVault, + streamToBlob } from '@opencloud-eu/web-pkg' import { locationSpacesGeneric, UppyService } from '@opencloud-eu/web-pkg' import { isPersonalSpaceResource, isShareSpaceResource } from '@opencloud-eu/web-client' @@ -537,25 +538,6 @@ export class HandleUpload extends BasePlugin this.uppyService.removeUploadFolder(uploadId) } - private async collectStream(stream: ReadableStream): Promise { - const reader = stream.getReader() - const chunks: Uint8Array[] = [] - let total = 0 - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks.push(value) - total += value.byteLength - } - const out = new Uint8Array(total) - let offset = 0 - for (const chunk of chunks) { - out.set(chunk, offset) - offset += chunk.byteLength - } - return out - } /** * Replace each file's content + name with their encrypted forms and rewrite @@ -595,17 +577,26 @@ export class HandleUpload extends BasePlugin const encryptedName = await vaultEngine.encryptName(file.name, uploadFolder.path) - const plaintextBytes = new Uint8Array(await (file.data as Blob).arrayBuffer()) - const plainStream = new ReadableStream({ - start(controller) { - controller.enqueue(plaintextBytes) - controller.close() - } - }) - const cipherBytes = await this.collectStream(vaultEngine.encryptContent(plainStream)) - const cipherBlob = new Blob([cipherBytes as BlobPart], { - type: 'application/octet-stream' - }) + // Feed the engine the Blob's native stream instead of materialising the + // whole plaintext first. The engine internals still collect today + // (lib only exposes `encryptData(Uint8Array)`), but keeping the + // input side genuinely streamed means a future engine that emits + // rclone-crypt blocks straight onto nacl can be swapped in without + // touching this call site. + // + // FIXME(poc-vault): the output is still collected into a Blob because + // Uppy + tus-js-client require `file.data` to be `Blob`-shaped with a + // working `.slice()` for chunked uploads. End-to-end streaming would + // need either a stream-aware uppy plugin or replacing the transport; + // both are out of PoC scope. The engine API stays streaming so that + // change lands as a pure replacement here. + // Drive the engine end-to-end with streams. Collection only happens + // because Uppy + tus need a sliceable Blob for file.data, not because + // the engine API forces it. + const cipherBlob = await streamToBlob( + vaultEngine.encryptContent((file.data as Blob).stream()), + 'application/octet-stream' + ) const endpointFolder = urlJoin(encryptedFolderPath, encryptedRelativeFolder) const endpointFolderUrl = space.getWebDavUrl({ diff --git a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts index b29b0cfbc0..e2691b8156 100644 --- a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts +++ b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts @@ -11,6 +11,7 @@ import { FileActionOptions, resolveFileNameDuplicate, resolveFolderVault, + streamToArrayBuffer, useAppsStore, useClientService, useEmbedMode, @@ -24,26 +25,6 @@ import { useUserStore } from '@opencloud-eu/web-pkg' -async function collectStream(stream: ReadableStream): Promise { - const reader = stream.getReader() - const chunks: Uint8Array[] = [] - let total = 0 - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks.push(value) - total += value.byteLength - } - const out = new Uint8Array(total) - let offset = 0 - for (const c of chunks) { - out.set(c, offset) - offset += c.byteLength - } - return out -} - export const useFileActionsCreateNewFile = ({ space }: { space?: Ref } = {}) => { const { showMessage, showErrorMessage } = useMessages() const userStore = useUserStore() @@ -154,19 +135,11 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref({ - start(controller) { - controller.close() - } - }) - ) - ) + ? await streamToArrayBuffer(vaultEngine.encryptContent(new Blob([]).stream())) : undefined resource = await (clientService.webdav as WebDAV).putFileContents(unref(space), { path, - ...(content ? { content: content.buffer as ArrayBuffer } : {}) + ...(content ? { content } : {}) }) } if (vaultEngine && resource) { diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue index b1d7e50ca1..6136248664 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -84,7 +84,9 @@ import { decryptResourceInPlace, formatFileSize, getSharedDriveItem, - resolveFolderVault + resolveFolderVault, + streamToArrayBuffer, + streamToBlob } from '../../helpers' import toNumber from 'lodash-es/toNumber' import { useIsMobile } from '@opencloud-eu/design-system/composables' @@ -284,26 +286,6 @@ const addMissingDriveAliasAndItem = async () => { }) } -async function collectStream(stream: ReadableStream): Promise { - const reader = stream.getReader() - const chunks: Uint8Array[] = [] - let total = 0 - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks.push(value) - total += value.byteLength - } - const out = new Uint8Array(total) - let offset = 0 - for (const c of chunks) { - out.set(c, offset) - offset += c.byteLength - } - return out -} - const loadResourceTask = useTask(function* (signal) { try { if (!unref(driveAliasAndItem)) { @@ -422,15 +404,16 @@ const loadFileTask = useTask(function* (signal) { let body = fileContentsResponse.body if (vaultEngine) { - const encryptedBytes = new Uint8Array(body as ArrayBuffer) - const encryptedStream = new ReadableStream({ - start(controller) { - controller.enqueue(encryptedBytes) - controller.close() - } - }) - const plaintextStream = vaultEngine.decryptContent(encryptedStream) - const plaintext: Uint8Array = yield* call(collectStream(plaintextStream)) + // FIXME(poc-vault): engine API is streaming, but both ends still hold + // bytes in memory — getFileContents resolves to a full ArrayBuffer, + // and editors want string/Blob/ArrayBuffer. End-to-end streaming + // hinges on exposing the fetch response.body upstream; landing that + // later won't require changes here. + const plaintextStream = vaultEngine.decryptContent( + new Blob([body as ArrayBuffer]).stream() + ) + const plaintextBuffer: ArrayBuffer = yield* call(streamToArrayBuffer(plaintextStream)) + const plaintext = new Uint8Array(plaintextBuffer) if (!originalResponseType || originalResponseType === 'text') { body = new TextDecoder().decode(plaintext) @@ -469,18 +452,16 @@ const loadFileTask = useTask(function* (signal) { signal }) ) - const encryptedBytes = new Uint8Array(cipherResponse.body as ArrayBuffer) - const encryptedStream = new ReadableStream({ - start(controller) { - controller.enqueue(encryptedBytes) - controller.close() - } - }) - const plaintextStream = urlVaultEngine.decryptContent(encryptedStream) - const plaintext: Uint8Array = yield* call(collectStream(plaintextStream)) - const blob = new Blob([plaintext as BlobPart], { - type: unref(resource)?.mimeType || 'application/octet-stream' - }) + // Collect the engine output straight into the Blob we hand to + // URL.createObjectURL — no intermediate Uint8Array in this path. + const blob: Blob = yield* call( + streamToBlob( + urlVaultEngine.decryptContent( + new Blob([cipherResponse.body as ArrayBuffer]).stream() + ), + unref(resource)?.mimeType || 'application/octet-stream' + ) + ) url.value = URL.createObjectURL(blob) } else { url.value = yield getUrlForResource(unref(space), unref(resource), { @@ -564,24 +545,17 @@ const saveFileTask = useTask(function* () { if (vaultEngine) { const baseCtx = unref(currentFileContext) - const plainBytes = - newContent instanceof Uint8Array - ? (newContent as Uint8Array) - : newContent instanceof ArrayBuffer - ? new Uint8Array(newContent as ArrayBuffer) - : new TextEncoder().encode(newContent as string) - const plainStream = new ReadableStream({ - start(controller) { - controller.enqueue(plainBytes) - controller.close() - } - }) - const encryptedStream = vaultEngine.encryptContent(plainStream) - const encryptedBytes: Uint8Array = yield* call(collectStream(encryptedStream)) - putContent = encryptedBytes.buffer.slice( - encryptedBytes.byteOffset, - encryptedBytes.byteOffset + encryptedBytes.byteLength - ) as ArrayBuffer + // Blob is the easiest "give me a stream over these bytes" primitive + // regardless of whether the editor handed us a string, ArrayBuffer or + // Uint8Array. The engine output goes back through Response#arrayBuffer + // to land in the shape webdav.putFileContents wants. + const plainBlob = + newContent instanceof Blob + ? newContent + : new Blob([newContent as BlobPart]) + putContent = yield* call( + streamToArrayBuffer(vaultEngine.encryptContent(plainBlob.stream())) + ) putCtx = { ...baseCtx, item: yield* call(vaultEngine.encryptPath(unref(baseCtx.item))) } } diff --git a/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts b/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts index 03f014681b..2b2a9aa930 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppFileHandling.ts @@ -10,6 +10,7 @@ import { ListFilesOptions } from '@opencloud-eu/web-client/webdav' import { WebDAV } from '@opencloud-eu/web-client/webdav' import { useExtensionRegistry, useUserStore } from '../piniaStores' import { resolveFolderVault } from '../../helpers/folderVault' +import { streamToBlob } from '../../helpers/streams' interface AppFileHandlingOptions { clientService: ClientService @@ -60,33 +61,13 @@ export function useAppFileHandling({ { path: encryptedPath }, { responseType: 'arraybuffer', signal: options?.signal } ) - const encryptedBytes = new Uint8Array(response.body as ArrayBuffer) - const encryptedStream = new ReadableStream({ - start(controller) { - controller.enqueue(encryptedBytes) - controller.close() - } - }) - const plaintextStream = vaultEngine.decryptContent(encryptedStream) - const reader = plaintextStream.getReader() - const chunks: Uint8Array[] = [] - let total = 0 - // eslint-disable-next-line no-constant-condition - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks.push(value) - total += value.byteLength - } - const plaintext = new Uint8Array(total) - let offset = 0 - for (const c of chunks) { - plaintext.set(c, offset) - offset += c.byteLength - } - const blob = new Blob([plaintext as BlobPart], { - type: resource.mimeType || 'application/octet-stream' - }) + // Run the ciphertext through the engine as a real stream (Blob.stream) + // and collect the plaintext stream directly into the Blob the URL + // points at — no intermediate buffer. + const blob = await streamToBlob( + vaultEngine.decryptContent(new Blob([response.body as ArrayBuffer]).stream()), + resource.mimeType || 'application/octet-stream' + ) return URL.createObjectURL(blob) } return clientService.webdav.getFileUrl(space, resource, { diff --git a/packages/web-pkg/src/helpers/index.ts b/packages/web-pkg/src/helpers/index.ts index cbee52bf4b..96600de50b 100644 --- a/packages/web-pkg/src/helpers/index.ts +++ b/packages/web-pkg/src/helpers/index.ts @@ -19,6 +19,7 @@ export * from './store' export * from './binary' export * from './platform' export * from './promise' +export * from './streams' export * from './textByteSize' export * from './versions' export * from './virtualCursorElement' diff --git a/packages/web-pkg/src/helpers/streams.ts b/packages/web-pkg/src/helpers/streams.ts new file mode 100644 index 0000000000..7f5cd5b28b --- /dev/null +++ b/packages/web-pkg/src/helpers/streams.ts @@ -0,0 +1,29 @@ +/** + * Tiny helpers for collecting a `ReadableStream` into a concrete + * buffer type. They wrap `new Response(stream)` so call sites don't have to + * reach into the Fetch API for what is really just a stream-to-bytes + * conversion. + * + * Today every encrypt/decrypt path inside the vault flow ends in one of + * these because the surrounding transports (webdav PUT, Uppy + tus, blob + * URLs) require sliceable bytes. Once a transport can consume a stream + * directly, the corresponding call site loses the helper but the engine + * API stays unchanged. + */ + +export function streamToArrayBuffer(stream: ReadableStream): Promise { + return new Response(stream).arrayBuffer() +} + +export function streamToBlob( + stream: ReadableStream, + type?: string +): Promise { + if (!type) { + return new Response(stream).blob() + } + // Response#blob() preserves whatever content-type the stream's underlying + // source declared (usually "" for our engines). When the caller already + // knows what kind of bytes they're producing, rewrap with the right type. + return new Response(stream).blob().then((b) => new Blob([b], { type })) +} From 45543748a9db6c2dd4df5130a6219de7b50c031f Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 20 May 2026 14:57:25 +0200 Subject: [PATCH 5/6] fix(rclone-crypt): tighten unit-test compatibility around vault hooks - useResourceIndicators: tolerate test mocks that omit requestExtensions - useFileActions: silently bail in openEditor when no route exists (instead of filtering the action out, which broke route-null tests) - useFileActionsCreateNewFile: null-safe appFileExtension access - useFileActionsDeleteResources: drop the sync/async zalgo path, always return Promises; matching tests now await - web-test-helpers defaultComponentMocks: hasRoute defaults to true so the overwhelming majority of suites get the expected behaviour - web-app-preview: only opt out of preview service on explicit hasPreview() === false, preserve legacy behaviour for missing method - web-app-rclone-crypt: drop unused imports --- packages/web-app-files/src/HandleUpload.ts | 1 - .../files/useFileActionsCreateNewFile.ts | 4 ++-- packages/web-app-preview/src/App.vue | 13 ++++++---- .../web-app-rclone-crypt/src/crypto/engine.ts | 4 ++-- packages/web-app-rclone-crypt/src/index.ts | 7 ++---- .../actions/files/useFileActions.ts | 19 +++++++++++---- .../helpers/useFileActionsDeleteResources.ts | 24 ++++++++++++------- .../resources/useResourceIndicators.ts | 10 +++++--- .../useFileActionsDeleteResources.spec.ts | 18 +++++++++----- .../src/mocks/defaultComponentMocks.ts | 4 ++++ 10 files changed, 66 insertions(+), 38 deletions(-) diff --git a/packages/web-app-files/src/HandleUpload.ts b/packages/web-app-files/src/HandleUpload.ts index aad103bf28..3e10c2d737 100644 --- a/packages/web-app-files/src/HandleUpload.ts +++ b/packages/web-app-files/src/HandleUpload.ts @@ -538,7 +538,6 @@ export class HandleUpload extends BasePlugin this.uppyService.removeUploadFolder(uploadId) } - /** * Replace each file's content + name with their encrypted forms and rewrite * the upload endpoint so Uppy/Tus pushes ciphertext to the encrypted diff --git a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts index e2691b8156..fe36924ae6 100644 --- a/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts +++ b/packages/web-app-files/src/composables/actions/files/useFileActionsCreateNewFile.ts @@ -56,8 +56,8 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref { loadPreviewImageController = new AbortController() try { - // Vault resources (and anything else marked without a server-side - // preview) can't be thumbnailed by the server, so skip the preview - // service entirely and fetch the full image instead. `getUrlForResource` - // is vault-aware and returns a blob URL with cleartext bytes. - const useFullImage = mediaFile.isImage && !mediaFile.resource.hasPreview?.() + // Vault resources (and anything else where the server explicitly says + // "no preview") can't be thumbnailed, so skip the preview service and + // fetch the full image instead — `getUrlForResource` is vault-aware and + // returns a blob URL with cleartext bytes. We only opt out on an + // explicit false; when hasPreview is missing we keep the legacy "try + // preview service" behaviour. + const useFullImage = + mediaFile.isImage && mediaFile.resource.hasPreview?.() === false if (mediaFile.isImage && !useFullImage) { mediaFile.url = await previewService.loadPreview( diff --git a/packages/web-app-rclone-crypt/src/crypto/engine.ts b/packages/web-app-rclone-crypt/src/crypto/engine.ts index c7142d0742..fe73f8847a 100644 --- a/packages/web-app-rclone-crypt/src/crypto/engine.ts +++ b/packages/web-app-rclone-crypt/src/crypto/engine.ts @@ -117,7 +117,7 @@ export function createEngine( const reader = plaintext.getReader() const chunks: Uint8Array[] = [] let total = 0 - // eslint-disable-next-line no-constant-condition + while (true) { const { done, value } = await reader.read() if (done) break @@ -156,7 +156,7 @@ export function createEngine( const chunks: Uint8Array[] = [] let total = 0 // collect entire ciphertext - // eslint-disable-next-line no-constant-condition + while (true) { const { done, value } = await reader.read() if (done) break diff --git a/packages/web-app-rclone-crypt/src/index.ts b/packages/web-app-rclone-crypt/src/index.ts index 86cc632e6c..1e98b312ba 100644 --- a/packages/web-app-rclone-crypt/src/index.ts +++ b/packages/web-app-rclone-crypt/src/index.ts @@ -1,13 +1,10 @@ import { useGettext } from 'vue3-gettext' -import { onBeforeUnmount, ref } from 'vue' +import { ref } from 'vue' import { ApplicationInformation, defineWebApplication, - Extension, - useEventBus, - useFolderVaultStore + Extension } from '@opencloud-eu/web-pkg' -import { Resource } from '@opencloud-eu/web-client' import translations from '../l10n/translations.json' import { folderVaultExtension } from './extensions/folderVault' import { resourceIndicatorExtension } from './extensions/resourceIndicator' diff --git a/packages/web-pkg/src/composables/actions/files/useFileActions.ts b/packages/web-pkg/src/composables/actions/files/useFileActions.ts index 145584b99c..b0fd082cbc 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActions.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActions.ts @@ -125,11 +125,13 @@ export const useFileActions = () => { return false } - // An app may register a file/folder extension purely to contribute - // icon mapping or the new-file menu, without owning a route. In - // that case we skip the editor action so the default action (e.g. - // folder navigation) can take over. - if (!router.hasRoute(fileExtension.routeName || fileExtension.app)) { + // An app may register a file/folder extension purely to + // contribute icon mapping or a new-file menu entry without + // owning a route (rclone-crypt's vault folder is one such case). + // Skip the editor action when no route is registered so the + // default action (folder navigation) can take over. + const editorRouteName = fileExtension.routeName || fileExtension.app + if (!editorRouteName || !router.hasRoute(editorRouteName)) { return false } @@ -213,6 +215,13 @@ export const useFileActions = () => { ) => { const remoteItemId = isShareSpaceResource(space) ? space.id : undefined const routeName = appFileExtension.routeName || appFileExtension.app + // Apps may register a file/folder extension purely to contribute icon + // mapping or a new-file menu entry, without owning an editor route + // (rclone-crypt's vault folder is one such case). Bail silently rather + // than pushing to a route that doesn't exist. + if (!routeName || !router.hasRoute(routeName)) { + return + } const routeOpts = getEditorRouteOpts(routeName, space, resource, remoteItemId) if (unref(options).openFilesInNewTab) { diff --git a/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts index 5c3cbfaf34..ea13357f91 100644 --- a/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts +++ b/packages/web-pkg/src/composables/actions/helpers/useFileActionsDeleteResources.ts @@ -248,14 +248,16 @@ export const useFileActionsDeleteResources = () => { const originalCurrentFolderId = unref(currentFolder)?.id - return Object.values(resourceSpaceMapping).map( - async ({ space: spaceForDeletion, resources: resourcesForDeletion }) => { - const workerResources = await translatePathsForDelete( - spaceForDeletion, - resourcesForDeletion - ) - startWorker( - { topic: 'fileListDelete', space: spaceForDeletion, resources: workerResources }, + const startGroup = async ( + spaceForDeletion: SpaceResource, + resourcesForDeletion: Resource[] + ) => { + const workerResources = await translatePathsForDelete( + spaceForDeletion, + resourcesForDeletion + ) + startWorker( + { topic: 'fileListDelete', space: spaceForDeletion, resources: workerResources }, async ({ successful, failed }) => { if (successful.length) { showSuccessMessage({ @@ -320,7 +322,11 @@ export const useFileActionsDeleteResources = () => { } } ) - } + } + + return Object.values(resourceSpaceMapping).map( + ({ space: spaceForDeletion, resources: resourcesForDeletion }) => + startGroup(spaceForDeletion, resourcesForDeletion) ) } diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index ade25aff44..dce0335d05 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -266,9 +266,13 @@ export const useResourceIndicators = () => { } } - for (const extension of extensionRegistry.requestExtensions( - resourceIndicatorExtensionPoint - )) { + // Defensive: most callers mock useExtensionRegistry without filling in + // every method, and getIndicators is used widely across the resource + // list components. Treat a missing requestExtensions as "no + // extensions" rather than throwing. + const indicatorExtensions = + extensionRegistry.requestExtensions?.(resourceIndicatorExtensionPoint) ?? [] + for (const extension of indicatorExtensions) { const extensionIndicators = extension.getResourceIndicators(resource) if (extensionIndicators) { indicators.push(...extensionIndicators) diff --git a/packages/web-pkg/tests/unit/composables/actions/helpers/useFileActionsDeleteResources.spec.ts b/packages/web-pkg/tests/unit/composables/actions/helpers/useFileActionsDeleteResources.spec.ts index c3fe8e9e45..b8f6453a0f 100644 --- a/packages/web-pkg/tests/unit/composables/actions/helpers/useFileActionsDeleteResources.spec.ts +++ b/packages/web-pkg/tests/unit/composables/actions/helpers/useFileActionsDeleteResources.spec.ts @@ -35,16 +35,19 @@ describe('deleteResources', () => { }) }) - it('should call the delete action on the current folder', () => { + it('should call the delete action on the current folder', async () => { const resourcesToDelete = [currentFolder] + let pending: ReturnType['filesList_delete']> + let routerRef: ReturnType['$router'] getWrapper({ currentFolder, setup: ({ filesList_delete }, { router }) => { - filesList_delete(resourcesToDelete) - - expect(router.push).toHaveBeenCalledTimes(1) + pending = filesList_delete(resourcesToDelete) + routerRef = router } }) + await Promise.all(pending) + expect(routerRef.push).toHaveBeenCalledTimes(1) }) it('should push resources into delete queue', () => { @@ -61,18 +64,21 @@ describe('deleteResources', () => { expect(addResourcesIntoDeleteQueue).toHaveBeenCalledWith(['2']) }) - it('should publish event "runtime.resource.deleted"', () => { + it('should publish event "runtime.resource.deleted"', async () => { const filesToDelete = [{ id: '2', path: '/folder/fileToDelete.txt' }] const spyBus = vi.spyOn(eventBus, 'publish') + let pending: ReturnType['filesList_delete']> getWrapper({ currentFolder, result: filesToDelete, setup: ({ filesList_delete }) => { - filesList_delete(filesToDelete) + pending = filesList_delete(filesToDelete) } }) + await Promise.all(pending) + const { addResourcesIntoDeleteQueue } = useResourcesStore() expect(addResourcesIntoDeleteQueue).toHaveBeenCalledWith(['2']) expect(spyBus).toHaveBeenCalledWith('runtime.resource.deleted', filesToDelete) diff --git a/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts b/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts index 752fc5b765..45d4153f01 100644 --- a/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts +++ b/packages/web-test-helpers/src/mocks/defaultComponentMocks.ts @@ -28,6 +28,10 @@ export const defaultComponentMocks = ({ currentRoute = undefined }: ComponentMoc $router.resolve.mockImplementation( (to: RouteLocationRaw) => ({ href: (to as any).name, location: { path: '' } }) as any ) + // Default to "every route exists" so suites that don't care about routing + // (most of them) don't have to wire this up. The few tests that *do* care + // override this with mockReturnValue(false) — see useFileActions.spec. + $router.hasRoute.mockReturnValue(true) const $route = $router.currentRoute.value $route.path = $route.path || '/' $route.meta = $route.meta || {} From 51b63cc99fd7aa16340d95e04c6e4422076775be Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 20 May 2026 15:10:13 +0200 Subject: [PATCH 6/6] test(e2e): dispatch drop event through Playwright instead of synthetic DragEvent Headless Chrome silently swallows `new DragEvent(...).dispatchEvent(...)` fired from inside `page.evaluate`, so the drag-drop upload scenario worked headed and failed headless. Switching the helper to `locator.dispatchEvent('drop', { dataTransfer })` routes the event through CDP, where both modes behave the same way. dataTransfer is built in the page via `evaluateHandle(new DataTransfer())` so File objects and webkitRelativePath stay intact. --- tests/e2e/support/utils/dragDrop.ts | 50 ++++++++++++----------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/tests/e2e/support/utils/dragDrop.ts b/tests/e2e/support/utils/dragDrop.ts index d2b12c4f35..e74819a28f 100644 --- a/tests/e2e/support/utils/dragDrop.ts +++ b/tests/e2e/support/utils/dragDrop.ts @@ -37,35 +37,25 @@ const getFiles = (resources: File[], files: FileBuffer[] = [], parent = ''): Fil export const dragDropFiles = async (page: Page, resources: File[], targetSelector: string) => { const files = getFiles(resources) - await page.evaluate( - ([files, selector]) => { - const target = document.querySelector(selector as string) - if (!target) throw new Error(`Target ${selector} not found`) - const input = document.createElement('input') - input.type = 'file' - input.multiple = true - input.style.display = 'none' - input.webkitdirectory = (files as FileBuffer[]).some((f) => f.relativePath.includes('/')) - document.body.appendChild(input) + // Build a real DataTransfer inside the page and return a handle to it, + // then have Playwright dispatch the `drop` event with that handle. A + // synthetic `new DragEvent(...).dispatchEvent(...)` from inside + // `page.evaluate` works in headed Chromium but headless silently drops + // the event — Playwright's `locator.dispatchEvent` goes through CDP and + // delivers the event the same way in both modes. + const dataTransfer = await page.evaluateHandle((files) => { + const dt = new DataTransfer() + ;(files as FileBuffer[]).forEach((file) => { + const buffer = new Uint8Array(JSON.parse(file.bufferString)) + const fileObj = new File([new Blob([buffer])], file.name) + if (file.relativePath.includes('/')) { + Object.defineProperty(fileObj, 'webkitRelativePath', { value: file.relativePath }) + } + dt.items.add(fileObj) + }) + return dt + }, files) - const dt = new DataTransfer() - ;(files as FileBuffer[]).forEach((file) => { - const buffer = new Uint8Array(JSON.parse(file.bufferString)) - const fileObj = new File([new Blob([buffer])], file.name) - if (file.relativePath.includes('/')) { - Object.defineProperty(fileObj, 'webkitRelativePath', { value: file.relativePath }) - } - dt.items.add(fileObj) - }) - input.files = dt.files - - const dropEvent = new Event('drop', { bubbles: true }) - Object.defineProperty(dropEvent, 'dataTransfer', { - value: { files: dt.files, types: ['Files'] } - }) - target.dispatchEvent(dropEvent) - document.body.removeChild(input) - }, - [files, targetSelector] - ) + await page.locator(targetSelector).dispatchEvent('dragover', { dataTransfer }) + await page.locator(targetSelector).dispatchEvent('drop', { dataTransfer }) }