diff --git a/jslib b/jslib index ba10d07042..ed89dfaba7 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit ba10d0704212f2bc8fabf0d3d6ebb552fd183401 +Subproject commit ed89dfaba70b60925817a0ce6f0c179b3f8bd2fb diff --git a/package-lock.json b/package-lock.json index c5a8e714ae..6c675b121d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,15 @@ "integrity": "sha512-MFFKFv2X4iZy/NFl1m1E8uwE1CR96SGwJjgHma09PLtqOWoj3nqeJHMG+P/EuJGVLvC2I6MdQRQsr4TcRduIow==", "dev": true }, + "@types/node-fetch": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-1.6.9.tgz", + "integrity": "sha512-n2r6WLoY7+uuPT7pnEtKJCmPUGyJ+cbyBR8Avnu4+m1nzz7DwBVuyIvvlBzCZ/nrpC7rIgb3D6pNavL7rFEa9g==", + "dev": true, + "requires": { + "@types/node": "10.0.8" + } + }, "@types/node-forge": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-0.7.4.tgz", diff --git a/package.json b/package.json index 9557a2cbe2..df95167817 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/lowdb": "^1.0.1", "@types/lunr": "^2.1.5", "@types/node": "^10.0.8", + "@types/node-fetch": "^1.6.9", "@types/node-forge": "^0.7.1", "@types/papaparse": "4.1.31", "@types/readline-sync": "^1.4.3", diff --git a/src/commands/export.command.ts b/src/commands/export.command.ts index 7075e21e69..381305baf5 100644 --- a/src/commands/export.command.ts +++ b/src/commands/export.command.ts @@ -41,40 +41,13 @@ export class ExportCommand { } async saveFile(csv: string, cmd: program.Command): Promise { - let p: string = null; - let mkdir = false; - if (cmd.output != null && cmd.output !== '') { - const osOutput = path.join(cmd.output); - if (osOutput.indexOf(path.sep) === -1) { - p = path.join(process.cwd(), osOutput); - } else { - mkdir = true; - if (osOutput.endsWith(path.sep)) { - p = path.join(osOutput, this.exportService.getFileName()); - } else { - p = osOutput; - } - } - } else { - p = path.join(process.cwd(), this.exportService.getFileName()); + try { + const filePath = await CliUtils.saveFile(csv, cmd.output, this.exportService.getFileName()); + const res = new MessageResponse('Saved ' + filePath, null); + res.raw = filePath; + return Response.success(res); + } catch (e) { + return Response.error(e.toString()); } - - p = path.resolve(p); - if (mkdir) { - const dir = p.substring(0, p.lastIndexOf(path.sep)); - if (!fs.existsSync(dir)) { - CliUtils.mkdirpSync(dir, 755); - } - } - - return new Promise((resolve, reject) => { - fs.writeFile(p, csv, (err) => { - if (err != null) { - reject(Response.error('Cannot save file to ' + p)); - } - const res = new MessageResponse('Saved ' + p + '', null); - resolve(Response.success(res)); - }); - }); } } diff --git a/src/commands/get.command.ts b/src/commands/get.command.ts index b15bd562d1..0be8d11fab 100644 --- a/src/commands/get.command.ts +++ b/src/commands/get.command.ts @@ -1,10 +1,12 @@ import * as program from 'commander'; +import * as fet from 'node-fetch'; import { CipherType } from 'jslib/enums/cipherType'; import { AuditService } from 'jslib/abstractions/audit.service'; import { CipherService } from 'jslib/abstractions/cipher.service'; import { CollectionService } from 'jslib/abstractions/collection.service'; +import { CryptoService } from 'jslib/abstractions/crypto.service'; import { FolderService } from 'jslib/abstractions/folder.service'; import { TotpService } from 'jslib/abstractions/totp.service'; @@ -16,9 +18,11 @@ import { Response } from '../models/response'; import { CipherResponse } from '../models/response/cipherResponse'; import { CollectionResponse } from '../models/response/collectionResponse'; import { FolderResponse } from '../models/response/folderResponse'; +import { MessageResponse } from '../models/response/messageResponse'; import { StringResponse } from '../models/response/stringResponse'; import { TemplateResponse } from '../models/response/templateResponse'; +import { Attachment } from '../models/attachment'; import { Card } from '../models/card'; import { Cipher } from '../models/cipher'; import { Collection } from '../models/collection'; @@ -34,7 +38,7 @@ import { CliUtils } from '../utils'; export class GetCommand { constructor(private cipherService: CipherService, private folderService: FolderService, private collectionService: CollectionService, private totpService: TotpService, - private auditService: AuditService) { } + private auditService: AuditService, private cryptoService: CryptoService) { } async run(object: string, id: string, cmd: program.Command): Promise { if (id != null) { @@ -43,7 +47,12 @@ export class GetCommand { switch (object.toLowerCase()) { case 'item': - return await this.getCipher(id); + if (cmd.attachmentid === null || cmd.attachmentid === '') { + return await this.getCipher(id); + } else { + + return await this.getAttachment(id, cmd); + } case 'username': return await this.getUsername(id); case 'password': @@ -148,6 +157,8 @@ export class GetCommand { } private async getTotp(id: string) { + // TODO: premium check + const cipherResponse = await this.getCipher(id); if (!cipherResponse.success) { return cipherResponse; @@ -182,6 +193,50 @@ export class GetCommand { return Response.success(res); } + private async getAttachment(id: string, cmd: program.Command) { + // TODO: Premium check + + const cipherResponse = await this.getCipher(id); + if (!cipherResponse.success) { + return cipherResponse; + } + + const cipher = cipherResponse.data as CipherResponse; + if (cipher.attachments == null || cipher.attachments.length === 0) { + return Response.error('No attachments available for this item.'); + } + + const attachment = cipher.attachments.filter((a) => + a.id === cmd.attachmentid || a.fileName === cmd.attachmentid); + if (attachment.length === 0) { + return Response.error('Attachment `' + cmd.attachmentid + '` was not found.'); + } + if (attachment.length > 1) { + return Response.multipleResults(attachment.map((a) => a.id)); + } + + const response = await fet.default(new fet.Request(attachment[0].url, { headers: { cache: 'no-cache' } })); + if (response.status !== 200) { + return Response.error('A ' + response.status + ' error occurred while downloading the attachment.'); + } + + try { + const buf = await response.arrayBuffer(); + const key = await this.cryptoService.getOrgKey(cipher.organizationId); + const decBuf = await this.cryptoService.decryptFromBytes(buf, key); + const filePath = await CliUtils.saveFile(new Buffer(decBuf), cmd.output, attachment[0].fileName); + const res = new MessageResponse('Saved ' + filePath, null); + res.raw = filePath; + return Response.success(res); + } catch (e) { + if (typeof (e) === 'string') { + return Response.error(e); + } else { + return Response.error('An error occurred while saving the attachment.'); + } + } + } + private async getFolder(id: string) { let decFolder: FolderView = null; if (this.isGuid(id)) { @@ -260,6 +315,9 @@ export class GetCommand { case 'securenote': template = SecureNote.template(); break; + case 'attachment': + template = Attachment.template(); + break; case 'folder': template = Folder.template(); break; diff --git a/src/models/attachment.ts b/src/models/attachment.ts new file mode 100644 index 0000000000..1ca4485d31 --- /dev/null +++ b/src/models/attachment.ts @@ -0,0 +1,21 @@ +import { AttachmentView } from 'jslib/models/view/attachmentView'; + +export class Attachment { + static template(): Attachment { + const req = new Attachment(); + req.fileName = 'photo.jpg'; + return req; + } + + static toView(req: Attachment, view = new AttachmentView()) { + view.fileName = req.fileName; + return view; + } + + fileName: string; + + // Use build method instead of ctor so that we can control order of JSON stringify for pretty print + build(o: AttachmentView) { + this.fileName = o.fileName; + } +} diff --git a/src/models/response/attachmentResponse.ts b/src/models/response/attachmentResponse.ts new file mode 100644 index 0000000000..9f1c315891 --- /dev/null +++ b/src/models/response/attachmentResponse.ts @@ -0,0 +1,19 @@ +import { AttachmentView } from 'jslib/models/view/attachmentView'; + +import { Attachment } from '../attachment'; + +export class AttachmentResponse extends Attachment { + id: string; + size: number; + sizeName: string; + url: string; + + constructor(o: AttachmentView) { + super(); + this.id = o.id; + this.build(o); + this.size = o.size; + this.sizeName = o.sizeName; + this.url = o.url; + } +} diff --git a/src/models/response/cipherResponse.ts b/src/models/response/cipherResponse.ts index e487f46908..9063888b5c 100644 --- a/src/models/response/cipherResponse.ts +++ b/src/models/response/cipherResponse.ts @@ -1,17 +1,21 @@ import { CipherView } from 'jslib/models/view/cipherView'; -import { BaseResponse } from './baseResponse'; - import { Cipher } from '../cipher'; +import { AttachmentResponse } from './attachmentResponse'; +import { BaseResponse } from './baseResponse'; export class CipherResponse extends Cipher implements BaseResponse { object: string; id: string; + attachments: AttachmentResponse[]; constructor(o: CipherView) { super(); this.object = 'item'; this.id = o.id; this.build(o); + if (o.attachments != null) { + this.attachments = o.attachments.map((a) => new AttachmentResponse(a)); + } } } diff --git a/src/program.ts b/src/program.ts index 0d421e5f60..8375ba6869 100644 --- a/src/program.ts +++ b/src/program.ts @@ -225,6 +225,8 @@ export class Program { program .command('get ') .description('Get an object.') + .option('--attachmentid ', 'Get an item\'s attachment.') + .option('--output ', 'Output directory or filename for attachment.') .on('--help', () => { writeLn('\n Objects:'); writeLn(''); @@ -248,6 +250,8 @@ export class Program { writeLn(' bw get password https://google.com'); writeLn(' bw get totp google.com'); writeLn(' bw get exposed yahoo.com'); + writeLn(' bw get item google --attachmentid b857igwl1dzrs2 --output ./photo.jpg'); + writeLn(' bw get item google --attachmentid photo.jpg --raw'); writeLn(' bw get folder email'); writeLn(' bw get template folder'); writeLn(''); @@ -255,7 +259,8 @@ export class Program { .action(async (object, id, cmd) => { await this.exitIfLocked(); const command = new GetCommand(this.main.cipherService, this.main.folderService, - this.main.collectionService, this.main.totpService, this.main.auditService); + this.main.collectionService, this.main.totpService, this.main.auditService, + this.main.cryptoService); const response = await command.run(object, id, cmd); this.processResponse(response); }); diff --git a/src/utils.ts b/src/utils.ts index 224b8eb9e3..765ffbc6c1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,6 +6,43 @@ import { CollectionView } from 'jslib/models/view/collectionView'; import { FolderView } from 'jslib/models/view/folderView'; export class CliUtils { + static saveFile(data: string | Buffer, output: string, defaultFileName: string) { + let p: string = null; + let mkdir = false; + if (output != null && output !== '') { + const osOutput = path.join(output); + if (osOutput.indexOf(path.sep) === -1) { + p = path.join(process.cwd(), osOutput); + } else { + mkdir = true; + if (osOutput.endsWith(path.sep)) { + p = path.join(osOutput, defaultFileName); + } else { + p = osOutput; + } + } + } else { + p = path.join(process.cwd(), defaultFileName); + } + + p = path.resolve(p); + if (mkdir) { + const dir = p.substring(0, p.lastIndexOf(path.sep)); + if (!fs.existsSync(dir)) { + CliUtils.mkdirpSync(dir, 755); + } + } + + return new Promise((resolve, reject) => { + fs.writeFile(p, data, 'utf8', (err) => { + if (err != null) { + reject('Cannot save file to ' + p); + } + resolve(p); + }); + }); + } + static mkdirpSync(targetDir: string, mode = 755, relative = false, relativeDir: string = null) { const initialDir = path.isAbsolute(targetDir) ? path.sep : ''; const baseDir = relative ? (relativeDir != null ? relativeDir : __dirname) : '.';