diff --git a/src/abstractions/api.service.ts b/src/abstractions/api.service.ts index eba6d8582d..a1410c0fe3 100644 --- a/src/abstractions/api.service.ts +++ b/src/abstractions/api.service.ts @@ -1,6 +1,7 @@ import { EnvironmentUrls } from '../models/domain/environmentUrls'; import { CipherRequest } from '../models/request/cipherRequest'; +import { CipherShareRequest } from '../models/request/cipherShareRequest'; import { FolderRequest } from '../models/request/folderRequest'; import { ImportDirectoryRequest } from '../models/request/importDirectoryRequest'; import { PasswordHintRequest } from '../models/request/passwordHintRequest'; @@ -15,6 +16,8 @@ import { IdentityTwoFactorResponse } from '../models/response/identityTwoFactorR import { ProfileResponse } from '../models/response/profileResponse'; import { SyncResponse } from '../models/response/syncResponse'; +import { AttachmentView } from '../models/view/attachmentView'; + export abstract class ApiService { urlsSet: boolean; baseUrl: string; @@ -34,8 +37,10 @@ export abstract class ApiService { deleteFolder: (id: string) => Promise; postCipher: (request: CipherRequest) => Promise; putCipher: (id: string, request: CipherRequest) => Promise; + shareCipher: (id: string, request: CipherShareRequest) => Promise; deleteCipher: (id: string) => Promise; postCipherAttachment: (id: string, data: FormData) => Promise; + shareCipherAttachment: (id: string, attachmentId: string, data: FormData, organizationId: string) => Promise; deleteCipherAttachment: (id: string, attachmentId: string) => Promise; getSync: () => Promise; postImportDirectory: (organizationId: string, request: ImportDirectoryRequest) => Promise; diff --git a/src/abstractions/cipher.service.ts b/src/abstractions/cipher.service.ts index 73ee81047c..d621443f1b 100644 --- a/src/abstractions/cipher.service.ts +++ b/src/abstractions/cipher.service.ts @@ -6,6 +6,7 @@ import { Cipher } from '../models/domain/cipher'; import { Field } from '../models/domain/field'; import { SymmetricCryptoKey } from '../models/domain/symmetricCryptoKey'; +import { AttachmentView } from '../models/view/attachmentView'; import { CipherView } from '../models/view/cipherView'; import { FieldView } from '../models/view/fieldView'; @@ -25,6 +26,9 @@ export abstract class CipherService { updateLastUsedDate: (id: string) => Promise; saveNeverDomain: (domain: string) => Promise; saveWithServer: (cipher: Cipher) => Promise; + shareWithServer: (cipher: Cipher) => Promise; + shareAttachmentWithServer: (attachmentView: AttachmentView, cipherId: string, + organizationId: string) => Promise; saveAttachmentWithServer: (cipher: Cipher, unencryptedFile: any) => Promise; saveAttachmentRawWithServer: (cipher: Cipher, filename: string, data: ArrayBuffer) => Promise; upsert: (cipher: CipherData | CipherData[]) => Promise; diff --git a/src/models/data/attachmentData.ts b/src/models/data/attachmentData.ts index 85aa452e1e..63b822174c 100644 --- a/src/models/data/attachmentData.ts +++ b/src/models/data/attachmentData.ts @@ -7,7 +7,10 @@ export class AttachmentData { size: number; sizeName: string; - constructor(response: AttachmentResponse) { + constructor(response?: AttachmentResponse) { + if (response == null) { + return; + } this.id = response.id; this.url = response.url; this.fileName = response.fileName; diff --git a/src/models/data/cardData.ts b/src/models/data/cardData.ts index 1a4edea4ea..d87c723158 100644 --- a/src/models/data/cardData.ts +++ b/src/models/data/cardData.ts @@ -8,7 +8,11 @@ export class CardData { expYear: string; code: string; - constructor(data: CardApi) { + constructor(data?: CardApi) { + if (data == null) { + return; + } + this.cardholderName = data.cardholderName; this.brand = data.brand; this.number = data.number; diff --git a/src/models/data/cipherData.ts b/src/models/data/cipherData.ts index 1a76ed5c79..59a52fe53d 100644 --- a/src/models/data/cipherData.ts +++ b/src/models/data/cipherData.ts @@ -17,7 +17,7 @@ export class CipherData { edit: boolean; organizationUseTotp: boolean; favorite: boolean; - revisionDate: string; + revisionDate: Date; type: CipherType; sizeName: string; name: string; @@ -30,7 +30,11 @@ export class CipherData { attachments?: AttachmentData[]; collectionIds?: string[]; - constructor(response: CipherResponse, userId: string, collectionIds?: string[]) { + constructor(response?: CipherResponse, userId?: string, collectionIds?: string[]) { + if (response == null) { + return; + } + this.id = response.id; this.organizationId = response.organizationId; this.folderId = response.folderId; diff --git a/src/models/data/fieldData.ts b/src/models/data/fieldData.ts index 0632d7349d..d28d285b5c 100644 --- a/src/models/data/fieldData.ts +++ b/src/models/data/fieldData.ts @@ -7,7 +7,10 @@ export class FieldData { name: string; value: string; - constructor(response: FieldApi) { + constructor(response?: FieldApi) { + if (response == null) { + return; + } this.type = response.type; this.name = response.name; this.value = response.value; diff --git a/src/models/data/identityData.ts b/src/models/data/identityData.ts index d1ac84a7b2..50fff95785 100644 --- a/src/models/data/identityData.ts +++ b/src/models/data/identityData.ts @@ -20,7 +20,11 @@ export class IdentityData { passportNumber: string; licenseNumber: string; - constructor(data: IdentityApi) { + constructor(data?: IdentityApi) { + if (data == null) { + return; + } + this.title = data.title; this.firstName = data.firstName; this.middleName = data.middleName; diff --git a/src/models/data/loginData.ts b/src/models/data/loginData.ts index f81e8e0897..a7d504e052 100644 --- a/src/models/data/loginData.ts +++ b/src/models/data/loginData.ts @@ -9,7 +9,11 @@ export class LoginData { password: string; totp: string; - constructor(data: LoginApi) { + constructor(data?: LoginApi) { + if (data == null) { + return; + } + this.username = data.username; this.password = data.password; this.totp = data.totp; diff --git a/src/models/data/loginUriData.ts b/src/models/data/loginUriData.ts index 71ad8079c1..e696355c74 100644 --- a/src/models/data/loginUriData.ts +++ b/src/models/data/loginUriData.ts @@ -6,7 +6,10 @@ export class LoginUriData { uri: string; match: UriMatchType = null; - constructor(data: LoginUriApi) { + constructor(data?: LoginUriApi) { + if (data == null) { + return; + } this.uri = data.uri; this.match = data.match; } diff --git a/src/models/data/secureNoteData.ts b/src/models/data/secureNoteData.ts index a508c1b31d..4a24f2cf86 100644 --- a/src/models/data/secureNoteData.ts +++ b/src/models/data/secureNoteData.ts @@ -5,7 +5,11 @@ import { SecureNoteApi } from '../api/secureNoteApi'; export class SecureNoteData { type: SecureNoteType; - constructor(data: SecureNoteApi) { + constructor(data?: SecureNoteApi) { + if (data == null) { + return; + } + this.type = data.type; } } diff --git a/src/models/domain/attachment.ts b/src/models/domain/attachment.ts index 7861066e0e..be345ce72f 100644 --- a/src/models/domain/attachment.ts +++ b/src/models/domain/attachment.ts @@ -32,4 +32,15 @@ export class Attachment extends Domain { fileName: null, }, orgId); } + + toAttachmentData(): AttachmentData { + const a = new AttachmentData(); + this.buildDataModel(this, a, { + id: null, + url: null, + sizeName: null, + fileName: null, + }, ['id', 'url', 'sizeName']); + return a; + } } diff --git a/src/models/domain/card.ts b/src/models/domain/card.ts index 19a2ad9e50..a9a4791274 100644 --- a/src/models/domain/card.ts +++ b/src/models/domain/card.ts @@ -39,4 +39,17 @@ export class Card extends Domain { code: null, }, orgId); } + + toCardData(): CardData { + const c = new CardData(); + this.buildDataModel(this, c, { + cardholderName: null, + brand: null, + number: null, + expMonth: null, + expYear: null, + code: null, + }); + return c; + } } diff --git a/src/models/domain/cipher.ts b/src/models/domain/cipher.ts index 7f9c18d1f0..ad5cd75587 100644 --- a/src/models/domain/cipher.ts +++ b/src/models/domain/cipher.ts @@ -23,6 +23,7 @@ export class Cipher extends Domain { favorite: boolean; organizationUseTotp: boolean; edit: boolean; + revisionDate: Date; localData: any; login: Login; identity: Identity; @@ -40,16 +41,18 @@ export class Cipher extends Domain { this.buildDomainModel(this, obj, { id: null, + userId: null, organizationId: null, folderId: null, name: null, notes: null, - }, alreadyEncrypted, ['id', 'organizationId', 'folderId']); + }, alreadyEncrypted, ['id', 'userId', 'organizationId', 'folderId']); this.type = obj.type; this.favorite = obj.favorite; this.organizationUseTotp = obj.organizationUseTotp; this.edit = obj.edit; + this.revisionDate = obj.revisionDate; this.collectionIds = obj.collectionIds; this.localData = localData; @@ -142,4 +145,55 @@ export class Cipher extends Domain { return model; } + + toCipherData(userId: string): CipherData { + const c = new CipherData(); + c.id = this.id; + c.organizationId = this.organizationId; + c.folderId = this.folderId; + c.userId = this.organizationId != null ? userId : null; + c.edit = this.edit; + c.organizationUseTotp = this.organizationUseTotp; + c.favorite = this.favorite; + c.revisionDate = this.revisionDate; + c.type = this.type; + c.collectionIds = this.collectionIds; + + this.buildDataModel(this, c, { + name: null, + notes: null, + }); + + switch (c.type) { + case CipherType.Login: + c.login = this.login.toLoginData(); + break; + case CipherType.SecureNote: + c.secureNote = this.secureNote.toSecureNoteData(); + break; + case CipherType.Card: + c.card = this.card.toCardData(); + break; + case CipherType.Identity: + c.identity = this.identity.toIdentityData(); + break; + default: + break; + } + + if (this.fields != null) { + c.fields = []; + this.fields.forEach((field) => { + c.fields.push(field.toFieldData()); + }); + } + + if (this.attachments != null) { + c.attachments = []; + this.attachments.forEach((attachment) => { + c.attachments.push(attachment.toAttachmentData()); + }); + } + return c; + } } diff --git a/src/models/domain/domain.ts b/src/models/domain/domain.ts index 7fe644740f..8eed0f5699 100644 --- a/src/models/domain/domain.ts +++ b/src/models/domain/domain.ts @@ -18,6 +18,20 @@ export default abstract class Domain { } } } + protected buildDataModel(domain: D, dataObj: any, map: any, notCipherStringList: any[] = []) { + for (const prop in map) { + if (!map.hasOwnProperty(prop)) { + continue; + } + + const objProp = (domain as any)[(map[prop] || prop)]; + if (notCipherStringList.indexOf(prop) > -1) { + (dataObj as any)[prop] = objProp != null ? objProp : null; + } else { + (dataObj as any)[prop] = objProp != null ? (objProp as CipherString).encryptedString : null; + } + } + } protected async decryptObj(viewModel: T, map: any, orgId: string): Promise { const promises = []; diff --git a/src/models/domain/field.ts b/src/models/domain/field.ts index 599dabff1c..82a884c849 100644 --- a/src/models/domain/field.ts +++ b/src/models/domain/field.ts @@ -31,4 +31,14 @@ export class Field extends Domain { value: null, }, orgId); } + + toFieldData(): FieldData { + const f = new FieldData(); + this.buildDataModel(this, f, { + name: null, + value: null, + type: null, + }, ['type']); + return f; + } } diff --git a/src/models/domain/identity.ts b/src/models/domain/identity.ts index ade03260ea..266d697579 100644 --- a/src/models/domain/identity.ts +++ b/src/models/domain/identity.ts @@ -75,4 +75,29 @@ export class Identity extends Domain { licenseNumber: null, }, orgId); } + + toIdentityData(): IdentityData { + const i = new IdentityData(); + this.buildDataModel(this, i, { + title: null, + firstName: null, + middleName: null, + lastName: null, + address1: null, + address2: null, + address3: null, + city: null, + state: null, + postalCode: null, + country: null, + company: null, + email: null, + phone: null, + ssn: null, + username: null, + passportNumber: null, + licenseNumber: null, + }); + return i; + } } diff --git a/src/models/domain/login.ts b/src/models/domain/login.ts index 211a371069..3d81b0eb09 100644 --- a/src/models/domain/login.ts +++ b/src/models/domain/login.ts @@ -51,4 +51,22 @@ export class Login extends Domain { return view; } + + toLoginData(): LoginData { + const l = new LoginData(); + this.buildDataModel(this, l, { + username: null, + password: null, + totp: null, + }); + + if (this.uris != null && this.uris.length > 0) { + l.uris = []; + this.uris.forEach((u) => { + l.uris.push(u.toLoginUriData()); + }); + } + + return l; + } } diff --git a/src/models/domain/loginUri.ts b/src/models/domain/loginUri.ts index 0cb6b19a7f..1e1d9a93fc 100644 --- a/src/models/domain/loginUri.ts +++ b/src/models/domain/loginUri.ts @@ -28,4 +28,12 @@ export class LoginUri extends Domain { uri: null, }, orgId); } + + toLoginUriData(): LoginUriData { + const u = new LoginUriData(); + this.buildDataModel(this, u, { + uri: null, + }, ['match']); + return u; + } } diff --git a/src/models/domain/secureNote.ts b/src/models/domain/secureNote.ts index 79724a4193..52988f1b25 100644 --- a/src/models/domain/secureNote.ts +++ b/src/models/domain/secureNote.ts @@ -21,4 +21,10 @@ export class SecureNote extends Domain { decrypt(orgId: string): Promise { return Promise.resolve(new SecureNoteView(this)); } + + toSecureNoteData(): SecureNoteData { + const n = new SecureNoteData(); + n.type = this.type; + return n; + } } diff --git a/src/models/request/cipherBulkDeleteRequest.ts b/src/models/request/cipherBulkDeleteRequest.ts new file mode 100644 index 0000000000..ccdf8ed22b --- /dev/null +++ b/src/models/request/cipherBulkDeleteRequest.ts @@ -0,0 +1,7 @@ +export class CipherBulkDeleteRequest { + ids: string[]; + + constructor(ids: string[]) { + this.ids = ids; + } +} diff --git a/src/models/request/cipherBulkMoveRequest.ts b/src/models/request/cipherBulkMoveRequest.ts new file mode 100644 index 0000000000..0d78e4f165 --- /dev/null +++ b/src/models/request/cipherBulkMoveRequest.ts @@ -0,0 +1,9 @@ +export class CipherBulkMoveRequest { + ids: string[]; + folderId: string; + + constructor(ids: string[], folderId: string) { + this.ids = ids; + this.folderId = folderId; + } +} diff --git a/src/models/request/cipherCollectionsRequest.ts b/src/models/request/cipherCollectionsRequest.ts new file mode 100644 index 0000000000..14718e2308 --- /dev/null +++ b/src/models/request/cipherCollectionsRequest.ts @@ -0,0 +1,7 @@ +export class CipherCollectionsRequest { + collectionIds: string[]; + + constructor(collectionIds: string[]) { + this.collectionIds = collectionIds; + } +} diff --git a/src/models/request/cipherRequest.ts b/src/models/request/cipherRequest.ts index a608cecb7a..a4d478c81f 100644 --- a/src/models/request/cipherRequest.ts +++ b/src/models/request/cipherRequest.ts @@ -21,6 +21,7 @@ export class CipherRequest { card: CardApi; identity: IdentityApi; fields: FieldApi[]; + attachments: { [id: string]: string; }; constructor(cipher: Cipher) { this.type = cipher.type; @@ -101,5 +102,12 @@ export class CipherRequest { }); }); } + + if (cipher.attachments) { + this.attachments = {}; + cipher.attachments.forEach((attachment) => { + this.attachments[attachment.id] = attachment.fileName ? attachment.fileName.encryptedString : null; + }); + } } } diff --git a/src/models/request/cipherShareRequest.ts b/src/models/request/cipherShareRequest.ts new file mode 100644 index 0000000000..4646890254 --- /dev/null +++ b/src/models/request/cipherShareRequest.ts @@ -0,0 +1,13 @@ +import { CipherRequest } from './cipherRequest'; + +import { Cipher } from '../domain/cipher'; + +export class CipherShareRequest { + cipher: CipherRequest; + collectionIds: string[]; + + constructor(cipher: Cipher) { + this.cipher = new CipherRequest(cipher); + this.collectionIds = cipher.collectionIds; + } +} diff --git a/src/models/response/cipherResponse.ts b/src/models/response/cipherResponse.ts index c8ed70ffd0..61c8907310 100644 --- a/src/models/response/cipherResponse.ts +++ b/src/models/response/cipherResponse.ts @@ -21,7 +21,7 @@ export class CipherResponse { favorite: boolean; edit: boolean; organizationUseTotp: boolean; - revisionDate: string; + revisionDate: Date; attachments: AttachmentResponse[]; collectionIds: string[]; @@ -35,7 +35,7 @@ export class CipherResponse { this.favorite = response.Favorite; this.edit = response.Edit; this.organizationUseTotp = response.OrganizationUseTotp; - this.revisionDate = response.RevisionDate; + this.revisionDate = new Date(response.RevisionDate); if (response.Login != null) { this.login = new LoginApi(response.Login); diff --git a/src/services/api.service.ts b/src/services/api.service.ts index 5a512c4509..806ffb151c 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 { CipherRequest } from '../models/request/cipherRequest'; +import { CipherShareRequest } from '../models/request/cipherShareRequest'; import { FolderRequest } from '../models/request/folderRequest'; import { ImportDirectoryRequest } from '../models/request/importDirectoryRequest'; import { PasswordHintRequest } from '../models/request/passwordHintRequest'; @@ -334,6 +335,27 @@ export class ApiService implements ApiServiceAbstraction { } } + async shareCipher(id: string, request: CipherShareRequest): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id + '/share', { + body: JSON.stringify(request), + cache: 'no-cache', + credentials: this.getCredentials(), + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Content-Type': 'application/json; charset=utf-8', + 'Device-Type': this.deviceType, + }), + method: 'PUT', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + async deleteCipher(id: string): Promise { const authHeader = await this.handleTokenState(); const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id, { @@ -377,6 +399,28 @@ export class ApiService implements ApiServiceAbstraction { } } + async shareCipherAttachment(id: string, attachmentId: string, data: FormData, + organizationId: string): Promise { + const authHeader = await this.handleTokenState(); + const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id + '/attachment/' + + attachmentId + '/share?organizationId=' + organizationId, { + body: data, + cache: 'no-cache', + credentials: this.getCredentials(), + headers: new Headers({ + 'Accept': 'application/json', + 'Authorization': authHeader, + 'Device-Type': this.deviceType, + }), + method: 'POST', + })); + + if (response.status !== 200) { + const error = await this.handleError(response, false); + return Promise.reject(error); + } + } + async deleteCipherAttachment(id: string, attachmentId: string): Promise { const authHeader = await this.handleTokenState(); const response = await fetch(new Request(this.baseUrl + '/ciphers/' + id + '/attachment/' + attachmentId, { diff --git a/src/services/cipher.service.ts b/src/services/cipher.service.ts index 45b2b93e99..6c89d23141 100644 --- a/src/services/cipher.service.ts +++ b/src/services/cipher.service.ts @@ -3,6 +3,7 @@ import { UriMatchType } from '../enums/uriMatchType'; import { CipherData } from '../models/data/cipherData'; +import { Attachment } from '../models/domain/attachment'; import { Card } from '../models/domain/card'; import { Cipher } from '../models/domain/cipher'; import { CipherString } from '../models/domain/cipherString'; @@ -19,6 +20,7 @@ import { CipherRequest } from '../models/request/cipherRequest'; import { CipherResponse } from '../models/response/cipherResponse'; import { ErrorResponse } from '../models/response/errorResponse'; +import { AttachmentView } from '../models/view/attachmentView'; import { CardView } from '../models/view/cardView'; import { CipherView } from '../models/view/cipherView'; import { FieldView } from '../models/view/fieldView'; @@ -38,6 +40,7 @@ import { StorageService } from '../abstractions/storage.service'; import { UserService } from '../abstractions/user.service'; import { Utils } from '../misc/utils'; +import { CipherShareRequest } from '../models/request/cipherShareRequest'; const Keys = { ciphersPrefix: 'ciphers_', @@ -77,11 +80,39 @@ export class CipherService implements CipherServiceAbstraction { this.encryptFields(model.fields, key).then((fields) => { cipher.fields = fields; }), + this.encryptAttachments(model.attachments, key).then((attachments) => { + cipher.attachments = attachments; + }), ]); return cipher; } + async encryptAttachments(attachmentsModel: AttachmentView[], key: SymmetricCryptoKey): Promise { + if (attachmentsModel == null || attachmentsModel.length === 0) { + return null; + } + + const promises: Array> = []; + const encAttachments: Attachment[] = []; + attachmentsModel.forEach(async (model) => { + const attachment = new Attachment(); + attachment.id = model.id; + attachment.size = model.size; + attachment.sizeName = model.sizeName; + attachment.url = model.url; + const promise = this.encryptObjProperty(model, attachment, { + fileName: null, + }, key).then(() => { + encAttachments.push(attachment); + }); + promises.push(promise); + }); + + await Promise.all(promises); + return encAttachments; + } + async encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise { if (!fieldsModel || !fieldsModel.length) { return null; @@ -324,6 +355,49 @@ export class CipherService implements CipherServiceAbstraction { await this.upsert(data); } + async shareWithServer(cipher: Cipher): Promise { + const request = new CipherShareRequest(cipher); + await this.apiService.shareCipher(cipher.id, request); + const userId = await this.userService.getUserId(); + await this.upsert(cipher.toCipherData(userId)); + } + + async shareAttachmentWithServer(attachmentView: AttachmentView, cipherId: string, + organizationId: string): Promise { + const attachmentResponse = await fetch(new Request(attachmentView.url, { cache: 'no-cache' })); + if (attachmentResponse.status !== 200) { + throw Error('Failed to download attachment: ' + attachmentResponse.status.toString()); + } + + const buf = await attachmentResponse.arrayBuffer(); + const decBuf = await this.cryptoService.decryptFromBytes(buf, null); + const key = await this.cryptoService.getOrgKey(organizationId); + const encData = await this.cryptoService.encryptToBytes(decBuf, key); + const encFileName = await this.cryptoService.encrypt(attachmentView.fileName, key); + + const fd = new FormData(); + try { + const blob = new Blob([encData], { type: 'application/octet-stream' }); + fd.append('data', blob, encFileName.encryptedString); + } catch (e) { + if (Utils.isNode && !Utils.isBrowser) { + fd.append('data', new Buffer(encData) as any, { + filepath: encFileName.encryptedString, + contentType: 'application/octet-stream', + } as any); + } else { + throw e; + } + } + + let response: CipherResponse; + try { + response = await this.apiService.shareCipherAttachment(cipherId, attachmentView.id, fd, organizationId); + } catch (e) { + throw new Error((e as ErrorResponse).getSingleMessage()); + } + } + saveAttachmentWithServer(cipher: Cipher, unencryptedFile: any): Promise { return new Promise((resolve, reject) => { const reader = new FileReader();