1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-10749] [BEEEP] New export format: Zip with attachments (#10465)

* Add new export format: zip

* Restrict zip export to just individual vaults

* Add tests

* Remove unused import

* Fix build error

* Fix tests

* Fix test

* Fix retrieval of ciphers by passing in activeUserId

* Guard feature behind `export-attachments`-feature-flag

* Extend cipher filter to also filter out any ciphers that are assigned to an organization

* Added apiService to retrieve AttachmentData (metaData) and then download the attachment

- Added ApiService as a depdency within DI for VaultExportService/IndividualVaultExportService
- Added unit tests for filtering ciphers
- Added unit test for downloading attachment metadata and attachments

* Moved attachment decryption into a separate method and added unit tests

* Added null check for creating the base attachment folder

* Move format check for zip within Org export into an early return/throw

* Add feature flag guard on the CLI

* Extend ExportScopeCallout to display an individual export will contain attachment when zip-format is selected

* Fix adding/removing the zip-export option based on selected vault and state of `export-attachments` feature-flag

* Separate AAA visually using whitespace within tests

* Remove unused error var

* Write test that verifies different http request failures when retrieving attachment data

* Remove uneeded ignore lint rule

* Rewrite test to actually check that ciphers assigned to an org are filtered out

* Introduce ExportedVault return type (#13842)

* Define ExportedVault type unioned by 2 new types that describe a plain-text export vs a blob-based zip-export

* Extend static getFileName to handle formats and add unit-tests

* Introduce new export return type throughout the vault export module

- Update abstractions
- Update return types within implementations
- Update callers/consumers to handle the new return value
- Fix all unit tests

* Add support for new export return type and fix download of blobs via CLI

* Add documentation to public methods

---------

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>

---------

Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2025-03-25 13:30:54 +01:00
committed by GitHub
parent 034112f42e
commit 27baa92fcf
23 changed files with 592 additions and 156 deletions

View File

@@ -3007,6 +3007,15 @@
}
}
},
"exportingIndividualVaultWithAttachmentsDescription": {
"message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
}
}
},
"exportingOrganizationVaultTitle": {
"message": "Exporting organization vault"
},

View File

