1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +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

@@ -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);
this.show = true;
) {
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 },
});
}
}