diff --git a/src/abstractions/api.service.ts b/src/abstractions/api.service.ts index f1f5983c8ae..776eedd60a4 100644 --- a/src/abstractions/api.service.ts +++ b/src/abstractions/api.service.ts @@ -1,6 +1,7 @@ import { PolicyType } from '../enums/policyType'; import { EnvironmentUrls } from '../models/domain/environmentUrls'; +import { AttachmentRequest } from '../models/request/attachmentRequest'; import { BitPayInvoiceRequest } from '../models/request/bitPayInvoiceRequest'; import { CipherBulkDeleteRequest } from '../models/request/cipherBulkDeleteRequest'; @@ -70,6 +71,8 @@ import { VerifyDeleteRecoverRequest } from '../models/request/verifyDeleteRecove import { VerifyEmailRequest } from '../models/request/verifyEmailRequest'; import { ApiKeyResponse } from '../models/response/apiKeyResponse'; +import { AttachmentResponse } from '../models/response/attachmentResponse'; +import { AttachmentUploadDataResponse } from '../models/response/attachmentUploadDataResponse'; import { BillingResponse } from '../models/response/billingResponse'; import { BreachAccountResponse } from '../models/response/breachAccountResponse'; import { CipherResponse } from '../models/response/cipherResponse'; @@ -193,6 +196,7 @@ export abstract class ApiService { getCipher: (id: string) => Promise; getCipherAdmin: (id: string) => Promise; + getAttachmentData: (cipherId: string, attachmentId: string, emergencyAccessId?: string) => Promise; getCiphersOrganization: (organizationId: string) => Promise>; postCipher: (request: CipherRequest) => Promise; postCipherCreate: (request: CipherCreateRequest) => Promise; @@ -219,12 +223,23 @@ export abstract class ApiService { putRestoreCipherAdmin: (id: string) => Promise; putRestoreManyCiphers: (request: CipherBulkRestoreRequest) => Promise>; - postCipherAttachment: (id: string, data: FormData) => Promise; - postCipherAttachmentAdmin: (id: string, data: FormData) => Promise; + /** + * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. + * This method still exists for backward compatibility with old server versions. + */ + postCipherAttachmentLegacy: (id: string, data: FormData) => Promise; + /** + * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. + * This method still exists for backward compatibility with old server versions. + */ + postCipherAttachmentAdminLegacy: (id: string, data: FormData) => Promise; + postCipherAttachment: (id: string, request: AttachmentRequest) => Promise; deleteCipherAttachment: (id: string, attachmentId: string) => Promise; deleteCipherAttachmentAdmin: (id: string, attachmentId: string) => Promise; postShareCipherAttachment: (id: string, attachmentId: string, data: FormData, organizationId: string) => Promise; + renewAttachmentUploadUrl: (id: string, attachmentId: string) => Promise; + postAttachmentFile: (id: string, attachmentId: string, data: FormData) => Promise; getCollectionDetails: (organizationId: string, id: string) => Promise; getUserCollections: () => Promise>; diff --git a/src/abstractions/fileUpload.service.ts b/src/abstractions/fileUpload.service.ts index 2bfe08b2ef4..dd758034300 100644 --- a/src/abstractions/fileUpload.service.ts +++ b/src/abstractions/fileUpload.service.ts @@ -1,7 +1,10 @@ import { CipherString } from '../models/domain'; +import { AttachmentUploadDataResponse } from '../models/response/attachmentUploadDataResponse'; import { SendFileUploadDataResponse } from '../models/response/sendFileUploadDataResponse'; export abstract class FileUploadService { uploadSendFile: (uploadData: SendFileUploadDataResponse, fileName: CipherString, encryptedFileData: ArrayBuffer) => Promise; + uploadCipherAttachment: (admin: boolean, uploadData: AttachmentUploadDataResponse, fileName: string, + encryptedFileData: ArrayBuffer) => Promise; } diff --git a/src/angular/components/attachments.component.ts b/src/angular/components/attachments.component.ts index 8de40cf1fe9..2cf130ab7e5 100644 --- a/src/angular/components/attachments.component.ts +++ b/src/angular/components/attachments.component.ts @@ -6,6 +6,7 @@ import { Output, } from '@angular/core'; +import { ApiService } from '../../abstractions/api.service'; import { CipherService } from '../../abstractions/cipher.service'; import { CryptoService } from '../../abstractions/crypto.service'; import { I18nService } from '../../abstractions/i18n.service'; @@ -13,6 +14,7 @@ import { PlatformUtilsService } from '../../abstractions/platformUtils.service'; import { UserService } from '../../abstractions/user.service'; import { Cipher } from '../../models/domain/cipher'; +import { ErrorResponse } from '../../models/response'; import { AttachmentView } from '../../models/view/attachmentView'; import { CipherView } from '../../models/view/cipherView'; @@ -31,10 +33,12 @@ export class AttachmentsComponent implements OnInit { formPromise: Promise; deletePromises: { [id: string]: Promise; } = {}; reuploadPromises: { [id: string]: Promise; } = {}; + emergencyAccessId?: string = null; constructor(protected cipherService: CipherService, protected i18nService: I18nService, protected cryptoService: CryptoService, protected userService: UserService, - protected platformUtilsService: PlatformUtilsService, protected win: Window) { } + protected platformUtilsService: PlatformUtilsService, protected apiService: ApiService, + protected win: Window) { } async ngOnInit() { await this.init(); @@ -55,7 +59,7 @@ export class AttachmentsComponent implements OnInit { return; } - if (files[0].size > 104857600) { // 100 MB + if (files[0].size > 524288000) { // 500 MB this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), this.i18nService.t('maxFileSize')); return; @@ -116,8 +120,23 @@ export class AttachmentsComponent implements OnInit { return; } + let url: string; + try { + const attachmentDownloadResponse = await this.apiService.getAttachmentData(this.cipher.id, attachment.id, + this.emergencyAccessId); + url = attachmentDownloadResponse.url; + } catch (e) { + if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) { + url = attachment.url; + } else if (e instanceof ErrorResponse) { + throw new Error((e as ErrorResponse).getSingleMessage()); + } else { + throw e; + } + } + a.downloading = true; - const response = await fetch(new Request(attachment.url, { cache: 'no-store' })); + const response = await fetch(new Request(url, { cache: 'no-store' })); if (response.status !== 200) { this.platformUtilsService.showToast('error', null, this.i18nService.t('errorOccurred')); a.downloading = false; diff --git a/src/models/request/attachmentRequest.ts b/src/models/request/attachmentRequest.ts index d341ae63a07..466829aa942 100644 --- a/src/models/request/attachmentRequest.ts +++ b/src/models/request/attachmentRequest.ts @@ -1,4 +1,6 @@ export class AttachmentRequest { fileName: string; key: string; + fileSize: number; + adminRequest: boolean; } diff --git a/src/models/response/attachmentUploadDataResponse.ts b/src/models/response/attachmentUploadDataResponse.ts new file mode 100644 index 00000000000..117ab6fa9f6 --- /dev/null +++ b/src/models/response/attachmentUploadDataResponse.ts @@ -0,0 +1,20 @@ +import { FileUploadType } from '../../enums/fileUploadType'; +import { BaseResponse } from './baseResponse'; +import { CipherResponse } from './cipherResponse'; + +export class AttachmentUploadDataResponse extends BaseResponse { + attachmentId: string; + fileUploadType: FileUploadType; + cipherResponse: CipherResponse; + cipherMiniResponse: CipherResponse; + url: string = null; + constructor(response: any) { + super(response); + this.attachmentId = this.getResponseProperty('AttachmentId'); + this.fileUploadType = this.getResponseProperty('FileUploadType'); + this.cipherResponse = this.getResponseProperty('CipherResponse'); + this.cipherMiniResponse = this.getResponseProperty('CipherMiniResponse'); + this.url = this.getResponseProperty('Url'); + } + +} diff --git a/src/services/api.service.ts b/src/services/api.service.ts index 5e1fa0f2dac..86173bbfae2 100644 --- a/src/services/api.service.ts +++ b/src/services/api.service.ts @@ -7,6 +7,7 @@ import { TokenService } from '../abstractions/token.service'; import { EnvironmentUrls } from '../models/domain/environmentUrls'; +import { AttachmentRequest } from '../models/request/attachmentRequest'; import { BitPayInvoiceRequest } from '../models/request/bitPayInvoiceRequest'; import { CipherBulkDeleteRequest } from '../models/request/cipherBulkDeleteRequest'; import { CipherBulkMoveRequest } from '../models/request/cipherBulkMoveRequest'; @@ -75,6 +76,8 @@ import { VerifyEmailRequest } from '../models/request/verifyEmailRequest'; import { Utils } from '../misc/utils'; import { ApiKeyResponse } from '../models/response/apiKeyResponse'; +import { AttachmentResponse } from '../models/response/attachmentResponse'; +import { AttachmentUploadDataResponse } from '../models/response/attachmentUploadDataResponse'; import { BillingResponse } from '../models/response/billingResponse'; import { BreachAccountResponse } from '../models/response/breachAccountResponse'; import { CipherResponse } from '../models/response/cipherResponse'; @@ -600,12 +603,33 @@ export class ApiService implements ApiServiceAbstraction { // Attachments APIs - async postCipherAttachment(id: string, data: FormData): Promise { + async getAttachmentData(cipherId: string, attachmentId: string, emergencyAccessId?: string): Promise { + const path = (emergencyAccessId != null ? + '/emergency-access/' + emergencyAccessId + '/' : + '/ciphers/') + cipherId + '/attachment/' + attachmentId; + const r = await this.send('GET', path, null, true, true); + return new AttachmentResponse(r); + } + + async postCipherAttachment(id: string, request: AttachmentRequest): Promise { + const r = await this.send('POST', '/ciphers/' + id + '/attachment/v2', request, true, true); + return new AttachmentUploadDataResponse(r); + } + + /** + * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. + * This method still exists for backward compatibility with old server versions. + */ + async postCipherAttachmentLegacy(id: string, data: FormData): Promise { const r = await this.send('POST', '/ciphers/' + id + '/attachment', data, true, true); return new CipherResponse(r); } - async postCipherAttachmentAdmin(id: string, data: FormData): Promise { + /** + * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. + * This method still exists for backward compatibility with old server versions. + */ + async postCipherAttachmentAdminLegacy(id: string, data: FormData): Promise { const r = await this.send('POST', '/ciphers/' + id + '/attachment-admin', data, true, true); return new CipherResponse(r); } @@ -624,6 +648,15 @@ export class ApiService implements ApiServiceAbstraction { attachmentId + '/share?organizationId=' + organizationId, data, true, false); } + async renewAttachmentUploadUrl(id: string, attachmentId: string): Promise { + const r = await this.send('GET', '/ciphers/' + id + '/attachment/' + attachmentId, null, true, true); + return new AttachmentUploadDataResponse(r); + } + + postAttachmentFile(id: string, attachmentId: string, data: FormData): Promise { + return this.send('POST', '/ciphers/' + id + '/attachment/' + attachmentId + '/renew', data, true, false); + } + // Collections APIs async getCollectionDetails(organizationId: string, id: string): Promise { diff --git a/src/services/azureFileUpload.service.ts b/src/services/azureFileUpload.service.ts index a9830b41ef5..371935f9ca8 100644 --- a/src/services/azureFileUpload.service.ts +++ b/src/services/azureFileUpload.service.ts @@ -32,6 +32,10 @@ export class AzureFileUploadService { }); const blobResponse = await fetch(request); + + if (blobResponse.status !== 201) { + throw new Error(`Failed to create Azure blob: ${blobResponse.status}`); + } } private async azureUploadBlocks(url: string, data: ArrayBuffer, renewalCallback: () => Promise) { const baseUrl = Utils.getUrl(url); diff --git a/src/services/bitwardenFileUpload.service.ts b/src/services/bitwardenFileUpload.service.ts index d4af2622b27..d272a7733da 100644 --- a/src/services/bitwardenFileUpload.service.ts +++ b/src/services/bitwardenFileUpload.service.ts @@ -2,28 +2,26 @@ import { ApiService } from '../abstractions/api.service'; import { Utils } from '../misc/utils'; -import { CipherString } from '../models/domain'; -import { SendResponse } from '../models/response/sendResponse'; - export class BitwardenFileUploadService { constructor(private apiService: ApiService) { } - async upload(sendResponse: SendResponse, fileName: CipherString, data: ArrayBuffer) { + async upload(encryptedFileName: string, encryptedFileData: ArrayBuffer, apiCall: (fd: FormData) => Promise) { const fd = new FormData(); try { - const blob = new Blob([data], { type: 'application/octet-stream' }); - fd.append('data', blob, fileName.encryptedString); + const blob = new Blob([encryptedFileData], { type: 'application/octet-stream' }); + fd.append('data', blob, encryptedFileName); } catch (e) { if (Utils.isNode && !Utils.isBrowser) { - fd.append('data', Buffer.from(data) as any, { - filepath: fileName.encryptedString, + fd.append('data', Buffer.from(encryptedFileData) as any, { + filepath: encryptedFileName, contentType: 'application/octet-stream', } as any); } else { throw e; } } - await this.apiService.postSendFile(sendResponse.id, sendResponse.file.id, fd); + + await apiCall(fd); } } diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts index c004f6dca79..3b3e8a524fd 100644 --- a/src/services/cipher.service.ts +++ b/src/services/cipher.service.ts @@ -40,6 +40,7 @@ import { SortedCiphersCache } from '../models/domain/sortedCiphersCache'; import { ApiService } from '../abstractions/api.service'; import { CipherService as CipherServiceAbstraction } from '../abstractions/cipher.service'; import { CryptoService } from '../abstractions/crypto.service'; +import { FileUploadService } from '../abstractions/fileUpload.service'; import { I18nService } from '../abstractions/i18n.service'; import { SearchService } from '../abstractions/search.service'; import { SettingsService } from '../abstractions/settings.service'; @@ -50,6 +51,7 @@ import { ConstantsService } from './constants.service'; import { sequentialize } from '../misc/sequentialize'; import { Utils } from '../misc/utils'; +import { AttachmentRequest } from '../models/request/attachmentRequest'; const Keys = { ciphersPrefix: 'ciphers_', @@ -69,8 +71,8 @@ export class CipherService implements CipherServiceAbstraction { constructor(private cryptoService: CryptoService, private userService: UserService, private settingsService: SettingsService, private apiService: ApiService, - private storageService: StorageService, private i18nService: I18nService, - private searchService: () => SearchService) { + private fileUploadService: FileUploadService, private storageService: StorageService, + private i18nService: I18nService, private searchService: () => SearchService) { } get decryptedCipherCache() { @@ -618,14 +620,50 @@ export class CipherService implements CipherServiceAbstraction { const dataEncKey = await this.cryptoService.makeEncKey(key); const encData = await this.cryptoService.encryptToBytes(data, dataEncKey[0]); + const request: AttachmentRequest = { + key: dataEncKey[1].encryptedString, + fileName: encFileName.encryptedString, + fileSize: encData.byteLength, + adminRequest: admin, + }; + + let response: CipherResponse; + try { + const uploadDataResponse = await this.apiService.postCipherAttachment(cipher.id, request); + response = admin ? uploadDataResponse.cipherMiniResponse : uploadDataResponse.cipherResponse; + this.fileUploadService.uploadCipherAttachment(admin, uploadDataResponse, filename, data); + } catch (e) { + if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404 || (e as ErrorResponse).statusCode === 405) { + response = await this.legacyServerAttachmentFileUpload(admin, cipher.id, encFileName, encData, dataEncKey[1]); + } else if (e instanceof ErrorResponse) { + throw new Error((e as ErrorResponse).getSingleMessage()); + } else { + throw e; + } + } + + const userId = await this.userService.getUserId(); + const cData = new CipherData(response, userId, cipher.collectionIds); + if (!admin) { + await this.upsert(cData); + } + return new Cipher(cData); + } + + /** + * @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads. + * This method still exists for backward compatibility with old server versions. + */ + async legacyServerAttachmentFileUpload(admin: boolean, cipherId: string, encFileName: CipherString, + encData: ArrayBuffer, key: CipherString) { const fd = new FormData(); try { const blob = new Blob([encData], { type: 'application/octet-stream' }); - fd.append('key', dataEncKey[1].encryptedString); + fd.append('key', key.encryptedString); fd.append('data', blob, encFileName.encryptedString); } catch (e) { if (Utils.isNode && !Utils.isBrowser) { - fd.append('key', dataEncKey[1].encryptedString); + fd.append('key', key.encryptedString); fd.append('data', Buffer.from(encData) as any, { filepath: encFileName.encryptedString, contentType: 'application/octet-stream', @@ -638,20 +676,15 @@ export class CipherService implements CipherServiceAbstraction { let response: CipherResponse; try { if (admin) { - response = await this.apiService.postCipherAttachmentAdmin(cipher.id, fd); + response = await this.apiService.postCipherAttachmentAdminLegacy(cipherId, fd); } else { - response = await this.apiService.postCipherAttachment(cipher.id, fd); + response = await this.apiService.postCipherAttachmentLegacy(cipherId, fd); } } catch (e) { throw new Error((e as ErrorResponse).getSingleMessage()); } - const userId = await this.userService.getUserId(); - const cData = new CipherData(response, userId, cipher.collectionIds); - if (!admin) { - await this.upsert(cData); - } - return new Cipher(cData); + return response; } async saveCollectionsWithServer(cipher: Cipher): Promise { diff --git a/src/services/fileUpload.service.ts b/src/services/fileUpload.service.ts index 5a56cfa1124..ba80932790a 100644 --- a/src/services/fileUpload.service.ts +++ b/src/services/fileUpload.service.ts @@ -5,6 +5,7 @@ import { LogService } from '../abstractions/log.service'; import { FileUploadType } from '../enums/fileUploadType'; import { CipherString } from '../models/domain'; +import { AttachmentUploadDataResponse } from '../models/response/attachmentUploadDataResponse'; import { SendFileUploadDataResponse } from '../models/response/sendFileUploadDataResponse'; import { AzureFileUploadService } from './azureFileUpload.service'; @@ -23,7 +24,8 @@ export class FileUploadService implements FileUploadServiceAbstraction { try { switch (uploadData.fileUploadType) { case FileUploadType.Direct: - await this.bitwardenFileUploadService.upload(uploadData.sendResponse, fileName, encryptedFileData); + await this.bitwardenFileUploadService.upload(fileName.encryptedString, encryptedFileData, + fd => this.apiService.postSendFile(uploadData.sendResponse.id, uploadData.sendResponse.file.id, fd)); break; case FileUploadType.Azure: const renewalCallback = async () => { @@ -42,4 +44,33 @@ export class FileUploadService implements FileUploadServiceAbstraction { throw e; } } + + async uploadCipherAttachment(admin: boolean, uploadData: AttachmentUploadDataResponse, encryptedFileName: string, encryptedFileData: ArrayBuffer) { + const response = admin ? uploadData.cipherMiniResponse : uploadData.cipherResponse; + try { + switch (uploadData.fileUploadType) { + case FileUploadType.Direct: + await this.bitwardenFileUploadService.upload(encryptedFileName, encryptedFileData, + fd => this.apiService.postAttachmentFile(response.id, uploadData.attachmentId, fd)); + break; + case FileUploadType.Azure: + const renewalCallback = async () => { + const renewalResponse = await this.apiService.renewAttachmentUploadUrl(response.id, + uploadData.attachmentId); + return renewalResponse.url; + }; + this.azureFileUploadService.upload(uploadData.url, encryptedFileData, renewalCallback); + break; + default: + throw new Error('Unknown file upload type.'); + } + } catch (e) { + if (admin) { + this.apiService.deleteCipherAttachmentAdmin(response.id, uploadData.attachmentId); + } else { + this.apiService.deleteCipherAttachment(response.id, uploadData.attachmentId); + } + throw e; + } + } } diff --git a/src/services/send.service.ts b/src/services/send.service.ts index 205a495345f..5bfc3de68f2 100644 --- a/src/services/send.service.ts +++ b/src/services/send.service.ts @@ -146,6 +146,8 @@ export class SendService implements SendServiceAbstraction { } catch (e) { if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) { response = await this.legacyServerSendFileUpload(sendData, request); + } else if (e instanceof ErrorResponse) { + throw new Error((e as ErrorResponse).getSingleMessage()); } else { throw e; }