@@ -1026,6 +1026,7 @@ export default class MainBackground {
this.cryptoFunctionService,
this.kdfConfigService,
this.accountService,
this.apiService,
);
this.organizationVaultExportService = new OrganizationVaultExportService(

View File

@@ -795,6 +795,7 @@ export class ServiceContainer {
this.cryptoFunctionService,
this.kdfConfigService,
this.accountService,
this.apiService,
);
this.organizationExportService = new OrganizationVaultExportService(

View File

@@ -7,11 +7,15 @@ import { EventCollectionService } from "@bitwarden/common/abstractions/event/eve
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
ExportFormat,
EXPORT_FORMATS,
VaultExportServiceAbstraction,
ExportedVault,
ExportedVaultAsBlob,
} from "@bitwarden/vault-export-core";
import { Response } from "../models/response";
@@ -22,6 +26,7 @@ export class ExportCommand {
private exportService: VaultExportServiceAbstraction,
private policyService: PolicyService,
private eventCollectionService: EventCollectionService,
private configService: ConfigService,
) {}
async run(options: OptionValues): Promise<Response> {
@@ -42,6 +47,13 @@ export class ExportCommand {
const format =
password && options.format == "json" ? "encrypted_json" : (options.format ?? "csv");
if (
format == "zip" &&
!(await this.configService.getFeatureFlag(FeatureFlag.ExportAttachments))
) {
return Response.badRequest("Exporting attachments is not supported in this environment.");
}
if (!this.isSupportedExportFormat(format)) {
return Response.badRequest(
`'${format}' is not a supported export format. Supported formats: ${EXPORT_FORMATS.join(
@@ -54,7 +66,7 @@ export class ExportCommand {
return Response.error("`" + options.organizationid + "` is not a GUID.");
}
let exportContent: string = null;
let exportContent: ExportedVault = null;
try {
if (format === "encrypted_json") {
password = await this.promptPassword(password);
@@ -78,34 +90,28 @@ export class ExportCommand {
} catch (e) {
return Response.error(e);
}
return await this.saveFile(exportContent, options, format);
return await this.saveFile(exportContent, options);
}
private async saveFile(
exportContent: string,
options: OptionValues,
format: ExportFormat,
): Promise<Response> {
private async saveFile(exportContent: ExportedVault, options: OptionValues): Promise<Response> {
try {
const fileName = this.getFileName(format, options.organizationid != null ? "org" : null);
return await CliUtils.saveResultToFile(exportContent, options.output, fileName);
if (exportContent.type === "application/zip") {
exportContent = exportContent as ExportedVaultAsBlob;
const arrayBuffer = await exportContent.data.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return await CliUtils.saveResultToFile(buffer, options.output, exportContent.fileName);
}
return await CliUtils.saveResultToFile(
exportContent.data,
options.output,
exportContent.fileName,
);
} catch (e) {
return Response.error(e.toString());
}
}
private getFileName(format: ExportFormat, prefix?: string) {
if (format === "encrypted_json") {
if (prefix == null) {
prefix = "encrypted";
} else {
prefix = "encrypted_" + prefix;
}
format = "json";
}
return this.exportService.getFileName(prefix, format);
}
private async promptPassword(password: string | boolean) {
// boolean => flag set with no value, we need to prompt for password
// string => flag set with value, use this value for password

View File

@@ -501,6 +501,7 @@ export class VaultProgram extends BaseProgram {
this.serviceContainer.exportService,
this.serviceContainer.policyService,
this.serviceContainer.eventCollectionService,
this.serviceContainer.configService,
);
const response = await command.run(options);
this.processResponse(response);

View File

@@ -2490,6 +2490,15 @@
}
}
},
"exportingIndividualVaultWithAttachmentsDescription": {
"message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
}
}
},
"exportingOrganizationVaultTitle": {
"message": "Exporting organization vault"
},

View File

@@ -6762,6 +6762,15 @@
}
}
},
"exportingIndividualVaultWithAttachmentsDescription": {
"message": "Only the individual vault items including attachments associated with $EMAIL$ will be exported. Organization vault items will not be included",
"placeholders": {
"email": {
"content": "$1",
"example": "name@example.com"
}
}
},
"exportingOrganizationVaultDesc": {
"message": "Only the organization vault associated with $ORGANIZATION$ will be exported. Items in individual vaults or other organizations will not be included.",
"placeholders": {

View File

@@ -846,6 +846,7 @@ const safeProviders: SafeProvider[] = [
CryptoFunctionServiceAbstraction,
KdfConfigService,
AccountServiceAbstraction,
ApiServiceAbstraction,
],
}),
safeProvider({

View File

@@ -0,0 +1,30 @@
import { ExportHelper } from "./export-helper";
describe("ExportHelper", () => {
describe("getFileName", () => {
it("should generate a filename with default prefix and format", () => {
const fileName = ExportHelper.getFileName();
expect(fileName).toMatch(/^bitwarden_export_\d{8}\d{6}\.csv$/);
});
it("should generate a filename with given prefix and default format", () => {
const fileName = ExportHelper.getFileName("test");
expect(fileName).toMatch(/^bitwarden_test_export_\d{8}\d{6}\.csv$/);
});
it("should generate a filename with given prefix and given format", () => {
const fileName = ExportHelper.getFileName("org", "json");
expect(fileName).toMatch(/^bitwarden_org_export_\d{8}\d{6}\.json$/);
});
it("should generate a filename with encrypted_json format and modify prefix", () => {
const fileName = ExportHelper.getFileName("org", "encrypted_json");
expect(fileName).toMatch(/^bitwarden_encrypted_org_export_\d{8}\d{6}\.json$/);
});
it("should generate a filename with encrypted_json format and default prefix", () => {
const fileName = ExportHelper.getFileName("", "encrypted_json");
expect(fileName).toMatch(/^bitwarden_encrypted_export_\d{8}\d{6}\.json$/);
});
});
});

View File

@@ -1,7 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class ExportHelper {
static getFileName(prefix: string = null, extension = "csv"): string {
static getFileName(prefix: string = "", format = "csv"): string {
if (format === "encrypted_json") {
if (prefix == "") {
prefix = "encrypted";
} else {
prefix = "encrypted_" + prefix;
}
format = "json";
}
const now = new Date();
const dateString =
now.getFullYear() +
@@ -14,7 +21,7 @@ export class ExportHelper {
this.padNumber(now.getMinutes(), 2) +
this.padNumber(now.getSeconds(), 2);
return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + extension;
return "bitwarden" + (prefix ? "_" + prefix : "") + "_export_" + dateString + "." + format;
}
private static padNumber(num: number, width: number, padCharacter = "0"): string {

View File

@@ -1,8 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ExportedVault } from "../types";
import { ExportFormat } from "./vault-export.service.abstraction";
export abstract class IndividualVaultExportServiceAbstraction {
getExport: (format: ExportFormat) => Promise<string>;
getPasswordProtectedExport: (password: string) => Promise<string>;
abstract getExport: (format: ExportFormat) => Promise<ExportedVault>;
abstract getPasswordProtectedExport: (password: string) => Promise<ExportedVault>;
}

View File

@@ -1,7 +1,9 @@
import { mock, MockProxy } from "jest-mock-extended";
import * as JSZip from "jszip";
import { BehaviorSubject, of } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export";
@@ -12,9 +14,14 @@ import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { AttachmentData } from "@bitwarden/common/vault/models/data/attachment.data";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { Attachment } from "@bitwarden/common/vault/models/domain/attachment";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
import { Login } from "@bitwarden/common/vault/models/domain/login";
import { AttachmentResponse } from "@bitwarden/common/vault/models/response/attachment.response";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
@@ -27,6 +34,12 @@ import {
} from "@bitwarden/key-management";
import { BuildTestObject, GetUniqueString } from "../../../../../../common/spec";
import {
BitwardenJsonExport,
ExportedVault,
ExportedVaultAsBlob,
ExportedVaultAsString,
} from "../types";
import { IndividualVaultExportService } from "./individual-vault-export.service";
@@ -156,6 +169,8 @@ describe("VaultExportService", () => {
let encryptService: MockProxy<EncryptService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let accountService: MockProxy<AccountService>;
let apiService: MockProxy<ApiService>;
let fetchMock: jest.Mock;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
@@ -166,6 +181,7 @@ describe("VaultExportService", () => {
encryptService = mock<EncryptService>();
kdfConfigService = mock<KdfConfigService>();
accountService = mock<AccountService>();
apiService = mock<ApiService>();
keyService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
@@ -178,10 +194,23 @@ describe("VaultExportService", () => {
const activeAccount = { id: userId, ...accountInfo };
accountService.activeAccount$ = new BehaviorSubject(activeAccount);
fetchMock = jest.fn().mockResolvedValue({});
global.fetch = fetchMock;
const attachmentResponse = {
id: GetUniqueString("id"),
url: "https://someurl.com",
fileName: "fileName",
key: GetUniqueString("key"),
size: "size",
sizeName: "sizeName",
} as AttachmentResponse;
folderService.folderViews$.mockReturnValue(of(UserFolderViews));
folderService.folders$.mockReturnValue(of(UserFolders));
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
encryptService.encrypt.mockResolvedValue(new EncString("encrypted"));
apiService.getAttachmentData.mockResolvedValue(attachmentResponse);
exportService = new IndividualVaultExportService(
folderService,
@@ -192,6 +221,7 @@ describe("VaultExportService", () => {
cryptoFunctionService,
kdfConfigService,
accountService,
apiService,
);
});
@@ -199,35 +229,160 @@ describe("VaultExportService", () => {
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1));
const actual = await exportService.getExport("json");
expectEqualCiphers(UserCipherViews.slice(0, 1), actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherViews.slice(0, 1), exportedData.data);
});
it("exports encrypted json user ciphers", async () => {
cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1));
const actual = await exportService.getExport("encrypted_json");
expectEqualCiphers(UserCipherDomains.slice(0, 1), actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherDomains.slice(0, 1), exportedData.data);
});
it("does not unencrypted export trashed user items", async () => {
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews);
const actual = await exportService.getExport("json");
expectEqualCiphers(UserCipherViews.slice(0, 2), actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherViews.slice(0, 2), exportedData.data);
});
it("does not encrypted export trashed user items", async () => {
cipherService.getAll.mockResolvedValue(UserCipherDomains);
const actual = await exportService.getExport("encrypted_json");
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherDomains.slice(0, 2), exportedData.data);
});
expectEqualCiphers(UserCipherDomains.slice(0, 2), actual);
describe("zip export", () => {
it("contains data.json", async () => {
cipherService.getAllDecrypted.mockResolvedValue([]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
const exportedVault = await exportService.getExport("zip");
expect(exportedVault.type).toBe("application/zip");
const exportZip = exportedVault as ExportedVaultAsBlob;
const zip = await JSZip.loadAsync(exportZip.data);
const data = await zip.file("data.json")?.async("string");
expect(data).toBeDefined();
});
it("filters out ciphers that are assigned to an org", async () => {
// Create a cipher that is not assigned to an org
const cipherData = new CipherData();
cipherData.id = "mock-id";
const cipherView = new CipherView(new Cipher(cipherData));
// Create a cipher that is assigned to an org
const orgCipher = new CipherData();
orgCipher.id = "mock-from-org-id";
orgCipher.organizationId = "mock-org-id";
const orgCipherView = new CipherView(new Cipher(orgCipher));
// Mock the cipher service to return both ciphers
cipherService.getAllDecrypted.mockResolvedValue([cipherView, orgCipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
const exportedVault = await exportService.getExport("zip");
const zip = await JSZip.loadAsync(exportedVault.data);
const data = await zip.file("data.json")?.async("string");
const exportData: BitwardenJsonExport = JSON.parse(data);
expect(exportData.items.length).toBe(1);
expect(exportData.items[0].id).toBe("mock-id");
expect(exportData.items[0].organizationId).toBe(null);
});
it.each([[400], [401], [404], [500]])(
"throws error if the http request fails (status === %n)",
async (status) => {
const cipherData = new CipherData();
cipherData.id = "mock-id";
const cipherView = new CipherView(new Cipher(cipherData));
const attachmentView = new AttachmentView(new Attachment(new AttachmentData()));
attachmentView.fileName = "mock-file-name";
cipherView.attachments = [attachmentView];
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
status,
}),
) as any;
global.Request = jest.fn(() => {}) as any;
await expect(async () => {
await exportService.getExport("zip");
}).rejects.toThrow("Error downloading attachment");
},
);
it("throws error if decrypting attachment fails", async () => {
const cipherData = new CipherData();
cipherData.id = "mock-id";
const cipherView = new CipherView(new Cipher(cipherData));
const attachmentView = new AttachmentView(new Attachment(new AttachmentData()));
attachmentView.fileName = "mock-file-name";
cipherView.attachments = [attachmentView];
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
status: 200,
arrayBuffer: () => Promise.resolve(null),
}),
) as any;
global.Request = jest.fn(() => {}) as any;
await expect(async () => {
await exportService.getExport("zip");
}).rejects.toThrow("Error decrypting attachment");
});
it("contains attachments with folders", async () => {
const cipherData = new CipherData();
cipherData.id = "mock-id";
const cipherView = new CipherView(new Cipher(cipherData));
const attachmentView = new AttachmentView(new Attachment(new AttachmentData()));
attachmentView.fileName = "mock-file-name";
cipherView.attachments = [attachmentView];
cipherService.getAllDecrypted.mockResolvedValue([cipherView]);
folderService.getAllDecryptedFromState.mockResolvedValue([]);
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(255));
global.fetch = jest.fn(() =>
Promise.resolve({
status: 200,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(255)),
}),
) as any;
global.Request = jest.fn(() => {}) as any;
const exportedVault = await exportService.getExport("zip");
expect(exportedVault.type).toBe("application/zip");
const exportZip = exportedVault as ExportedVaultAsBlob;
const zip = await JSZip.loadAsync(exportZip.data);
const attachment = await zip.file("attachments/mock-id/mock-file-name")?.async("blob");
expect(attachment).toBeDefined();
});
});
describe("password protected export", () => {
let exportedVault: ExportedVault;
let exportString: string;
let exportObject: any;
let mac: MockProxy<EncString>;
@@ -246,7 +401,8 @@ describe("VaultExportService", () => {
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(salt);
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1));
exportString = await exportService.getPasswordProtectedExport(password);
exportedVault = await exportService.getPasswordProtectedExport(password);
exportString = exportedVault.data;
exportObject = JSON.parse(exportString);
});
@@ -272,7 +428,8 @@ describe("VaultExportService", () => {
it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
exportString = await exportService.getPasswordProtectedExport(password);
exportedVault = await exportService.getPasswordProtectedExport(password);
exportString = exportedVault.data;
exportObject = JSON.parse(exportString);
expect(exportObject.encKeyValidation_DO_NOT_EDIT).toEqual(mac.encryptedString);
@@ -280,15 +437,18 @@ describe("VaultExportService", () => {
it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
exportString = await exportService.getPasswordProtectedExport(password);
exportedVault = await exportService.getPasswordProtectedExport(password);
exportString = exportedVault.data;
exportObject = JSON.parse(exportString);
expect(exportObject.data).toEqual(data.encryptedString);
});
it("encrypts the data property", async () => {
const unencrypted = await exportService.getExport();
expect(exportObject.data).not.toEqual(unencrypted);
const unEncryptedExportVault = await exportService.getExport();
const unEncryptedExportString = unEncryptedExportVault.data;
expect(exportObject.data).not.toEqual(unEncryptedExportString);
});
});
});
@@ -296,17 +456,23 @@ describe("VaultExportService", () => {
it("exported unencrypted object contains folders", async () => {
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1));
folderService.folderViews$.mockReturnValue(of(UserFolderViews));
const actual = await exportService.getExport("json");
expectEqualFolderViews(UserFolderViews, actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualFolderViews(UserFolderViews, exportedData.data);
});
it("exported encrypted json contains folders", async () => {
cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1));
folderService.folders$.mockReturnValue(of(UserFolders));
const actual = await exportService.getExport("encrypted_json");
expectEqualFolders(UserFolders, actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualFolders(UserFolders, exportedData.data);
});
});

View File

@@ -1,20 +1,24 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as JSZip from "jszip";
import * as papa from "papaparse";
import { firstValueFrom } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -23,9 +27,13 @@ import {
BitwardenCsvIndividualExportType,
BitwardenEncryptedIndividualJsonExport,
BitwardenUnEncryptedIndividualJsonExport,
ExportedVault,
ExportedVaultAsBlob,
ExportedVaultAsString,
} from "../types";
import { BaseVaultExportService } from "./base-vault-export.service";
import { ExportHelper } from "./export-helper";
import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction";
import { ExportFormat } from "./vault-export.service.abstraction";
@@ -42,23 +50,118 @@ export class IndividualVaultExportService
cryptoFunctionService: CryptoFunctionService,
kdfConfigService: KdfConfigService,
private accountService: AccountService,
private apiService: ApiService,
) {
super(pinService, encryptService, cryptoFunctionService, kdfConfigService);
}
async getExport(format: ExportFormat = "csv"): Promise<string> {
/** Creates an export of an individual vault (My Vault). Based on the provided format it will either be unencrypted, encrypted or password protected and in case zip is selected will include attachments
* @param format The format of the export
*/
async getExport(format: ExportFormat = "csv"): Promise<ExportedVault> {
if (format === "encrypted_json") {
return this.getEncryptedExport();
} else if (format === "zip") {
return this.getDecryptedExportZip();
}
return this.getDecryptedExport(format);
}
async getPasswordProtectedExport(password: string): Promise<string> {
const clearText = await this.getExport("json");
return this.buildPasswordExport(clearText, password);
/** Creates a password protected export of an individiual vault (My Vault) as a JSON file
* @param password The password to encrypt the export with
* @returns A password-protected encrypted individual vault export
*/
async getPasswordProtectedExport(password: string): Promise<ExportedVaultAsString> {
const exportVault = await this.getExport("json");
if (exportVault.type !== "text/plain") {
throw new Error("Unexpected export type");
}
private async getDecryptedExport(format: "json" | "csv"): Promise<string> {
return {
type: "text/plain",
data: await this.buildPasswordExport(exportVault.data, password),
fileName: ExportHelper.getFileName("json"),
} as ExportedVaultAsString;
}
/** Creates a unencrypted export of an individual vault including attachments
* @returns A unencrypted export including attachments
*/
async getDecryptedExportZip(): Promise<ExportedVaultAsBlob> {
const zip = new JSZip();
// ciphers
const exportedVault = await this.getDecryptedExport("json");
zip.file("data.json", exportedVault.data);
const attachmentsFolder = zip.folder("attachments");
if (attachmentsFolder == null) {
throw new Error("Error creating attachments folder");
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// attachments
for (const cipher of await this.cipherService.getAllDecrypted(activeUserId)) {
if (
!cipher.attachments ||
cipher.attachments.length === 0 ||
cipher.deletedDate != null ||
cipher.organizationId != null
) {
continue;
}
const cipherFolder = attachmentsFolder.folder(cipher.id);
for (const attachment of cipher.attachments) {
const response = await this.downloadAttachment(cipher.id, attachment.id);
const decBuf = await this.decryptAttachment(cipher, attachment, response);
cipherFolder.file(attachment.fileName, decBuf);
}
}
const blobData = await zip.generateAsync({ type: "blob" });
return {
type: "application/zip",
data: blobData,
fileName: ExportHelper.getFileName("json"),
} as ExportedVaultAsBlob;
}
private async downloadAttachment(cipherId: string, attachmentId: string): Promise<Response> {
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
cipherId,
attachmentId,
);
const url = attachmentDownloadResponse.url;
const response = await fetch(new Request(url, { cache: "no-store" }));
if (response.status !== 200) {
throw new Error("Error downloading attachment");
}
return response;
}
private async decryptAttachment(
cipher: CipherView,
attachment: AttachmentView,
response: Response,
) {
try {
const encBuf = await EncArrayBuffer.fromResponse(response);
const key =
attachment.key != null
? attachment.key
: await this.keyService.getOrgKey(cipher.organizationId);
return await this.encryptService.decryptToBytes(encBuf, key);
} catch {
throw new Error("Error decrypting attachment");
}
}
private async getDecryptedExport(format: "json" | "csv"): Promise<ExportedVaultAsString> {
let decFolders: FolderView[] = [];
let decCiphers: CipherView[] = [];
const promises = [];
@@ -79,13 +182,21 @@ export class IndividualVaultExportService
await Promise.all(promises);
if (format === "csv") {
return this.buildCsvExport(decFolders, decCiphers);
return {
type: "text/plain",
data: this.buildCsvExport(decFolders, decCiphers),
fileName: ExportHelper.getFileName("csv"),
} as ExportedVaultAsString;
}
return this.buildJsonExport(decFolders, decCiphers);
return {
type: "text/plain",
data: this.buildJsonExport(decFolders, decCiphers),
fileName: ExportHelper.getFileName("json"),
} as ExportedVaultAsString;
}
private async getEncryptedExport(): Promise<string> {
private async getEncryptedExport(): Promise<ExportedVaultAsString> {
let folders: Folder[] = [];
let ciphers: Cipher[] = [];
const promises = [];
@@ -136,7 +247,11 @@ export class IndividualVaultExportService
jsonDoc.items.push(cipher);
});
return JSON.stringify(jsonDoc, null, " ");
return {
type: "text/plain",
data: JSON.stringify(jsonDoc, null, " "),
fileName: ExportHelper.getFileName("json"),
} as ExportedVaultAsString;
}
private buildCsvExport(decFolders: FolderView[], decCiphers: CipherView[]): string {

View File

@@ -1,16 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ExportedVaultAsString } from "../types";
import { ExportFormat } from "./vault-export.service.abstraction";
export abstract class OrganizationVaultExportServiceAbstraction {
getPasswordProtectedExport: (
abstract getPasswordProtectedExport: (
organizationId: string,
password: string,
onlyManagedCollections: boolean,
) => Promise<string>;
getOrganizationExport: (
) => Promise<ExportedVaultAsString>;
abstract getOrganizationExport: (
organizationId: string,
format: ExportFormat,
onlyManagedCollections: boolean,
) => Promise<string>;
) => Promise<ExportedVaultAsString>;
}

View File

@@ -30,9 +30,11 @@ import {
BitwardenCsvOrgExportType,
BitwardenEncryptedOrgJsonExport,
BitwardenUnEncryptedOrgJsonExport,
ExportedVaultAsString,
} from "../types";
import { BaseVaultExportService } from "./base-vault-export.service";
import { ExportHelper } from "./export-helper";
import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction";
import { ExportFormat } from "./vault-export.service.abstraction";
@@ -54,38 +56,70 @@ export class OrganizationVaultExportService
super(pinService, encryptService, cryptoFunctionService, kdfConfigService);
}
/** Creates a password protected export of an organizational vault.
* @param organizationId The organization id
* @param password The password to protect the export
* @param onlyManagedCollections If true only managed collections will be exported
* @returns The exported vault
*/
async getPasswordProtectedExport(
organizationId: string,
password: string,
onlyManagedCollections: boolean,
): Promise<string> {
const clearText = await this.getOrganizationExport(
): Promise<ExportedVaultAsString> {
const exportVault = await this.getOrganizationExport(
organizationId,
"json",
onlyManagedCollections,
);
return this.buildPasswordExport(clearText, password);
return {
type: "text/plain",
data: await this.buildPasswordExport(exportVault.data, password),
fileName: ExportHelper.getFileName("org", "encrypted_json"),
} as ExportedVaultAsString;
}
/** Creates an export of an organizational vault. Based on the provided format it will either be unencrypted, encrypted
* @param organizationId The organization id
* @param format The format of the export
* @param onlyManagedCollections If true only managed collections will be exported
* @returns The exported vault
* @throws Error if the format is zip
* @throws Error if the organization id is not set
* @throws Error if the format is not supported
* @throws Error if the organization policies prevent the export
*/
async getOrganizationExport(
organizationId: string,
format: ExportFormat = "csv",
onlyManagedCollections: boolean,
): Promise<string> {
): Promise<ExportedVaultAsString> {
if (Utils.isNullOrWhitespace(organizationId)) {
throw new Error("OrganizationId must be set");
}
if (format === "encrypted_json") {
return onlyManagedCollections
? this.getEncryptedManagedExport(organizationId)
: this.getOrganizationEncryptedExport(organizationId);
if (format === "zip") {
throw new Error("Zip export not supported for organization");
}
return onlyManagedCollections
? this.getDecryptedManagedExport(organizationId, format)
: this.getOrganizationDecryptedExport(organizationId, format);
if (format === "encrypted_json") {
return {
type: "text/plain",
data: onlyManagedCollections
? await this.getEncryptedManagedExport(organizationId)
: await this.getOrganizationEncryptedExport(organizationId),
fileName: ExportHelper.getFileName("org", "json"),
} as ExportedVaultAsString;
}
return {
type: "text/plain",
data: onlyManagedCollections
? await this.getDecryptedManagedExport(organizationId, format)
: await this.getOrganizationDecryptedExport(organizationId, format),
fileName: ExportHelper.getFileName("org", format),
} as ExportedVaultAsString;
}
private async getOrganizationDecryptedExport(

View File

@@ -1,15 +1,14 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export const EXPORT_FORMATS = ["csv", "json", "encrypted_json"] as const;
import { ExportedVault } from "../types";
export const EXPORT_FORMATS = ["csv", "json", "encrypted_json", "zip"] as const;
export type ExportFormat = (typeof EXPORT_FORMATS)[number];
export abstract class VaultExportServiceAbstraction {
getExport: (format: ExportFormat, password: string) => Promise<string>;
getOrganizationExport: (
abstract getExport: (format: ExportFormat, password: string) => Promise<ExportedVault>;
abstract getOrganizationExport: (
organizationId: string,
format: ExportFormat,
password: string,
onlyManagedCollections?: boolean,
) => Promise<string>;
getFileName: (prefix?: string, extension?: string) => string;
) => Promise<ExportedVault>;
}

View File

@@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export";
@@ -27,6 +28,7 @@ import {
} from "@bitwarden/key-management";
import { BuildTestObject, GetUniqueString } from "../../../../../../common/spec";
import { ExportedVault, ExportedVaultAsString } from "../types";
import { IndividualVaultExportService } from "./individual-vault-export.service";
@@ -156,6 +158,7 @@ describe("VaultExportService", () => {
let encryptService: MockProxy<EncryptService>;
let accountService: MockProxy<AccountService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let apiService: MockProxy<ApiService>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
@@ -165,6 +168,7 @@ describe("VaultExportService", () => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
accountService = mock<AccountService>();
apiService = mock<ApiService>();
kdfConfigService = mock<KdfConfigService>();
@@ -191,6 +195,7 @@ describe("VaultExportService", () => {
cryptoFunctionService,
kdfConfigService,
accountService,
apiService,
);
});
@@ -198,35 +203,40 @@ describe("VaultExportService", () => {
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1));
const actual = await exportService.getExport("json");
expectEqualCiphers(UserCipherViews.slice(0, 1), actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherViews.slice(0, 1), exportedData.data);
});
it("exports encrypted json user ciphers", async () => {
cipherService.getAll.mockResolvedValue(UserCipherDomains.slice(0, 1));
const actual = await exportService.getExport("encrypted_json");
expectEqualCiphers(UserCipherDomains.slice(0, 1), actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherDomains.slice(0, 1), exportedData.data);
});
it("does not unencrypted export trashed user items", async () => {
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews);
const actual = await exportService.getExport("json");
expectEqualCiphers(UserCipherViews.slice(0, 2), actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherViews.slice(0, 2), exportedData.data);
});
it("does not encrypted export trashed user items", async () => {
cipherService.getAll.mockResolvedValue(UserCipherDomains);
const actual = await exportService.getExport("encrypted_json");
expectEqualCiphers(UserCipherDomains.slice(0, 2), actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualCiphers(UserCipherDomains.slice(0, 2), exportedData.data);
});
describe("password protected export", () => {
let exportedVault: ExportedVault;
let exportString: string;
let exportObject: any;
let mac: MockProxy<EncString>;
@@ -245,7 +255,9 @@ describe("VaultExportService", () => {
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(salt);
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1));
exportString = await exportService.getPasswordProtectedExport(password);
exportedVault = await exportService.getPasswordProtectedExport(password);
expect(typeof exportedVault.data).toBe("string");
exportString = (exportedVault as ExportedVaultAsString).data;
exportObject = JSON.parse(exportString);
});
@@ -271,23 +283,32 @@ describe("VaultExportService", () => {
it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
exportString = await exportService.getPasswordProtectedExport(password);
exportObject = JSON.parse(exportString);
exportedVault = await exportService.getPasswordProtectedExport(password);
expect(typeof exportedVault.data).toBe("string");
exportString = (exportedVault as ExportedVaultAsString).data;
exportObject = JSON.parse(exportString);
expect(exportObject.encKeyValidation_DO_NOT_EDIT).toEqual(mac.encryptedString);
});
it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
exportString = await exportService.getPasswordProtectedExport(password);
exportObject = JSON.parse(exportString);
exportedVault = await exportService.getPasswordProtectedExport(password);
expect(typeof exportedVault.data).toBe("string");
exportString = (exportedVault as ExportedVaultAsString).data;
exportObject = JSON.parse(exportString);
expect(exportObject.data).toEqual(data.encryptedString);
});
it("encrypts the data property", async () => {
const unencrypted = await exportService.getExport();
expect(exportObject.data).not.toEqual(unencrypted);
const unEncryptedExportVault = await exportService.getExport();
expect(typeof unEncryptedExportVault.data).toBe("string");
const unEncryptedExportString = (unEncryptedExportVault as ExportedVaultAsString).data;
expect(exportObject.data).not.toEqual(unEncryptedExportString);
});
});
});
@@ -297,7 +318,9 @@ describe("VaultExportService", () => {
const actual = await exportService.getExport("json");
expectEqualFolderViews(UserFolderViews, actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualFolderViews(UserFolderViews, exportedData.data);
});
it("exported encrypted json contains folders", async () => {
@@ -305,7 +328,9 @@ describe("VaultExportService", () => {
const actual = await exportService.getExport("encrypted_json");
expectEqualFolders(UserFolders, actual);
expect(typeof actual.data).toBe("string");
const exportedData = actual as ExportedVaultAsString;
expectEqualFolders(UserFolders, exportedData.data);
});
});

View File

@@ -1,8 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ExportHelper } from "./export-helper";
import { ExportedVault } from "../types";
import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction";
import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction";
import { ExportFormat, VaultExportServiceAbstraction } from "./vault-export.service.abstraction";
@@ -13,7 +12,13 @@ export class VaultExportService implements VaultExportServiceAbstraction {
private organizationVaultExportService: OrganizationVaultExportServiceAbstraction,
) {}
async getExport(format: ExportFormat = "csv", password: string): Promise<string> {
/** Creates an export of an individual vault (My vault). Based on the provided format it will either be unencrypted, encrypted or password protected
* @param format The format of the export
* @param password An optional password if the export should be password-protected
* @returns The exported vault
* @throws Error if the format is csv and a password is provided
*/
async getExport(format: ExportFormat = "csv", password: string = ""): Promise<ExportedVault> {
if (!Utils.isNullOrWhitespace(password)) {
if (format == "csv") {
throw new Error("CSV does not support password protected export");
@@ -24,12 +29,24 @@ export class VaultExportService implements VaultExportServiceAbstraction {
return this.individualVaultExportService.getExport(format);
}
/** Creates an export of an organizational vault. Based on the provided format it will either be unencrypted, encrypted or password protected
* @param organizationId The organization id
* @param format The format of the export
* @param password The password to protect the export
* @param onlyManagedCollections If true only managed collections will be exported
* @returns The exported vault
* @throws Error if the format is csv and a password is provided
* @throws Error if the format is zip and the environment does not support exporting attachments
* @throws Error if the format is not supported
* @throws Error if the organization id is not a valid guid
* @throws Error if the organization policies prevent the export
*/
async getOrganizationExport(
organizationId: string,
format: ExportFormat,
password: string,
onlyManagedCollections = false,
): Promise<string> {
): Promise<ExportedVault> {
if (!Utils.isNullOrWhitespace(password)) {
if (format == "csv") {
throw new Error("CSV does not support password protected export");
@@ -48,8 +65,4 @@ export class VaultExportService implements VaultExportServiceAbstraction {
onlyManagedCollections,
);
}
getFileName(prefix: string = null, extension = "csv"): string {
return ExportHelper.getFileName(prefix, extension);
}
}

View File

@@ -0,0 +1,13 @@
export type ExportedVaultAsBlob = {
type: "application/zip";
data: Blob;
fileName: string;
};
export type ExportedVaultAsString = {
type: "text/plain";
data: string;
fileName: string;
};
export type ExportedVault = ExportedVaultAsBlob | ExportedVaultAsString;

View File

@@ -1,2 +1,3 @@
export * from "./bitwarden-csv-export-type";
export * from "./bitwarden-json-export-types";
export * from "./exported-vault-type";

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { Component, effect, input } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -19,7 +19,7 @@ import { CalloutModule } from "@bitwarden/components";
standalone: true,
imports: [CommonModule, JslibModule, CalloutModule],
})
export class ExportScopeCalloutComponent implements OnInit {
export class ExportScopeCalloutComponent {
show = false;
scopeConfig: {
title: string;
@@ -27,35 +27,23 @@ export class ExportScopeCalloutComponent implements OnInit {
scopeIdentifier: string;
};
private _organizationId: string;
get organizationId(): string {
return this._organizationId;
}
@Input() set organizationId(value: string) {
this._organizationId = value;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.getScopeMessage(this._organizationId);
}
/* Optional OrganizationId, if not provided, it will display individual vault export message */
readonly organizationId = input<string>();
/* Optional export format, determines which individual export description to display */
readonly exportFormat = input<string>();
constructor(
protected organizationService: OrganizationService,
protected accountService: AccountService,
) {}
async ngOnInit(): Promise<void> {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
if (!(await firstValueFrom(this.organizationService.hasOrganizations(userId)))) {
return;
}
await this.getScopeMessage(this.organizationId);
) {
effect(async () => {
this.show = false;
await this.getScopeMessage(this.organizationId(), this.exportFormat());
this.show = true;
});
}
private async getScopeMessage(organizationId: string) {
private async getScopeMessage(organizationId: string, exportFormat: string): Promise<void> {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.scopeConfig =
organizationId != null
@@ -72,7 +60,10 @@ export class ExportScopeCalloutComponent implements OnInit {
}
: {
title: "exportingPersonalVaultTitle",
description: "exportingIndividualVaultDescription",
description:
exportFormat == "zip"
? "exportingIndividualVaultWithAttachmentsDescription"
: "exportingIndividualVaultDescription",
scopeIdentifier: await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
),

View File

@@ -5,7 +5,10 @@
>
{{ "personalVaultExportPolicyInEffect" | i18n }}
</bit-callout>
<tools-export-scope-callout [organizationId]="organizationId"></tools-export-scope-callout>
<tools-export-scope-callout
[organizationId]="organizationId"
[exportFormat]="format"
></tools-export-scope-callout>
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">
<ng-container *ngIf="organizations$ | async as organizations">

View File

@@ -40,6 +40,8 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -59,7 +61,7 @@ import {
} from "@bitwarden/components";
import { GeneratorServicesModule } from "@bitwarden/generator-components";
import { CredentialGeneratorService, GenerateRequest, Generators } from "@bitwarden/generator-core";
import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core";
import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core";
import { EncryptedExportType } from "../enums/encrypted-export-type.enum";
@@ -183,6 +185,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
private onlyManagedCollections = true;
private onGenerate$ = new Subject<GenerateRequest>();
private isExportAttachmentsEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.ExportAttachments,
);
constructor(
protected i18nService: I18nService,
protected toastService: ToastService,
@@ -197,6 +203,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
protected organizationService: OrganizationService,
private accountService: AccountService,
private collectionService: CollectionService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -305,10 +312,20 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
)
.subscribe();
this.exportForm.controls.vaultSelector.valueChanges
combineLatest([
this.exportForm.controls.vaultSelector.valueChanges,
this.isExportAttachmentsEnabled$,
])
.pipe(takeUntil(this.destroy$))
.subscribe((value) => {
this.organizationId = value != "myVault" ? value : undefined;
.subscribe(([value, isExportAttachmentsEnabled]) => {
this.organizationId = value !== "myVault" ? value : undefined;
if (value === "myVault" && isExportAttachmentsEnabled) {
if (!this.formatOptions.some((option) => option.value === "zip")) {
this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" });
}
} else {
this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip");
}
});
}
@@ -344,7 +361,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
protected async doExport() {
try {
const data = await this.getExportData();
// Download the export file
this.downloadFile(data);
this.toastService.showToast({
variant: "success",
title: null,
@@ -429,7 +449,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
return true;
}
protected async getExportData(): Promise<string> {
protected async getExportData(): Promise<ExportedVault> {
return Utils.isNullOrWhitespace(this.organizationId)
? this.exportService.getExport(this.format, this.filePassword)
: this.exportService.getOrganizationExport(
@@ -440,23 +460,6 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
);
}
protected getFileName(prefix?: string) {
if (this.organizationId) {
prefix = "org";
}
let extension = this.format;
if (this.format === "encrypted_json") {
if (prefix == null) {
prefix = "encrypted";
} else {
prefix = "encrypted_" + prefix;
}
extension = "json";
}
return this.exportService.getFileName(prefix, extension);
}
protected async collectEvent(): Promise<void> {
if (this.organizationId) {
return await this.eventCollectionService.collect(
@@ -498,12 +501,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
}
}
private downloadFile(csv: string): void {
const fileName = this.getFileName();
private downloadFile(exportedVault: ExportedVault): void {
this.fileDownloadService.download({
fileName: fileName,
blobData: csv,
blobOptions: { type: "text/plain" },
fileName: exportedVault.fileName,
blobData: exportedVault.data,
blobOptions: { type: exportedVault.type },
});
}
}