From f03dde374163252edccb6e4fd9c3aabace8c02bb Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 14 Jun 2018 17:58:34 +0200 Subject: [PATCH] GH-1905: Implemented the file download functionality. Closes #1905. Signed-off-by: Akos Kitta --- examples/browser/package.json | 3 +- examples/electron/package.json | 3 +- packages/core/package.json | 6 +- packages/core/src/common/objects.ts | 12 ++ packages/file-download/README.md | 29 +++ packages/file-download/compile.tsconfig.json | 10 + packages/file-download/package.json | 60 ++++++ .../file-download-command-contribution.ts | 95 +++++++++ .../browser/file-download-frontend-module.ts | 19 ++ .../file-download-menu-contribution.ts | 24 +++ .../src/browser/file-download-service.ts | 119 +++++++++++ .../src/common/file-download-data.ts | 16 ++ .../src/node/directory-zipper.spec.ts | 36 ++++ .../src/node/directory-zipper.ts | 26 +++ .../src/node/file-download-backend-module.ts | 20 ++ .../src/node/file-download-endpoint.ts | 36 ++++ .../src/node/file-download-handler.ts | 190 ++++++++++++++++++ tsconfig.json | 3 + yarn.lock | 90 +++++++-- 19 files changed, 779 insertions(+), 18 deletions(-) create mode 100644 packages/file-download/README.md create mode 100644 packages/file-download/compile.tsconfig.json create mode 100644 packages/file-download/package.json create mode 100644 packages/file-download/src/browser/file-download-command-contribution.ts create mode 100644 packages/file-download/src/browser/file-download-frontend-module.ts create mode 100644 packages/file-download/src/browser/file-download-menu-contribution.ts create mode 100644 packages/file-download/src/browser/file-download-service.ts create mode 100644 packages/file-download/src/common/file-download-data.ts create mode 100644 packages/file-download/src/node/directory-zipper.spec.ts create mode 100644 packages/file-download/src/node/directory-zipper.ts create mode 100644 packages/file-download/src/node/file-download-backend-module.ts create mode 100644 packages/file-download/src/node/file-download-endpoint.ts create mode 100644 packages/file-download/src/node/file-download-handler.ts diff --git a/examples/browser/package.json b/examples/browser/package.json index 0387ead2ffccb..5501618959791 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -9,6 +9,7 @@ "@theia/editor": "^0.3.11", "@theia/editorconfig": "^0.3.11", "@theia/extension-manager": "^0.3.11", + "@theia/file-download": "^0.3.11", "@theia/file-search": "^0.3.11", "@theia/filesystem": "^0.3.11", "@theia/git": "^0.3.11", @@ -53,4 +54,4 @@ "devDependencies": { "@theia/cli": "^0.3.11" } -} +} \ No newline at end of file diff --git a/examples/electron/package.json b/examples/electron/package.json index 07f8aca3d4c7c..a3738539fb345 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -12,6 +12,7 @@ "@theia/editor": "^0.3.11", "@theia/editorconfig": "^0.3.11", "@theia/extension-manager": "^0.3.11", + "@theia/file-download": "^0.3.11", "@theia/file-search": "^0.3.11", "@theia/filesystem": "^0.3.11", "@theia/git": "^0.3.11", @@ -52,4 +53,4 @@ "devDependencies": { "@theia/cli": "^0.3.11" } -} +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 9767a6f23c71d..cc17dc26a2da1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "@theia/application-package": "^0.3.11", "@types/body-parser": "^1.16.4", "@types/bunyan": "^1.8.0", - "@types/express": "^4.0.36", + "@types/express": "^4.16.0", "@types/lodash.debounce": "4.0.3", "@types/lodash.throttle": "^4.1.3", "@types/route-parser": "^0.1.1", @@ -20,7 +20,7 @@ "bunyan": "^1.8.10", "electron": "1.8.2-beta.5", "es6-promise": "^4.2.4", - "express": "^4.15.3", + "express": "^4.16.3", "file-icons-js": "^1.0.3", "font-awesome": "^4.7.0", "inversify": "^4.2.0", @@ -80,4 +80,4 @@ "nyc": { "extends": "../../configs/nyc.json" } -} +} \ No newline at end of file diff --git a/packages/core/src/common/objects.ts b/packages/core/src/common/objects.ts index 14d499b9dad74..b3745fb5fb5df 100644 --- a/packages/core/src/common/objects.ts +++ b/packages/core/src/common/objects.ts @@ -30,3 +30,15 @@ const _hasOwnProperty = Object.prototype.hasOwnProperty; export function notEmpty(arg: T | undefined | null): arg is T { return arg !== undefined && arg !== null; } + +/** + * `true` if the argument is an empty object. Otherwise, `false`. + */ +export function isEmpty(arg: Object): boolean { + for (const key in arg) { + if (arg.hasOwnProperty(key)) { + return false; + } + } + return true; +} diff --git a/packages/file-download/README.md b/packages/file-download/README.md new file mode 100644 index 0000000000000..461db1c34887b --- /dev/null +++ b/packages/file-download/README.md @@ -0,0 +1,29 @@ +# Theia - File Download + +Provides the file download contribution to the `Files` navigator. + +Supports single and multi file downloads. + - Single files will be downloaded as is. + - Folders will be downloaded az ZIP archives. + - When downloading multiple files, each file should be contained in the same parent folder. Otherwise, the command contribution is disabled. + +### REST API + + - To download a single file or folder use the following endpoint: `GET /file-download/?uri=/encoded/file/uri/to/the/resource`. + - Example: `curl -X GET http://localhost:3000/file-download/?uri=file:///Users/akos.kitta/git/theia/package.json`. + + - To download multiple files (from the same folder) use the `PUT /file-download/` endpoint with the `application/json` content type header and the following body format: + ```json + { + "uri": [ + "/encoded/file/uri/to/the/resource", + "/another/encoded/file/file/uri/to/the/resource" + ] + } + ``` + ``` + curl -X PUT -H "Content-Type: application/json" -d '{ "uris": ["file:///Users/akos.kitta/git/theia/package.json", "file:///Users/akos.kitta/git/theia/README.md"] }' http://localhost:3000/file-download/ + ``` + +## License +[Apache-2.0](https://github.com/theia-ide/theia/blob/master/LICENSE) \ No newline at end of file diff --git a/packages/file-download/compile.tsconfig.json b/packages/file-download/compile.tsconfig.json new file mode 100644 index 0000000000000..b8b72b49c8822 --- /dev/null +++ b/packages/file-download/compile.tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/file-download/package.json b/packages/file-download/package.json new file mode 100644 index 0000000000000..622db1ce405e4 --- /dev/null +++ b/packages/file-download/package.json @@ -0,0 +1,60 @@ +{ + "name": "@theia/file-download", + "version": "0.3.11", + "description": "Theia - File Download Extension", + "dependencies": { + "@theia/core": "^0.3.11", + "@theia/filesystem": "^0.3.11", + "@theia/navigator": "^0.3.11", + "@theia/workspace": "^0.3.11", + "@types/body-parser": "^1.17.0", + "@types/mime-types": "^2.1.0", + "@types/rimraf": "^2.0.2", + "@types/uuid": "^3.4.3", + "body-parser": "^1.18.3", + "http-status-codes": "^1.3.0", + "mime-types": "^2.1.18", + "rimraf": "^2.6.2", + "uuid": "^3.2.1", + "zip-dir": "^1.0.2" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/file-download-frontend-module", + "backend": "lib/node/file-download-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/theia-ide/theia.git" + }, + "bugs": { + "url": "https://github.com/theia-ide/theia/issues" + }, + "homepage": "https://github.com/theia-ide/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "prepare": "yarn run clean && yarn run build", + "clean": "theiaext clean", + "build": "theiaext build", + "watch": "theiaext watch", + "test": "theiaext test", + "docs": "theiaext docs" + }, + "devDependencies": { + "@theia/ext-scripts": "^0.3.11" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} \ No newline at end of file diff --git a/packages/file-download/src/browser/file-download-command-contribution.ts b/packages/file-download/src/browser/file-download-command-contribution.ts new file mode 100644 index 0000000000000..4a9a84337d81f --- /dev/null +++ b/packages/file-download/src/browser/file-download-command-contribution.ts @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { inject, injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { notEmpty } from '@theia/core/lib/common/objects'; +import { UriSelection } from '@theia/core/lib/common/selection'; +import { SelectionService } from '@theia/core/lib/common/selection-service'; +import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; +import { UriAwareCommandHandler, UriCommandHandler } from '@theia/core/lib/common/uri-command-handler'; +import { FileDownloadService } from './file-download-service'; + +@injectable() +export class FileDownloadCommandContribution implements CommandContribution { + + @inject(FileDownloadService) + protected readonly downloadService: FileDownloadService; + + @inject(SelectionService) + protected readonly selectionService: SelectionService; + + registerCommands(registry: CommandRegistry): void { + const options = { + multi: true, + isValid: this.isValid.bind(this) + }; + const handler = new UriAwareCommandHandler(this.selectionService, this.downloadHandler(), options); + registry.registerCommand(FileDownloadCommands.DOWNLOAD, handler); + } + + protected downloadHandler(): UriCommandHandler { + return { + execute: uri => this.executeDownload(uri), + isEnabled: (uri, args) => this.isDownloadEnabled(uri, args), + isVisible: (uri, args) => this.isDownloadVisible(uri, args), + }; + } + + protected async executeDownload(uris: URI[]): Promise { + this.downloadService.download(uris); + } + + // tslint:disable-next-line:no-any + protected isDownloadEnabled(uri: Object | undefined, ...args: any[]): boolean { + return this.getUris(uri).length > 0; + } + + // tslint:disable-next-line:no-any + protected isDownloadVisible(uri: Object | undefined, ...args: any[]): boolean { + return this.isDownloadEnabled(uri, args); + } + + protected isValid(uris: URI[]): boolean { + if (uris.length === 0) { + return false; + } + if (uris.length === 1) { + return true; + } + // Can download multiple files iff they are from the same container folder. + const [firstUri, ...restUris] = uris; + const expectedParent = firstUri.parent.toString(); + return restUris.every(u => u.parent.toString() === expectedParent); + } + + protected getUris(uri: Object | undefined): URI[] { + if (uri === undefined) { + return []; + } + return (Array.isArray(uri) ? uri : [uri]).map(u => this.getUri(u)).filter(notEmpty); + } + + protected getUri(uri: Object | undefined): URI | undefined { + if (uri instanceof URI) { + return uri; + } + if (UriSelection.is(uri)) { + return uri.uri; + } + return undefined; + } + +} + +export namespace FileDownloadCommands { + + export const DOWNLOAD: Command = { + id: 'file.download' + }; + +} diff --git a/packages/file-download/src/browser/file-download-frontend-module.ts b/packages/file-download/src/browser/file-download-frontend-module.ts new file mode 100644 index 0000000000000..c9809bb476fe5 --- /dev/null +++ b/packages/file-download/src/browser/file-download-frontend-module.ts @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { ContainerModule } from 'inversify'; +import { MenuContribution } from '@theia/core/lib/common/menu'; +import { CommandContribution } from '@theia/core/lib/common/command'; +import { FileDownloadService } from './file-download-service'; +import { FileDownloadMenuContribution } from './file-download-menu-contribution'; +import { FileDownloadCommandContribution } from './file-download-command-contribution'; + +export default new ContainerModule(bind => { + bind(FileDownloadService).toSelf().inSingletonScope(); + bind(CommandContribution).to(FileDownloadCommandContribution).inSingletonScope(); + bind(MenuContribution).to(FileDownloadMenuContribution).inSingletonScope(); +}); diff --git a/packages/file-download/src/browser/file-download-menu-contribution.ts b/packages/file-download/src/browser/file-download-menu-contribution.ts new file mode 100644 index 0000000000000..b9e21d9362efc --- /dev/null +++ b/packages/file-download/src/browser/file-download-menu-contribution.ts @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { injectable } from 'inversify'; +import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu'; +import { NavigatorContextMenu } from '@theia/navigator/lib/browser/navigator-contribution'; +import { FileDownloadCommands } from './file-download-command-contribution'; + +@injectable() +export class FileDownloadMenuContribution implements MenuContribution { + + registerMenus(registry: MenuModelRegistry) { + registry.registerMenuAction(NavigatorContextMenu.MOVE, { + commandId: FileDownloadCommands.DOWNLOAD.id, + label: 'Download', + order: 'z' // Should be the last item in the menu group. + }); + } + +} diff --git a/packages/file-download/src/browser/file-download-service.ts b/packages/file-download/src/browser/file-download-service.ts new file mode 100644 index 0000000000000..b53c3ab093e65 --- /dev/null +++ b/packages/file-download/src/browser/file-download-service.ts @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { inject, injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { ILogger } from '@theia/core/lib/common/logger'; +import { Endpoint } from '@theia/core/lib/browser/endpoint'; +import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import { FileDownloadData } from '../common/file-download-data'; + +@injectable() +export class FileDownloadService { + + protected anchor: HTMLAnchorElement | undefined; + + @inject(ILogger) + protected readonly logger: ILogger; + + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + + async download(uris: URI[]): Promise { + if (uris.length === 0) { + return; + } + try { + const title = await this.title(uris); + const response = await fetch(this.request(uris)); + const { status, statusText } = response; + if (status === 200) { + this.forceDownload(response, title); + } else { + throw new Error(`Received unexpected status code: ${status}. [${statusText}]`); + } + } catch (e) { + this.logger.error(`Error occurred when downloading: ${uris.map(u => u.toString(true))}.`, e); + } + } + + protected async forceDownload(response: Response, title: string): Promise { + let url: string | undefined; + try { + const blob = await response.blob(); + url = URL.createObjectURL(blob); + if (this.anchor === undefined) { + this.anchor = document.createElement('a'); + this.anchor.style.display = 'none'; + } + this.anchor.href = url; + this.anchor.download = title; + this.anchor.click(); + } finally { + if (url) { + URL.revokeObjectURL(url); + } + } + } + + protected async title(uris: URI[]): Promise { + // tslint:disable-next-line:whitespace + const [uri,] = uris; + if (uris.length === 1) { + const stat = await this.fileSystem.getFileStat(uri.toString()); + if (stat === undefined) { + throw new Error(`Unexpected error occurred when downloading file. Files does not exist. URI: ${uri.toString(true)}.`); + } + const title = uri.path.base; + return stat.isDirectory ? `${title}.zip` : title; + } + return `${uri.parent.path.name}.zip`; + } + + protected request(uris: URI[]): Request { + const url = this.url(uris); + const init = this.requestInit(uris); + return new Request(url, init); + } + + protected requestInit(uris: URI[]): RequestInit { + if (uris.length === 1) { + return { + body: undefined, + method: 'GET' + }; + } + return { + method: 'PUT', + body: JSON.stringify(this.body(uris)), + headers: new Headers({ 'Content-Type': 'application/json' }), + }; + } + + protected body(uris: URI[]): FileDownloadData { + return { + uris: uris.map(u => u.toString(true)) + }; + } + + protected url(uris: URI[]): string { + const endpoint = this.endpoint(); + if (uris.length === 1) { + // tslint:disable-next-line:whitespace + const [uri,] = uris; + return `${endpoint}/?uri=${uri.toString()}`; + } + return endpoint; + + } + + protected endpoint(): string { + const url = new Endpoint({ path: 'file-download' }).getRestUrl().toString(); + return url.endsWith('/') ? url.slice(0, -1) : url; + } + +} diff --git a/packages/file-download/src/common/file-download-data.ts b/packages/file-download/src/common/file-download-data.ts new file mode 100644 index 0000000000000..8b81624f09b25 --- /dev/null +++ b/packages/file-download/src/common/file-download-data.ts @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +export interface FileDownloadData { + readonly uris: string[]; +} + +export namespace FileDownloadData { + export function is(arg: Object | undefined): arg is FileDownloadData { + return !!arg && 'uris' in arg; + } +} diff --git a/packages/file-download/src/node/directory-zipper.spec.ts b/packages/file-download/src/node/directory-zipper.spec.ts new file mode 100644 index 0000000000000..85c030593b320 --- /dev/null +++ b/packages/file-download/src/node/directory-zipper.spec.ts @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as temp from 'temp'; +import { expect } from 'chai'; +import { DirectoryZipper } from './directory-zipper'; + +// tslint:disable:no-unused-expression + +const track = temp.track(); + +describe('directory-zipper', () => { + + after(() => { + track.cleanupSync(); + }); + + it('should zip up a directory', async () => { + const fromPath = track.mkdirSync('from'); + fs.writeFileSync(path.join(fromPath, 'A.txt'), 'A'); + fs.writeFileSync(path.join(fromPath, 'B.txt'), 'B'); + expect(fs.readFileSync(path.join(fromPath, 'A.txt'), { encoding: 'utf8' })).to.be.equal('A'); + expect(fs.readFileSync(path.join(fromPath, 'B.txt'), { encoding: 'utf8' })).to.be.equal('B'); + const toPath = track.mkdirSync('to'); + const zipper = new DirectoryZipper(); + await zipper.zip(fromPath, path.join(toPath, 'output.zip')); + expect(fs.existsSync(path.join(toPath, 'output.zip'))).to.be.true; + }); + +}); diff --git a/packages/file-download/src/node/directory-zipper.ts b/packages/file-download/src/node/directory-zipper.ts new file mode 100644 index 0000000000000..06d57ea05a64f --- /dev/null +++ b/packages/file-download/src/node/directory-zipper.ts @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { injectable } from 'inversify'; +const zipDir = require('zip-dir'); + +@injectable() +export class DirectoryZipper { + + async zip(inputPath: string, outputPath: string): Promise { + return new Promise((resolve, reject) => { + zipDir(inputPath, { saveTo: outputPath }, (error: Error, buffer: Buffer) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + +} diff --git a/packages/file-download/src/node/file-download-backend-module.ts b/packages/file-download/src/node/file-download-backend-module.ts new file mode 100644 index 0000000000000..3960b0ca3746b --- /dev/null +++ b/packages/file-download/src/node/file-download-backend-module.ts @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { ContainerModule } from 'inversify'; +import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; +import { FileDownloadEndpoint } from './file-download-endpoint'; +import { FileDownloadHandler, SingleFileDownloadHandler, MultiFileDownloadHandler } from './file-download-handler'; +import { DirectoryZipper } from './directory-zipper'; + +export default new ContainerModule(bind => { + bind(FileDownloadEndpoint).toSelf().inSingletonScope(); + bind(BackendApplicationContribution).toService(FileDownloadEndpoint); + bind(FileDownloadHandler).to(SingleFileDownloadHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.SINGLE); + bind(FileDownloadHandler).to(MultiFileDownloadHandler).inSingletonScope().whenTargetNamed(FileDownloadHandler.MULTI); + bind(DirectoryZipper).toSelf().inSingletonScope(); +}); diff --git a/packages/file-download/src/node/file-download-endpoint.ts b/packages/file-download/src/node/file-download-endpoint.ts new file mode 100644 index 0000000000000..2d97b7384660a --- /dev/null +++ b/packages/file-download/src/node/file-download-endpoint.ts @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { injectable, inject, named } from 'inversify'; +import { json } from 'body-parser'; +import { Application, Router } from 'express'; +import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; +import { FileDownloadHandler } from './file-download-handler'; + +@injectable() +export class FileDownloadEndpoint implements BackendApplicationContribution { + + protected static PATH = '/file-download'; + + @inject(FileDownloadHandler) + @named(FileDownloadHandler.SINGLE) + protected readonly singleFileDownloadHandler: FileDownloadHandler; + + @inject(FileDownloadHandler) + @named(FileDownloadHandler.MULTI) + protected readonly multiFileDownloadHandler: FileDownloadHandler; + + configure(app: Application): void { + const router = Router(); + router.get('/', (request, response) => this.singleFileDownloadHandler.handle(request, response)); + router.put('/', (request, response) => this.multiFileDownloadHandler.handle(request, response)); + // Content-Type: application/json + app.use(json()); + app.use(FileDownloadEndpoint.PATH, router); + } + +} diff --git a/packages/file-download/src/node/file-download-handler.ts b/packages/file-download/src/node/file-download-handler.ts new file mode 100644 index 0000000000000..868b74f30431a --- /dev/null +++ b/packages/file-download/src/node/file-download-handler.ts @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2018 TypeFox and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import * as os from 'os'; +import * as fs from 'fs-extra'; +import * as path from 'path'; +import * as rimraf from 'rimraf'; +import { v4 } from 'uuid'; +import { lookup } from 'mime-types'; +import { Request, Response } from 'express'; +import { inject, injectable } from 'inversify'; +import { OK, BAD_REQUEST, METHOD_NOT_ALLOWED, NOT_FOUND, INTERNAL_SERVER_ERROR } from 'http-status-codes'; +import URI from '@theia/core/lib/common/uri'; +import { isEmpty } from '@theia/core/lib/common/objects'; +import { ILogger } from '@theia/core/lib/common/logger'; +import { FileUri } from '@theia/core/lib/node/file-uri'; +import { FileSystem } from '@theia/filesystem/lib/common/filesystem'; +import { DirectoryZipper } from './directory-zipper'; +import { FileDownloadData } from '../common/file-download-data'; + +@injectable() +export abstract class FileDownloadHandler { + + @inject(ILogger) + protected readonly logger: ILogger; + + @inject(FileSystem) + protected readonly fileSystem: FileSystem; + + @inject(DirectoryZipper) + protected readonly zipper: DirectoryZipper; + + public abstract handle(request: Request, response: Response): Promise; + + protected async download(filePath: string, request: Request, response: Response): Promise { + const name = path.basename(filePath); + const mimeType = lookup(filePath); + if (mimeType) { + response.contentType(mimeType); + } else { + this.logger.debug(`Cannot determine the content-type for file: ${filePath}. Skipping the 'Content-type' header from the HTTP response.`); + } + response.setHeader('Content-Disposition', `attachment; filename=${name}`); + try { + await fs.access(filePath, fs.constants.W_OK); + fs.readFile(filePath, (error, data) => { + if (error) { + this.handleError(response, error, INTERNAL_SERVER_ERROR); + return; + } + response.status(OK).send(data).end(); + }); + } catch (e) { + this.handleError(response, e, INTERNAL_SERVER_ERROR); + } + } + + protected async zip(inputPath: string): Promise { + const outputPath = path.join(os.tmpdir(), v4()); + await this.zipper.zip(inputPath, outputPath); + return outputPath; + } + + protected async deleteRecursively(pathToDelete: string): Promise { + rimraf(pathToDelete, error => { + if (error) { + this.logger.warn(`An error occurred while deleting the temporary data from the disk. Cannot clean up: ${pathToDelete}.`, error); + } + }); + } + + protected async createTempDir(): Promise { + const outputPath = path.join(os.tmpdir(), v4()); + await fs.mkdir(outputPath); + return outputPath; + } + + protected async handleError(response: Response, reason: string | Error, status: number = INTERNAL_SERVER_ERROR): Promise { + this.logger.error(reason); + response.status(status).send(reason).end(); + } + +} + +export namespace FileDownloadHandler { + export const SINGLE: symbol = Symbol('single'); + export const MULTI: symbol = Symbol('multi'); +} + +@injectable() +export class SingleFileDownloadHandler extends FileDownloadHandler { + + async handle(request: Request, response: Response): Promise { + const { method, body, query } = request; + if (method !== 'GET') { + this.handleError(response, `Unexpected HTTP method. Expected GET got '${method}' instead.`, METHOD_NOT_ALLOWED); + return; + } + if (body !== undefined && !isEmpty(body)) { + this.handleError(response, `The request body must either undefined or empty when downloading a single file. The body was: ${JSON.stringify(body)}.`, BAD_REQUEST); + return; + } + if (query === undefined || query.uri === undefined || typeof query.uri !== 'string') { + this.handleError(response, `Cannot access the 'uri' query from the request. The query was: ${JSON.stringify(query)}.`, BAD_REQUEST); + return; + } + const uri = new URI(query.uri).toString(true); + const stat = await this.fileSystem.getFileStat(uri); + if (stat === undefined) { + this.handleError(response, `The file does not exist. URI: ${uri}.`, NOT_FOUND); + return; + } + try { + const filePath = FileUri.fsPath(uri); + if (!stat.isDirectory) { + await this.download(filePath, request, response); + } else { + const outputPath = await this.zip(filePath); + await this.download(outputPath, request, response); + // Do not wait for the clean up. + this.deleteRecursively(outputPath); + } + } catch (e) { + this.handleError(response, e, INTERNAL_SERVER_ERROR); + } + } + +} + +@injectable() +export class MultiFileDownloadHandler extends FileDownloadHandler { + + async handle(request: Request, response: Response): Promise { + const { method, body } = request; + if (method !== 'PUT') { + this.handleError(response, `Unexpected HTTP method. Expected PUT got '${method}' instead.`, METHOD_NOT_ALLOWED); + return; + } + if (body === undefined) { + this.handleError(response, `The request body must be defined when downloading multiple files.`, BAD_REQUEST); + return; + } + if (!FileDownloadData.is(body)) { + this.handleError(response, `Unexpected body format. Cannot extract the URIs from the request body. Body was: ${JSON.stringify(body)}.`, BAD_REQUEST); + return; + } + if (body.uris.length === 0) { + this.handleError(response, `Insufficient body format. No URIs were defined by the request body. Body was: ${JSON.stringify(body)}.`, BAD_REQUEST); + return; + } + const [firstUri, ...restUris] = body.uris.map(uri => new URI(uri)); + const firstStat = await this.fileSystem.getFileStat(firstUri.toString()); + if (firstStat === undefined) { + this.handleError(response, `The file does not exist. URI: ${firstUri.toString(true)}.`, NOT_FOUND); + return; + } + const expectedParent = firstUri.parent.toString(); + for (const uri of restUris) { + if (uri.parent.toString() !== expectedParent) { + this.handleError(response, `Incorrect body format. Each URI must have the same parent. Body was: ${JSON.stringify(body)}.`, BAD_REQUEST); + return; + } + const stat = await this.fileSystem.getFileStat(uri.toString()); + if (stat === undefined) { + this.handleError(response, `The file does not exist. URI: ${uri.toString(true)}.`, NOT_FOUND); + return; + } + } + try { + const filePath = await this.prepareDownload(body.uris.map(FileUri.fsPath)); + const outputPath = await this.zip(filePath); + await this.download(outputPath, request, response); + // Do not wait for the clean up. + this.deleteRecursively(outputPath); + } catch (e) { + this.handleError(response, e, INTERNAL_SERVER_ERROR); + } + } + + protected async prepareDownload(paths: string[]): Promise { + const outputPath = await this.createTempDir(); + await Promise.all(paths.map(p => fs.copy(p, path.join(outputPath, path.basename(p))))); + return outputPath; + } + +} diff --git a/tsconfig.json b/tsconfig.json index fb35fbf855113..d191bdf927a19 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -90,6 +90,9 @@ ], "@theia/editorconfig/lib/*": [ "packages/editorconfig/src/*" + ], + "@theia/file-download/lib/*": [ + "packages/file-download/src/*" ] }, "plugins": [ diff --git a/yarn.lock b/yarn.lock index 3d3b23dc835c7..0ab0c1dfbef58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -118,7 +118,7 @@ version "1.2.5" resolved "https://registry.yarnpkg.com/@types/base64-js/-/base64-js-1.2.5.tgz#582b2476169a6cba460a214d476c744441d873d5" -"@types/body-parser@*", "@types/body-parser@^1.16.4": +"@types/body-parser@*", "@types/body-parser@^1.16.4", "@types/body-parser@^1.17.0": version "1.17.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.0.tgz#9f5c9d9bd04bb54be32d5eb9fc0d8c974e6cf58c" dependencies: @@ -179,9 +179,9 @@ "@types/events" "*" "@types/node" "*" -"@types/express@^4.0.36": - version "4.11.1" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.11.1.tgz#f99663b3ab32d04cb11db612ef5dd7933f75465b" +"@types/express@^4.16.0": + version "4.16.0" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.16.0.tgz#6d8bc42ccaa6f35cf29a2b7c3333cb47b5a32a19" dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "*" @@ -290,6 +290,13 @@ "@types/node" "*" "@types/tough-cookie" "*" +"@types/rimraf@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-2.0.2.tgz#7f0fc3cf0ff0ad2a99bb723ae1764f30acaf8b6e" + dependencies: + "@types/glob" "*" + "@types/node" "*" + "@types/route-parser@^0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@types/route-parser/-/route-parser-0.1.1.tgz#24b69588c68249f695122c230e547d3d6c8be095" @@ -348,6 +355,12 @@ version "2.3.2" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-2.3.2.tgz#e0d481d8bb282ad8a8c9e100ceb72c995fb5e709" +"@types/uuid@^3.4.3": + version "3.4.3" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.3.tgz#121ace265f5569ce40f4f6d0ff78a338c732a754" + dependencies: + "@types/node" "*" + "@types/webdriverio@^4.7.0": version "4.10.1" resolved "https://registry.yarnpkg.com/@types/webdriverio/-/webdriverio-4.10.1.tgz#efa8ae7d4aab0952bfba1676feb0c0cf920a52bc" @@ -691,7 +704,7 @@ async-limiter@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" -async@1.x, async@^1.4.0, async@^1.5.0: +async@1.x, async@^1.4.0, async@^1.5.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" @@ -1425,6 +1438,21 @@ body-parser@1.18.2, body-parser@^1.17.2: raw-body "2.3.2" type-is "~1.6.15" +body-parser@^1.18.3: + version "1.18.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" + dependencies: + bytes "3.0.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "~1.6.3" + iconv-lite "0.4.23" + on-finished "~2.3.0" + qs "6.5.2" + raw-body "2.3.3" + type-is "~1.6.16" + boom@2.x.x: version "2.10.1" resolved "https://registry.yarnpkg.com/boom/-/boom-2.10.1.tgz#39c8918ceff5799f83f9492a848f625add0c766f" @@ -3270,7 +3298,7 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -express@^4.15.3: +express@^4.16.3: version "4.16.3" resolved "https://registry.yarnpkg.com/express/-/express-4.16.3.tgz#6af8a502350db3246ecc4becf6b5a34d22f7ed53" dependencies: @@ -4300,7 +4328,7 @@ http-errors@1.6.2: setprototypeof "1.0.3" statuses ">= 1.3.1 < 2" -http-errors@~1.6.2: +http-errors@1.6.3, http-errors@~1.6.2, http-errors@~1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" dependencies: @@ -4329,6 +4357,10 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +http-status-codes@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-1.3.0.tgz#9cd0e71391773d0671b489d41cbc5094aa4163b6" + https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" @@ -4341,6 +4373,12 @@ iconv-lite@0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" +iconv-lite@0.4.23: + version "0.4.23" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" + dependencies: + safer-buffer ">= 2.1.2 < 3" + iconv-lite@^0.4.17, iconv-lite@^0.4.4: version "0.4.21" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.21.tgz#c47f8733d02171189ebc4a400f3218d348094798" @@ -5048,6 +5086,12 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jszip@^2.4.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-2.6.1.tgz#b88f3a7b2e67a2a048152982c7a3756d9c4828f0" + dependencies: + pako "~1.0.2" + just-extend@^1.1.27: version "1.1.27" resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-1.1.27.tgz#ec6e79410ff914e472652abfa0e603c03d60e905" @@ -6529,7 +6573,7 @@ package-json@^4.0.1: registry-url "^3.0.3" semver "^5.1.0" -pako@~1.0.5: +pako@~1.0.2, pako@~1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258" @@ -7168,6 +7212,10 @@ qs@6.5.1, qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" +qs@6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + qs@~6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" @@ -7232,6 +7280,15 @@ raw-body@2.3.2: iconv-lite "0.4.19" unpipe "1.0.0" +raw-body@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" + dependencies: + bytes "3.0.0" + http-errors "1.6.3" + iconv-lite "0.4.23" + unpipe "1.0.0" + rc@^1.0.1, rc@^1.1.2, rc@^1.1.6, rc@^1.1.7: version "1.2.7" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.7.tgz#8a10ca30d588d00464360372b890d06dacd02297" @@ -7789,7 +7846,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -safer-buffer@^2.1.0: +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -8807,9 +8864,9 @@ tslint-language-service@^0.9.9: dependencies: mock-require "^2.0.2" -tslint@^5.7.0: - version "5.9.1" - resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.9.1.tgz#1255f87a3ff57eb0b0e1f0e610a8b4748046c9ae" +tslint@^5.10.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/tslint/-/tslint-5.10.0.tgz#11e26bccb88afa02dd0d9956cae3d4540b5f54c3" dependencies: babel-code-frame "^6.22.0" builtin-modules "^1.1.1" @@ -9112,7 +9169,7 @@ uuid@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" -uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0: +uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0, uuid@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14" @@ -9829,6 +9886,13 @@ yn@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/yn/-/yn-2.0.0.tgz#e5adabc8acf408f6385fc76495684c88e6af689a" +zip-dir@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/zip-dir/-/zip-dir-1.0.2.tgz#253f907aead62a21acd8721d8b88032b2411c051" + dependencies: + async "^1.5.2" + jszip "^2.4.0" + zip-stream@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04"