Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dev/docker/opencloud.web.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"activities",
"preview",
"mail",
"contacts"
"contacts",
"rclone-crypt"
]
}
130 changes: 126 additions & 4 deletions packages/web-app-files/src/HandleUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,7 +18,9 @@ import {
formatFileSize,
OcUppyFile,
OcUppyMeta,
OcUppyBody
OcUppyBody,
resolveFolderVault,
streamToBlob
} from '@opencloud-eu/web-pkg'
import { locationSpacesGeneric, UppyService } from '@opencloud-eu/web-pkg'
import { isPersonalSpaceResource, isShareSpaceResource } from '@opencloud-eu/web-client'
Expand All @@ -31,6 +35,7 @@ export interface HandleUploadOptions {
messageStore: MessageStore
spacesStore: SpacesStore
resourcesStore: ResourcesStore
extensionRegistry: ExtensionRegistry
uppyService: UppyService
id?: string
space?: Ref<SpaceResource>
Expand All @@ -57,6 +62,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
messageStore: MessageStore
spacesStore: SpacesStore
resourcesStore: ResourcesStore
extensionRegistry: ExtensionRegistry
uppyService: UppyService
quotaCheckEnabled: boolean
directoryTreeCreateEnabled: boolean
Expand All @@ -76,6 +82,7 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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
Expand Down Expand Up @@ -297,7 +304,8 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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)
Expand Down Expand Up @@ -366,8 +374,16 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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', {
Expand Down Expand Up @@ -429,6 +445,20 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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
Expand Down Expand Up @@ -482,7 +512,12 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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
}
Expand All @@ -503,6 +538,93 @@ export class HandleUpload extends BasePlugin<PluginOpts, OcUppyMeta, OcUppyBody>
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
* 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<OcUppyFile[]> {
const space = unref(this.space)
const encryptedFolderPath = await vaultEngine.encryptPath(uploadFolder.path)

const updated: Record<string, OcUppyFile> = {}
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)

// 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({
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)
}
Expand Down
28 changes: 26 additions & 2 deletions packages/web-app-files/src/components/AppBar/CreateAndUpload.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
ClipboardActions,
isLocationPublicActive,
useClipboardStore,
useExtensionRegistry,
useMessages,
useResourcesStore,
useRoute,
Expand All @@ -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'
Expand Down Expand Up @@ -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')

Expand All @@ -85,6 +94,7 @@ if (!uppyService.getPlugin('HandleUpload')) {
spacesStore,
messageStore,
resourcesStore,
extensionRegistry,
uppyService
})
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@ import { computed, Ref, unref } from 'vue'
import { useGettext } from 'vue3-gettext'
import {
ApplicationFileExtension,
decryptResourceInPlace,
FileAction,
FileActionOptions,
resolveFileNameDuplicate,
resolveFolderVault,
streamToArrayBuffer,
useAppsStore,
useClientService,
useEmbedMode,
useExtensionRegistry,
useFileActions,
useIsResourceNameValid,
useMessages,
useModals,
useResourcesStore,
useRouter,
useUserStore
} from '@opencloud-eu/web-pkg'

Expand All @@ -30,9 +35,11 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref<SpaceResour

const { openEditor } = useFileActions()
const clientService = useClientService()
const router = useRouter()

const resourcesStore = useResourcesStore()
const { resources, currentFolder, areFileExtensionsShown } = storeToRefs(resourcesStore)
const extensionRegistry = useExtensionRegistry()

const { isFileNameValid } = useIsResourceNameValid()

Expand All @@ -43,7 +50,26 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref<SpaceResour
const openFile = (resource: Resource, appFileExtension: ApplicationFileExtension) => {
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 = (
Expand Down Expand Up @@ -80,21 +106,45 @@ export const useFileActionsCreateNewFile = ({ space }: { space?: Ref<SpaceResour

try {
let resource: Resource
// Vault-aware: the picker/modal works in cleartext (what the user
// sees in the listing), but webdav needs the encrypted segment
// names. Look up the engine for the parent and translate before
// calling out — then decrypt the response so the upserted resource
// matches the rest of the (cleartext) listing.
const cleartextParentPath = unref(currentFolder).path
const vaultEngine = resolveFolderVault(
extensionRegistry,
unref(space),
cleartextParentPath
)
if (appFileExtension.createFileHandler) {
resource = await appFileExtension.createFileHandler({
fileName,
space: unref(space),
currentFolder: unref(currentFolder)
})
} else if (appFileExtension.type === 'folder') {
const path = join(unref(currentFolder).path, fileName)
const cleartextPath = join(cleartextParentPath, fileName)
const path = vaultEngine ? await vaultEngine.encryptPath(cleartextPath) : cleartextPath
resource = await (clientService.webdav as WebDAV).createFolder(unref(space), { path })
} else {
const path = join(unref(currentFolder).path, fileName)
const cleartextPath = join(cleartextParentPath, fileName)
const path = vaultEngine ? await vaultEngine.encryptPath(cleartextPath) : cleartextPath
// In a vault, "create empty file" must still produce a valid
// rclone-crypt blob on the server — a 0-byte PUT would have no
// header and refuse to decrypt later. Pipe an empty plaintext
// stream through encryptContent to get the file-header bytes.
const content = vaultEngine
? await streamToArrayBuffer(vaultEngine.encryptContent(new Blob([]).stream()))
: undefined
resource = await (clientService.webdav as WebDAV).putFileContents(unref(space), {
path
path,
...(content ? { content } : {})
})
}
if (vaultEngine && resource) {
await decryptResourceInPlace(vaultEngine, resource)
}

resourcesStore.upsertResource(resource)

Expand Down
Loading