diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b15667ece..78d0e073f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ # 1.42.0 +## ✨ Features + +* Support moving files from/to encrypted folder + ## 🐛 Bug Fixes * Disable sharing on public file viewer diff --git a/jestHelpers/setup.js b/jestHelpers/setup.js index 0c4111ad62..5d3a8c6d3d 100644 --- a/jestHelpers/setup.js +++ b/jestHelpers/setup.js @@ -1,3 +1,4 @@ +import React from 'react' import Enzyme from 'enzyme' import Adapter from 'enzyme-adapter-react-16' @@ -10,6 +11,35 @@ jest.mock('cozy-ui/transpiled/react/utils/color', () => ({ getCssVariableValue: () => '#fff' })) +jest.mock('cozy-keys-lib', () => ({ + withVaultUnlockContext: BaseComponent => { + const Wrapper = props => { + return + } + Wrapper.displayName = `withVaultUnlockContext(${ + BaseComponent.displayName || BaseComponent.name + })` + return Wrapper + }, + withVaultClient: BaseComponent => { + const Component = props => ( + <> + {({ vaultClient }) => ( + + )} + + ) + + Component.displayName = `withVaultClient(${ + BaseComponent.displayName || BaseComponent.name + })` + + return Component + }, + useVaultUnlockContext: jest.fn().mockReturnValue(jest.fn()), + useVaultClient: jest.fn() +})) + global.cozy = { bar: { BarLeft: () => null, diff --git a/package.json b/package.json index b50855a1a5..4d4a750aa0 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "cozy-scanner": "2.0.2", "cozy-scripts": "6.3.1", "cozy-sharing": "4.1.5", - "cozy-stack-client": "^29.0.0", + "cozy-stack-client": "^29.1.0", "cozy-ui": "^67.0.2", "date-fns": "1.30.1", "diacritics": "1.3.0", diff --git a/src/drive/lib/encryption.js b/src/drive/lib/encryption.js index 7a3c110c1c..fbf77f4584 100644 --- a/src/drive/lib/encryption.js +++ b/src/drive/lib/encryption.js @@ -60,10 +60,10 @@ export const createEncryptedDir = async ( export const encryptAndUploadNewFile = async ( client, vaultClient, - { file, encryptionKey, fileOptions } + { binary, encryptionKey, fileOptions } ) => { const { name, dirID, onUploadProgress } = fileOptions - const encryptedFile = await vaultClient.encryptFile(file, encryptionKey) + const encryptedFile = await vaultClient.encryptFile(binary, encryptionKey) const resp = await client .collection(DOCTYPE_FILES) .createFile(encryptedFile, { @@ -75,6 +75,69 @@ export const encryptAndUploadNewFile = async ( return resp.data } +export const encryptAndUploadExistingFile = async ( + client, + vaultClient, + { file, encryptionKey } +) => { + const encryptedFile = await encryptFile(client, vaultClient, { + file, + encryptionKey + }) + + const attributes = { + ...file, + encrypted: true, + fileId: file._id, + data: encryptedFile + } + const resp = await client.save(attributes) + return resp.data +} + +export const decryptAndUploadExistingFile = async ( + client, + vaultClient, + { file, decryptionKey } +) => { + const plaintextFile = await decryptFile(client, vaultClient, { + file, + decryptionKey + }) + + const attributes = { + ...file, + encrypted: false, + fileId: file._id, + data: plaintextFile + } + const resp = await client.save(attributes) + return resp.data +} + +export const reencryptAndUploadExistingFile = async ( + client, + vaultClient, + { file, encryptionKey, decryptionKey } +) => { + const plaintextFile = await decryptFile(client, vaultClient, { + file, + decryptionKey + }) + const reencryptedFile = await vaultClient.encryptFile( + plaintextFile, + encryptionKey + ) + const attributes = { + ...file, + encrypted: true, + fileId: file._id, + data: reencryptedFile + } + const resp = await client.save(attributes) + return resp.data +} + const getBinaryFile = async (client, fileId) => { const resp = await client .collection(DOCTYPE_FILES) @@ -82,24 +145,36 @@ const getBinaryFile = async (client, fileId) => { return resp.arrayBuffer() } -export const decryptFile = async ( +const decryptFile = async (client, vaultClient, { file, decryptionKey }) => { + const cipher = await getBinaryFile(client, file._id) + return vaultClient.decryptFile(cipher, decryptionKey) +} + +const encryptFile = async (client, vaultClient, { file, encryptionKey }) => { + const cipher = await getBinaryFile(client, file._id) + return vaultClient.encryptFile(cipher, encryptionKey) +} + +export const decryptFileIntoBlob = async ( client, vaultClient, - { file, encryptionKey } + { file, decryptionKey } ) => { - const cipher = await getBinaryFile(client, file._id) - const decryptedFile = await vaultClient.decryptFile(cipher, encryptionKey) - return new Blob([decryptedFile], { type: file.type }) + const plaintextFile = decryptFile(client, vaultClient, { + file, + decryptionKey + }) + return new Blob([plaintextFile], { type: file.type }) } export const downloadEncryptedFile = async ( client, vaultClient, - { file, encryptionKey } + { file, decryptionKey } ) => { const url = await getDecryptedFileURL(client, vaultClient, { file, - encryptionKey + decryptionKey }) return client.collection(DOCTYPE_FILES).forceFileDownload(url, file.name) } @@ -107,8 +182,13 @@ export const downloadEncryptedFile = async ( export const getDecryptedFileURL = async ( client, vaultClient, - { file, encryptionKey } + { file, decryptionKey } ) => { - const blob = await decryptFile(client, vaultClient, { file, encryptionKey }) + const plaintextFile = await decryptFile(client, vaultClient, { + file, + decryptionKey + }) + const blob = new Blob([plaintextFile], { type: file.type }) + return URL.createObjectURL(blob) } diff --git a/src/drive/mobile/modules/offline/duck.js b/src/drive/mobile/modules/offline/duck.js index 4e80d65518..a489009786 100644 --- a/src/drive/mobile/modules/offline/duck.js +++ b/src/drive/mobile/modules/offline/duck.js @@ -11,7 +11,7 @@ import Alerter from 'cozy-ui/transpiled/react/Alerter' import { getEncryptionKeyFromDirId, - decryptFile, + decryptFileToBlob, isEncryptedFile } from 'drive/lib/encryption' import { openFileWith } from 'drive/web/modules/actions/utils' @@ -73,8 +73,11 @@ export const saveOfflineFileCopy = async (file, client, { vaultClient }) => { try { let blob if (isEncryptedFile(file)) { - const encryptionKey = await getEncryptionKeyFromDirId(client, file.dir_id) - blob = await decryptFile(client, vaultClient, { file, encryptionKey }) + const decryptionKey = await getEncryptionKeyFromDirId(client, file.dir_id) + blob = await decryptFileToBlob(client, vaultClient, { + file, + decryptionKey + }) } else { const response = await client .collection(DOCTYPE_FILES) diff --git a/src/drive/mobile/modules/upload/index.jsx b/src/drive/mobile/modules/upload/index.jsx index 396eb1df2b..5beb094a36 100644 --- a/src/drive/mobile/modules/upload/index.jsx +++ b/src/drive/mobile/modules/upload/index.jsx @@ -135,9 +135,8 @@ export class DumbUpload extends Component { >
diff --git a/src/drive/mobile/modules/upload/index.spec.js b/src/drive/mobile/modules/upload/index.spec.js index 7133bb128b..5dea4bcbb6 100644 --- a/src/drive/mobile/modules/upload/index.spec.js +++ b/src/drive/mobile/modules/upload/index.spec.js @@ -4,7 +4,8 @@ import { shallow } from 'enzyme' import { DumbUpload, generateForQueue } from './' jest.mock('cozy-keys-lib', () => ({ - withVaultClient: jest.fn().mockReturnValue({}) + withVaultClient: jest.fn().mockReturnValue({}), + withVaultUnlockContext: jest.fn().mockReturnValue({}) })) const tSpy = jest.fn() diff --git a/src/drive/web/modules/actions/index.spec.js b/src/drive/web/modules/actions/index.spec.js index ce4e9363c5..86c45c3c92 100644 --- a/src/drive/web/modules/actions/index.spec.js +++ b/src/drive/web/modules/actions/index.spec.js @@ -1,6 +1,10 @@ import { download } from './index' import { DOCTYPE_FILES_ENCRYPTION } from 'drive/lib/doctypes' +jest.mock('cozy-keys-lib', () => ({ + withVaultUnlockContext: jest.fn().mockReturnValue({}) +})) + describe('download', () => { it('should not display when an encrypted folder is selected', () => { const files = [ diff --git a/src/drive/web/modules/actions/utils.js b/src/drive/web/modules/actions/utils.js index a7708313dc..38c40db0bf 100644 --- a/src/drive/web/modules/actions/utils.js +++ b/src/drive/web/modules/actions/utils.js @@ -10,7 +10,7 @@ import { getEncryptionKeyFromDirId, downloadEncryptedFile, isEncryptedFolder, - decryptFile, + decryptFileToBlob, isEncryptedFile } from 'drive/lib/encryption' import { DOCTYPE_FILES } from 'drive/lib/doctypes' @@ -48,16 +48,16 @@ const openFileDownloadError = error => { * @param {array} files One or more files to download */ export const downloadFiles = async (client, files, { vaultClient } = {}) => { - const encryptionKey = await getEncryptionKeyFromDirId(client, files[0].dir_id) + const decryptionKey = await getEncryptionKeyFromDirId(client, files[0].dir_id) if (files.length === 1 && !isDirectory(files[0])) { const file = files[0] try { const filename = file.name - if (encryptionKey) { + if (decryptionKey) { return downloadEncryptedFile(client, vaultClient, { file, - encryptionKey + decryptionKey }) } else { return client.collection(DOCTYPE_FILES).download(file, null, filename) @@ -66,8 +66,8 @@ export const downloadFiles = async (client, files, { vaultClient } = {}) => { Alerter.error(downloadFileError(error)) } } else { - if (encryptionKey) { - // Multiple download is forbidden for encrypted files because we cannot generate client archive for now. + if (decryptionKey) { + // decryptionKey download is forbidden for encrypted files because we cannot generate client archive for now. return Alerter.error('error.download_file.encryption_many') } const hasEncryptedDirs = files.find( @@ -136,13 +136,16 @@ export const exportFilesNative = async ( files, { vaultClient } = {} ) => { - const encryptionKey = isEncryptedFile(files[0]) + const decryptionKey = isEncryptedFile(files[0]) ? await getEncryptionKeyFromDirId(client, files[0].dir_id) : null const downloadAllFiles = files.map(async file => { let blob - if (encryptionKey) { - blob = await decryptFile(client, vaultClient, { file, encryptionKey }) + if (decryptionKey) { + blob = await decryptFileToBlob(client, vaultClient, { + file, + decryptionKey + }) } else { const response = await client .collection(DOCTYPE_FILES) @@ -206,11 +209,14 @@ export const openFileWith = async (client, file, { vaultClient } = {}) => { let originalMime = file.mime try { if (isEncryptedFile(file)) { - const encryptionKey = await getEncryptionKeyFromDirId( + const decryptionKey = await getEncryptionKeyFromDirId( client, file.dir_id ) - blob = await decryptFile(client, vaultClient, { file, encryptionKey }) + blob = await decryptFileToBlob(client, vaultClient, { + file, + decryptionKey + }) originalMime = client .collection(DOCTYPE_FILES) .getFileTypeFromName(file.name) diff --git a/src/drive/web/modules/actions/utils.spec.js b/src/drive/web/modules/actions/utils.spec.js index cc561b42b3..fdfc756f05 100644 --- a/src/drive/web/modules/actions/utils.spec.js +++ b/src/drive/web/modules/actions/utils.spec.js @@ -10,7 +10,7 @@ import { import { getEncryptionKeyFromDirId, downloadEncryptedFile, - decryptFile + decryptFileToBlob } from 'drive/lib/encryption' import { DOCTYPE_FILES_ENCRYPTION } from 'drive/lib/doctypes' import { TRASH_DIR_ID } from 'drive/constants/config' @@ -50,7 +50,7 @@ jest.mock('drive/lib/encryption', () => ({ ...jest.requireActual('drive/lib/encryption'), getEncryptionKeyFromDirId: jest.fn(), downloadEncryptedFile: jest.fn(), - decryptFile: jest.fn() + decryptFileToBlob: jest.fn() })) describe('trashFiles', () => { @@ -140,7 +140,7 @@ describe('downloadFiles', () => { expect(downloadEncryptedFile).toHaveBeenCalledWith( mockClient, {}, - { file, encryptionKey: 'encryption-key' } + { file, decryptionKey: 'encryption-key' } ) }) @@ -254,14 +254,14 @@ describe('openFileWith', () => { it('open an encrypted file', async () => { getEncryptionKeyFromDirId.mockResolvedValueOnce('encryption-key') - decryptFile.mockResolvedValueOnce('fake file blob') + decryptFileToBlob.mockResolvedValueOnce('fake file blob') await openFileWith(mockClient, encryptedFile, { vaultClient }) - expect(decryptFile).toHaveBeenCalledWith( + expect(decryptFileToBlob).toHaveBeenCalledWith( mockClient, {}, { file: encryptedFile, - encryptionKey: 'encryption-key' + decryptionKey: 'encryption-key' } ) expect(saveAndOpenWithCordova).toHaveBeenCalledWith('fake file blob', { @@ -344,12 +344,12 @@ describe('exportFilesNative', () => { await exportFilesNative(mockClient, encryptedFiles, { vaultClient }) encryptedFiles.forEach(file => - expect(decryptFile).toHaveBeenCalledWith( + expect(decryptFileToBlob).toHaveBeenCalledWith( mockClient, {}, { file, - encryptionKey: 'encryption-key' + decryptionKey: 'encryption-key' } ) ) diff --git a/src/drive/web/modules/filelist/AddFolder.spec.jsx b/src/drive/web/modules/filelist/AddFolder.spec.jsx index 1f7c3ae674..617f64ce8d 100644 --- a/src/drive/web/modules/filelist/AddFolder.spec.jsx +++ b/src/drive/web/modules/filelist/AddFolder.spec.jsx @@ -16,7 +16,8 @@ jest.mock('cozy-flags', () => jest.fn()) jest.mock('cozy-keys-lib', () => ({ withVaultClient: jest.fn().mockReturnValue({}), useVaultClient: jest.fn(), - WebVaultClient: jest.fn().mockReturnValue({}) + WebVaultClient: jest.fn().mockReturnValue({}), + withVaultUnlockContext: jest.fn().mockReturnValue({}) })) describe('AddFolder', () => { const setup = () => { diff --git a/src/drive/web/modules/move/FileList.jsx b/src/drive/web/modules/move/FileList.jsx index 6ba4281376..89c9abbe6a 100644 --- a/src/drive/web/modules/move/FileList.jsx +++ b/src/drive/web/modules/move/FileList.jsx @@ -1,57 +1,86 @@ -import React, { useState } from 'react' +import React from 'react' import PropTypes from 'prop-types' import { DumbFile as File } from 'drive/web/modules/filelist/File' -import { VaultUnlocker } from 'cozy-keys-lib' -import { ROOT_DIR_ID } from 'drive/constants/config' +import { useVaultUnlockContext } from 'cozy-keys-lib' import { isEncryptedFolder } from 'drive/lib/encryption' -const isInvalidMoveTarget = (subjects, target) => { - const isASubject = subjects.find(subject => subject._id === target._id) - const isAFile = target.type === 'file' +const getFoldersInEntries = entries => { + return entries.filter(entry => entry.type === 'directory') +} + +const getEncryptedFolders = entries => { + return entries.filter(entry => { + if (entry.type !== 'directory') { + return false + } + return isEncryptedFolder(entry) + }) +} + +export const isInvalidMoveTarget = (entries, target) => { + const isTargetAnEntry = entries.find(subject => subject._id === target._id) + const isTargetAFile = target.type === 'file' + if (isTargetAFile || isTargetAnEntry) { + return true + } + const dirs = getFoldersInEntries(entries) + if (dirs.length > 0) { + const encryptedFoldersEntries = getEncryptedFolders(dirs) + const hasEncryptedFolderEntries = encryptedFoldersEntries.length > 0 + const hasEncryptedAndNonEncryptedFolderEntries = + hasEncryptedFolderEntries && + encryptedFoldersEntries.length !== dirs.length + const isTargetEncrypted = isEncryptedFolder(target) - return isAFile || isASubject + if (isTargetEncrypted && !hasEncryptedFolderEntries) { + // Do not allow moving a non-encrypted folder to an encrypted one + return true + } + if (isTargetEncrypted && hasEncryptedAndNonEncryptedFolderEntries) { + // Do not allow moving encrypted + non encrypted folders + return true + } + } + return false } -const FileList = ({ targets, files, folder, navigateTo }) => { - const [shouldUnlock, setShouldUnlock] = useState(true) - const isEncFolder = isEncryptedFolder(folder) - - if (isEncFolder && shouldUnlock) { - return ( - { - setShouldUnlock(false) - return navigateTo(ROOT_DIR_ID) - }} - onUnlock={() => setShouldUnlock(false)} - /> - ) - } else { - return ( - <> - {files.map(file => ( - navigateTo(files.find(f => f.id === id))} - onFileOpen={null} - withSelectionCheckbox={false} - withFilePath={false} - withSharedBadge - /> - ))} - - ) +const FileList = ({ entries, files, folder, navigateTo }) => { + const { showUnlockForm } = useVaultUnlockContext() + + const onFolderOpen = folderId => { + const dir = folder ? folder._id : files.find(f => f._id === folderId) + const shouldUnlock = isEncryptedFolder(dir) + if (shouldUnlock) { + return showUnlockForm({ onUnlock: () => navigateTo(dir) }) + } else { + return navigateTo(dir) + } } + + return ( + <> + {files.map(file => ( + onFolderOpen(id)} + onFileOpen={null} + withSelectionCheckbox={false} + withFilePath={false} + withSharedBadge + /> + ))} + + ) } FileList.propTypes = { - targets: PropTypes.array.isRequired, + entries: PropTypes.array.isRequired, files: PropTypes.array.isRequired, navigateTo: PropTypes.func.isRequired, folder: PropTypes.object diff --git a/src/drive/web/modules/move/FileList.spec.jsx b/src/drive/web/modules/move/FileList.spec.jsx new file mode 100644 index 0000000000..44274a678e --- /dev/null +++ b/src/drive/web/modules/move/FileList.spec.jsx @@ -0,0 +1,119 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { createMockClient } from 'cozy-client' +import { DOCTYPE_FILES_ENCRYPTION } from 'drive/lib/doctypes' +import AppLike from 'test/components/AppLike' +import { generateFile } from 'test/generate' +import FileList, { isInvalidMoveTarget } from 'drive/web/modules/move/FileList' + +jest.mock('cozy-keys-lib', () => ({ + useVaultUnlockContext: jest + .fn() + .mockReturnValue({ showUnlockForm: jest.fn() }) +})) +const client = createMockClient({}) + +const setup = ({ files, entries, navigateTo }) => { + const root = render( + + + + ) + + return { root } +} + +describe('FileList', () => { + it('should display files', () => { + const entries = [generateFile({ i: '1', type: 'file' })] + const files = [ + generateFile({ i: '1', type: 'directory', prefix: '' }), + generateFile({ i: '2', type: 'file', prefix: '' }) + ] + + const { root } = setup({ files, entries, navigateTo: () => null }) + const { queryAllByText } = root + + expect(queryAllByText('directory-1')).toBeTruthy() + expect(queryAllByText('file-2')).toBeTruthy() + }) +}) + +describe('isInvalidMoveTarget', () => { + it('should return true when target is a file', () => { + const target = { _id: '1', type: 'file' } + const entries = [{ _id: 'dir1', type: 'directory' }] + expect(isInvalidMoveTarget(entries, target)).toBe(true) + }) + + it('should return true when subject is the target', () => { + const target = { _id: '1', type: 'directory' } + const entries = [{ _id: '1', type: 'directory' }] + expect(isInvalidMoveTarget(entries, target)).toBe(true) + }) + + it('should return true when target is an encrypted directory and entries has non-encrypted folder', () => { + const target = { + _id: '1', + type: 'directory', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }] + } + const entries = [{ _id: 'dir1', type: 'directory' }] + expect(isInvalidMoveTarget(entries, target)).toBe(true) + }) + + it('shold return true when target is an encrypted dir and entries include both encrytped and non-encrypted dir', () => { + const target = { + _id: '1', + type: 'directory', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }] + } + const entries = [ + { + _id: 'dir1', + type: 'directory', + referenced_by: [ + { type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' } + ] + }, + { + _id: 'dir2', + type: 'directory' + } + ] + expect(isInvalidMoveTarget(entries, target)).toBe(true) + }) + + it('should return false when both target and entries are encrypted', () => { + const target = { + _id: '1', + type: 'directory', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' }] + } + const entries = [ + { + _id: 'dir1', + type: 'directory', + referenced_by: [ + { type: DOCTYPE_FILES_ENCRYPTION, id: 'encrypted-key-1' } + ] + } + ] + expect(isInvalidMoveTarget(entries, target)).toBe(false) + }) + + it('should return false when both target and entries are regular folders', () => { + const target = { _id: '1', type: 'directory' } + const entries = [{ _id: 'dir1', type: 'directory' }] + expect(isInvalidMoveTarget(entries, target)).toBe(false) + }) + + it('should return false when subject is a mix of regular file and folder and target a regular folder', () => { + const target = { _id: '1', type: 'directory' } + const entries = [ + { _id: 'dir1', type: 'file' }, + { _id: 'file1', type: 'file' } + ] + expect(isInvalidMoveTarget(entries, target)).toBe(false) + }) +}) diff --git a/src/drive/web/modules/move/MoveModal.jsx b/src/drive/web/modules/move/MoveModal.jsx index 123bc96ff8..facbd7029b 100644 --- a/src/drive/web/modules/move/MoveModal.jsx +++ b/src/drive/web/modules/move/MoveModal.jsx @@ -6,6 +6,7 @@ import { withStyles } from '@material-ui/core/styles' import { Query, cancelable, withClient, Q } from 'cozy-client' import { CozyFile } from 'models' +import { withVaultUnlockContext } from 'cozy-keys-lib' import logger from 'lib/logger' import { RefreshableSharings } from 'cozy-sharing' @@ -16,7 +17,6 @@ import Alerter from 'cozy-ui/transpiled/react/Alerter' import { getTracker } from 'cozy-ui/transpiled/react/helpers/tracker' import { withBreakpoints } from 'cozy-ui/transpiled/react' -import { ROOT_DIR_ID } from 'drive/constants/config' import Header from 'drive/web/modules/move/Header' import Explorer from 'drive/web/modules/move/Explorer' import FileList from 'drive/web/modules/move/FileList' @@ -30,6 +30,14 @@ import { buildMoveOrImportQuery, buildOnlyFolderQuery } from 'drive/web/modules/queries' +import { + isEncryptedFolder, + isEncryptedFile, + getEncryptionKeyFromDirId, + encryptAndUploadExistingFile, + decryptAndUploadExistingFile, + reencryptAndUploadExistingFile +} from 'drive/lib/encryption' const styles = theme => ({ paper: { @@ -57,7 +65,7 @@ export class MoveModal extends React.Component { this.promises = [] const { displayedFolder } = props this.state = { - folderId: displayedFolder ? displayedFolder._id : ROOT_DIR_ID, + targetFolder: displayedFolder, isMoveInProgress: false } } @@ -67,7 +75,7 @@ export class MoveModal extends React.Component { } navigateTo = folder => { - this.setState({ folderId: folder._id }) + this.setState({ targetFolder: folder }) } registerCancelable = promise => { @@ -82,21 +90,91 @@ export class MoveModal extends React.Component { this.promises = [] } + moveEncryptedEntry = async ( + client, + vaultClient, + entry, + { isEncryptedTarget, isEncryptedFileEntry, targetFolder, sourceFolder } + ) => { + if (isEncryptedTarget && !isEncryptedFileEntry) { + // The plaintext file is moved to an encrypted directory + const encryptionKey = await getEncryptionKeyFromDirId( + client, + targetFolder._id + ) + + await encryptAndUploadExistingFile(client, vaultClient, { + file: entry, + encryptionKey + }) + } else if (!isEncryptedTarget && isEncryptedFileEntry) { + // The encrypted file is moved to a plaintext directory + const decryptionKey = await getEncryptionKeyFromDirId( + client, + sourceFolder._id + ) + + await decryptAndUploadExistingFile(client, vaultClient, { + file: entry, + decryptionKey + }) + } else if (isEncryptedTarget && isEncryptedFileEntry) { + // The encrypted file is moved to another encrypted directory + const encryptionKey = await getEncryptionKeyFromDirId( + client, + targetFolder._id + ) + const decryptionKey = await getEncryptionKeyFromDirId( + client, + sourceFolder._id + ) + await reencryptAndUploadExistingFile(client, vaultClient, { + file: entry, + decryptionKey, + encryptionKey + }) + } + } + + moveEntry = async ( + entry, + targetFolder, + { sharedPaths, isEncryptedTarget, client, vaultClient } + ) => { + const { displayedFolder } = this.props + const isEncryptedFileEntry = isEncryptedFile(entry) + if (isEncryptedTarget || isEncryptedFileEntry) { + await this.moveEncryptedEntry(client, vaultClient, entry, { + isEncryptedTarget, + isEncryptedFileEntry, + targetFolder, + sourceFolder: displayedFolder + }) + } + + const targetPath = await CozyFile.getFullpath(targetFolder._id, entry.name) + const force = !sharedPaths.includes(targetPath) + return CozyFile.move(entry._id, { folderId: targetFolder._id }, force) + } + moveEntries = async callback => { - const { client, entries, onClose, sharingState, t } = this.props + const { client, vaultClient, entries, onClose, sharingState, t } = + this.props const { sharedPaths } = sharingState - const { folderId } = this.state + const { targetFolder } = this.state try { this.setState({ isMoveInProgress: true }) + const isEncryptedTarget = isEncryptedFolder(targetFolder) const trashedFiles = [] await Promise.all( entries.map(async entry => { - const targetPath = await this.registerCancelable( - CozyFile.getFullpath(folderId, entry.name) - ) - const force = !sharedPaths.includes(targetPath) const moveResponse = await this.registerCancelable( - CozyFile.move(entry._id, { folderId }, force) + this.moveEntry(entry, targetFolder, { + sharedPaths, + isEncryptedTarget, + client, + vaultClient + }) ) if (moveResponse.deleted) { trashedFiles.push(moveResponse.deleted) @@ -105,7 +183,7 @@ export class MoveModal extends React.Component { ) const response = await this.registerCancelable( - client.query(Q('io.cozy.files').getById(folderId)) + client.query(Q('io.cozy.files').getById(targetFolder._id)) ) const targetName = response.data.name Alerter.info('Move.success', { @@ -113,7 +191,8 @@ export class MoveModal extends React.Component { target: targetName, smart_count: entries.length, buttonText: t('Move.cancel'), - buttonAction: () => this.cancelMove(entries, trashedFiles, callback) + buttonAction: () => + this.cancelMove(entries, trashedFiles, callback, { targetFolder }) }) this.trackEvent(entries.length) if (callback) callback() @@ -129,14 +208,28 @@ export class MoveModal extends React.Component { } cancelMove = async (entries, trashedFiles, callback) => { - const { client } = this.props + const { client, vaultClient, displayedFolder } = this.props + const { targetFolder } = this.state try { + const isEncryptedDisplayedFolder = isEncryptedFolder(displayedFolder) + const isEncryptedTargetFolder = isEncryptedFolder(targetFolder) + await Promise.all( - entries.map(entry => - this.registerCancelable( + entries.map(async entry => { + if (isEncryptedDisplayedFolder || isEncryptedTargetFolder) { + this.registerCancelable( + this.moveEncryptedEntry(client, vaultClient, entry, { + isEncryptedTarget: isEncryptedDisplayedFolder, + isEncryptedFileEntry: isEncryptedTargetFolder, + targetFolder: displayedFolder, + sourceFolder: targetFolder + }) + ) + } + return this.registerCancelable( CozyFile.move(entry._id, { folderId: entry.dir_id }) ) - ) + }) ) const fileCollection = client.collection(CozyFile.doctype) let restoreErrorsCount = 0 @@ -183,10 +276,10 @@ export class MoveModal extends React.Component { classes, breakpoints: { isMobile } } = this.props - const { folderId, isMoveInProgress } = this.state + const { targetFolder, isMoveInProgress } = this.state - const contentQuery = buildMoveOrImportQuery(folderId) - const folderQuery = buildOnlyFolderQuery(folderId) + const contentQuery = buildMoveOrImportQuery(targetFolder._id) + const folderQuery = buildOnlyFolderQuery(targetFolder._id) return ( {({ data, fetchStatus }) => ( {({ data, fetchStatus, hasMore, fetchMore }) => { return ( @@ -231,7 +324,7 @@ export class MoveModal extends React.Component { > @@ -248,7 +341,7 @@ export class MoveModal extends React.Component { onConfirm={() => this.moveEntries(refresh)} onClose={onClose} targets={entries} - currentDirId={folderId} + currentDirId={targetFolder._id} isMoving={isMoveInProgress} /> )} @@ -276,6 +369,7 @@ export default compose( connect(mapStateToProps), translate(), withClient, + withVaultUnlockContext, withSharingState, withStyles(styles), withBreakpoints() diff --git a/src/drive/web/modules/move/MoveModal.spec.jsx b/src/drive/web/modules/move/MoveModal.spec.jsx index ba9ad4782f..36799bbec2 100644 --- a/src/drive/web/modules/move/MoveModal.spec.jsx +++ b/src/drive/web/modules/move/MoveModal.spec.jsx @@ -5,11 +5,29 @@ import CozyClient from 'cozy-client' import { CozyFile } from 'models' import { MoveModal } from './MoveModal' +import { DOCTYPE_FILES_ENCRYPTION } from 'drive/lib/doctypes' +import { + getEncryptionKeyFromDirId, + encryptAndUploadExistingFile, + decryptAndUploadExistingFile, + reencryptAndUploadExistingFile +} from 'drive/lib/encryption' jest.mock('cozy-client/dist/utils', () => ({ cancelable: jest.fn().mockImplementation(promise => promise) })) +jest.mock('cozy-keys-lib', () => ({ + withVaultUnlockContext: jest.fn().mockReturnValue(<>) +})) +jest.mock('drive/lib/encryption', () => ({ + ...jest.requireActual('drive/lib/encryption'), + getEncryptionKeyFromDirId: jest.fn(), + encryptAndUploadExistingFile: jest.fn(), + decryptAndUploadExistingFile: jest.fn(), + reencryptAndUploadExistingFile: jest.fn() +})) + jest.mock('cozy-doctypes') jest.mock('cozy-stack-client') @@ -44,10 +62,19 @@ describe('MoveModal component', () => { sharedPaths: ['/sharedFolder', '/bills/bill_201903.pdf'] } - const setupComponent = (entries = defaultEntries) => { + const defaultDisplayedFolder = { _id: 'bills' } + const encryptedDisplayedFolder = { + _id: 'encrypted', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: '123' }] + } + + const setupComponent = ( + entries = defaultEntries, + displayedFolder = defaultDisplayedFolder + ) => { const props = { client: cozyClient, - displayedFolder: { _id: 'bills' }, + displayedFolder, entries, onClose: onCloseSpy, sharingState, @@ -60,8 +87,8 @@ describe('MoveModal component', () => { describe('moveEntries', () => { it('should move entries to destination', async () => { - const component = setupComponent(defaultEntries, sharingState) - component.setState({ folderId: 'destinationFolder' }) + const component = setupComponent(defaultEntries) + component.setState({ targetFolder: { _id: 'destinationFolder' } }) CozyFile.getFullpath.mockImplementation((destinationFolder, name) => Promise.resolve( name === 'bill_201903.pdf' ? '/bills/bill_201903.pdf' : '/whatever' @@ -112,6 +139,81 @@ describe('MoveModal component', () => { expect(cb).toHaveBeenCalled() // TODO: check that trashedFiles are passed to cancel button }) + + it('should move non-encrypted files to encrypted dir', async () => { + const component = setupComponent(defaultEntries) + component.setState({ + targetFolder: { + _id: 'destinationFolder', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: '123' }] + } + }) + CozyFile.move.mockImplementation(id => ({ moved: { id } })) + const cb = jest.fn() + await component.instance().moveEntries(cb) + + expect(getEncryptionKeyFromDirId).toHaveBeenCalled() + expect(encryptAndUploadExistingFile).toHaveBeenCalled() + }) + + it('should move encrypted files to non-encrypted dir', async () => { + const entries = [ + { + _id: 'bill_201901', + dir_id: 'bills', + name: 'bill_201901.pdf', + encrypted: true + }, + { + _id: 'bill_201901', + dir_id: 'bills', + name: 'bill_201901.pdf', + encrypted: true + } + ] + const component = setupComponent(entries) + component.setState({ + targetFolder: { + _id: 'destinationFolder' + } + }) + CozyFile.move.mockImplementation(id => ({ moved: { id } })) + const cb = jest.fn() + await component.instance().moveEntries(cb) + + expect(getEncryptionKeyFromDirId).toHaveBeenCalled() + expect(decryptAndUploadExistingFile).toHaveBeenCalled() + }) + + it('should move encrypted files to encrypted dir', async () => { + const entries = [ + { + _id: 'bill_201901', + dir_id: 'bills', + name: 'bill_201901.pdf', + encrypted: true + }, + { + _id: 'bill_201901', + dir_id: 'bills', + name: 'bill_201901.pdf', + encrypted: true + } + ] + const component = setupComponent(entries) + component.setState({ + targetFolder: { + _id: 'destinationFolder', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: '123' }] + } + }) + CozyFile.move.mockImplementation(id => ({ moved: { id } })) + const cb = jest.fn() + await component.instance().moveEntries(cb) + + expect(getEncryptionKeyFromDirId).toHaveBeenCalled() + expect(reencryptAndUploadExistingFile).toHaveBeenCalled() + }) }) describe('cancelMove', () => { @@ -140,5 +242,73 @@ describe('MoveModal component', () => { expect(restoreSpy).toHaveBeenCalledWith('trashed-2') expect(callback).toHaveBeenCalled() }) + + it('should move back files moved from non-encrypted dir to encrypted dir', async () => { + const component = setupComponent(defaultEntries) + component.setState({ + targetFolder: { + _id: 'destinationFolder', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: '123' }] + } + }) + const callback = jest.fn() + await component.instance().cancelMove(defaultEntries, [], callback) + expect(getEncryptionKeyFromDirId).toHaveBeenCalled() + expect(decryptAndUploadExistingFile).toHaveBeenCalled() + + expect(CozyFile.move).toHaveBeenCalledWith('bill_201901', { + folderId: 'bills' + }) + expect(CozyFile.move).toHaveBeenCalledWith('bill_201902', { + folderId: 'bills' + }) + expect(restoreSpy).not.toHaveBeenCalled() + expect(callback).toHaveBeenCalled() + }) + + it('should move back files moved from encrypted dir to non-encrypted dir', async () => { + const component = setupComponent(defaultEntries, encryptedDisplayedFolder) + component.setState({ + targetFolder: { + _id: 'destinationFolder' + } + }) + const callback = jest.fn() + await component.instance().cancelMove(defaultEntries, [], callback) + expect(getEncryptionKeyFromDirId).toHaveBeenCalled() + expect(encryptAndUploadExistingFile).toHaveBeenCalled() + + expect(CozyFile.move).toHaveBeenCalledWith('bill_201901', { + folderId: 'bills' + }) + expect(CozyFile.move).toHaveBeenCalledWith('bill_201902', { + folderId: 'bills' + }) + expect(restoreSpy).not.toHaveBeenCalled() + expect(callback).toHaveBeenCalled() + }) + + it('should move back files moved from encrypted dir to another encrypted dir', async () => { + const component = setupComponent(defaultEntries, encryptedDisplayedFolder) + component.setState({ + targetFolder: { + _id: 'destinationFolder', + referenced_by: [{ type: DOCTYPE_FILES_ENCRYPTION, id: '123' }] + } + }) + const callback = jest.fn() + await component.instance().cancelMove(defaultEntries, [], callback) + expect(getEncryptionKeyFromDirId).toHaveBeenCalled() + expect(reencryptAndUploadExistingFile).toHaveBeenCalled() + + expect(CozyFile.move).toHaveBeenCalledWith('bill_201901', { + folderId: 'bills' + }) + expect(CozyFile.move).toHaveBeenCalledWith('bill_201902', { + folderId: 'bills' + }) + expect(restoreSpy).not.toHaveBeenCalled() + expect(callback).toHaveBeenCalled() + }) }) }) diff --git a/src/drive/web/modules/navigation/duck/actions.spec.jsx b/src/drive/web/modules/navigation/duck/actions.spec.jsx index 88e74c97d0..c89f047589 100644 --- a/src/drive/web/modules/navigation/duck/actions.spec.jsx +++ b/src/drive/web/modules/navigation/duck/actions.spec.jsx @@ -9,7 +9,8 @@ import { createFolder } from './actions' jest.mock('cozy-keys-lib', () => ({ withVaultClient: jest.fn().mockReturnValue({}), useVaultClient: jest.fn(), - WebVaultClient: jest.fn().mockReturnValue({}) + WebVaultClient: jest.fn().mockReturnValue({}), + withVaultUnlockContext: jest.fn().mockReturnValue({}) })) const vaultClient = new WebVaultClient('http://alice.cozy.cloud') diff --git a/src/drive/web/modules/public/LightFileViewer.spec.jsx b/src/drive/web/modules/public/LightFileViewer.spec.jsx index d45c2b4d4c..5a2c2050a2 100644 --- a/src/drive/web/modules/public/LightFileViewer.spec.jsx +++ b/src/drive/web/modules/public/LightFileViewer.spec.jsx @@ -16,6 +16,10 @@ jest.mock('cozy-ui/transpiled/react/hooks/useBreakpoints', () => ({ default: jest.fn(), BreakpointsProvider: ({ children }) => children })) +jest.mock('cozy-keys-lib', () => ({ + withVaultUnlockContext: jest.fn().mockReturnValue(<>), + withVaultClient: jest.fn().mockReturnValue(<>) +})) const client = new createMockClient({}) diff --git a/src/drive/web/modules/upload/index.js b/src/drive/web/modules/upload/index.js index a211868868..1339b7e1c4 100644 --- a/src/drive/web/modules/upload/index.js +++ b/src/drive/web/modules/upload/index.js @@ -337,7 +337,7 @@ const uploadFile = async (client, file, dirID, options = {}) => { const fr = new FileReader() fr.onloadend = async () => { return encryptAndUploadNewFile(client, vaultClient, { - file: fr.result, + binary: fr.result, encryptionKey, fileOptions: { name: file.name, diff --git a/src/drive/web/modules/viewer/FilesViewer.jsx b/src/drive/web/modules/viewer/FilesViewer.jsx index 31a14c6861..f9ef704ee2 100644 --- a/src/drive/web/modules/viewer/FilesViewer.jsx +++ b/src/drive/web/modules/viewer/FilesViewer.jsx @@ -131,13 +131,13 @@ const FilesViewer = ({ filesQuery, files, fileId, onClose, onChange }) => { const getDecryptedURLIfNecessary = async () => { const file = files[currentIndex] if (file && isEncryptedFile(file)) { - const encryptionKey = await getEncryptionKeyFromDirId( + const decryptionKey = await getEncryptionKeyFromDirId( client, file.dir_id ) const url = await getDecryptedFileURL(client, vaultClient, { file, - encryptionKey + decryptionKey }) setCurrentDecryptedFileURL(url) } diff --git a/src/drive/web/modules/viewer/FilesViewer.spec.jsx b/src/drive/web/modules/viewer/FilesViewer.spec.jsx index 375d8a2b69..f927d31c94 100644 --- a/src/drive/web/modules/viewer/FilesViewer.spec.jsx +++ b/src/drive/web/modules/viewer/FilesViewer.spec.jsx @@ -10,7 +10,8 @@ import { getEncryptionKeyFromDirId } from 'drive/lib/encryption' jest.mock('cozy-client/dist/hooks/useQuery', () => jest.fn()) jest.mock('cozy-keys-lib', () => ({ - useVaultClient: jest.fn() + useVaultClient: jest.fn(), + withVaultUnlockContext: jest.fn().mockReturnValue({}) })) jest.mock('drive/lib/encryption', () => ({ diff --git a/src/drive/web/modules/viewer/helpers.js b/src/drive/web/modules/viewer/helpers.js index eeeb02fb76..480ca1af95 100644 --- a/src/drive/web/modules/viewer/helpers.js +++ b/src/drive/web/modules/viewer/helpers.js @@ -6,8 +6,8 @@ import { export const downloadFile = async (client, file, { vaultClient }) => { if (isEncryptedFile(file)) { - const encryptionKey = await getEncryptionKeyFromDirId(client, file.dir_id) - return downloadEncryptedFile(client, vaultClient, { file, encryptionKey }) + const decryptionKey = await getEncryptionKeyFromDirId(client, file.dir_id) + return downloadEncryptedFile(client, vaultClient, { file, decryptionKey }) } else { return client.collection('io.cozy.files').download(file) } diff --git a/test/setup.jsx b/test/setup.jsx index de9a256e2c..534a392390 100644 --- a/test/setup.jsx +++ b/test/setup.jsx @@ -13,25 +13,6 @@ import FolderContent from 'test/components/FolderContent' import { generateFile } from './generate' import { act } from 'react-dom/test-utils' -jest.mock('cozy-keys-lib', () => ({ - withVaultClient: BaseComponent => { - const Component = props => ( - <> - {({ vaultClient }) => ( - - )} - - ) - - Component.displayName = `withVaultClient(${ - BaseComponent.displayName || BaseComponent.name - })` - - return Component - }, - useVaultClient: jest.fn() -})) - configure({ testIdAttribute: 'data-testid' }) export const mockCozyClientRequestQuery = () => {