diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 39bc6ed9b86..289f294656b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4903,5 +4903,8 @@ }, "extraWide": { "message": "Extra wide" + }, + "files": { + "message": "Files" } } diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index 2c16886a2d4..105d263f26a 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -149,10 +149,21 @@ export class ProductSwitcherService { external: true, }, isActive: + !this.router.url.includes("/files") && !this.router.url.includes("/sm/") && !this.router.url.includes("/organizations/") && !this.router.url.includes("/providers/"), }, + files: { + name: "File Manager", + icon: "bwi-file", + appRoute: "/files", + marketingRoute: { + route: "https://bitwarden.com/products/personal/", + external: true, + }, + isActive: this.router.url.includes("/files"), + }, sm: { name: "Secrets Manager", icon: "bwi-cli", @@ -196,7 +207,7 @@ export class ProductSwitcherService { }, } satisfies Record; - const bento: ProductSwitcherItem[] = [products.pm]; + const bento: ProductSwitcherItem[] = [products.pm, products.files]; const other: ProductSwitcherItem[] = []; if (smOrg) { diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 8aea628ddde..e80ee88a835 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -87,6 +87,8 @@ import { ReportsModule } from "./tools/reports"; import { AccessComponent } from "./tools/send/access.component"; import { SendAccessExplainerComponent } from "./tools/send/send-access-explainer.component"; import { SendComponent } from "./tools/send/send.component"; +import { FilesLayoutComponent } from "./vault/files/files-layout.component"; +import { FilesComponent } from "./vault/files/files.component"; import { VaultModule } from "./vault/individual-vault/vault.module"; const routes: Routes = [ @@ -663,6 +665,17 @@ const routes: Routes = [ }, ], }, + { + path: "files", + component: FilesLayoutComponent, + children: [ + { + path: "", + component: FilesComponent, + data: { titleId: "files" } satisfies RouteDataProperties, + }, + ], + }, { path: "", component: UserLayoutComponent, diff --git a/apps/web/src/app/vault/files/file-preview-dialog.component.html b/apps/web/src/app/vault/files/file-preview-dialog.component.html new file mode 100644 index 00000000000..345ace33474 --- /dev/null +++ b/apps/web/src/app/vault/files/file-preview-dialog.component.html @@ -0,0 +1,21 @@ + + + + + + + + + +
+                {{ blob.text() | async }}
+            
+
+
+ + + + + +
diff --git a/apps/web/src/app/vault/files/file-preview-dialog.component.ts b/apps/web/src/app/vault/files/file-preview-dialog.component.ts new file mode 100644 index 00000000000..ecf090b3223 --- /dev/null +++ b/apps/web/src/app/vault/files/file-preview-dialog.component.ts @@ -0,0 +1,62 @@ +import { DIALOG_DATA } from "@angular/cdk/dialog"; +import { Component, OnInit, inject } from "@angular/core"; + +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + DialogService, +} from "@bitwarden/components"; + +import { SharedModule } from "../../shared"; + +import { FileService } from "./file.service"; +import { FileView } from "./files.component"; + +export type FilePreviewDialogData = { + file: FileView; +}; + +@Component({ + standalone: true, + templateUrl: "file-preview-dialog.component.html", + imports: [SharedModule, DialogModule, ButtonModule, AsyncActionsModule], +}) +export class FilePreviewDialogComponent implements OnInit { + protected data = inject(DIALOG_DATA) as FilePreviewDialogData; + + private fileService = inject(FileService); + + protected srcUrl?: string; + protected blob?: Blob; + + protected get fileType(): string { + return this.fileService.getFileType(this.data.file); + } + + protected get icon(): `bwi-${string}` { + switch (this.fileType) { + case "image": + return "bwi-camera"; + case "pdf": + return "bwi-pdf"; + case "text": + return "bwi-file-text"; + default: + return "bwi-file"; + } + } + + async ngOnInit() { + this.blob = await this.fileService.toBlob(this.data.file); + this.srcUrl = URL.createObjectURL(this.blob); + } + + static open(dialogService: DialogService, file: FileView) { + dialogService.open(FilePreviewDialogComponent, { + data: { + file, + }, + }); + } +} diff --git a/apps/web/src/app/vault/files/file.service.ts b/apps/web/src/app/vault/files/file.service.ts new file mode 100644 index 00000000000..04f9ced9aa1 --- /dev/null +++ b/apps/web/src/app/vault/files/file.service.ts @@ -0,0 +1,94 @@ +import { Injectable, inject } from "@angular/core"; +import { Observable, from, map, shareReplay } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { KeyService } from "@bitwarden/key-management"; + +export type FileView = { + cipher: CipherView; + attachment: AttachmentView; +}; + +@Injectable({ providedIn: "root" }) +export class FileService { + private apiService = inject(ApiService); + private cipherService = inject(CipherService); + private encryptService = inject(EncryptService); + private keyService = inject(KeyService); + + files$: Observable = this.cipherService.cipherViews$.pipe( + map((ciphers) => + Object.values(ciphers) + .filter((cipher) => Array.isArray(cipher.attachments) && cipher.attachments.length > 0) + .map((cipher) => cipher.attachments.map((attachment) => ({ cipher, attachment }))) + .flat(), + ), + ); + + sub$: Observable = from(this.apiService.getUserSubscription()).pipe( + shareReplay({ bufferSize: 1, refCount: true }), + ); + + storagePercentage$: Observable = this.sub$.pipe( + map((sub) => + sub != null && sub.maxStorageGb ? +(100 * (sub.storageGb / sub.maxStorageGb)).toFixed(2) : 0, + ), + ); + + getFileType(file: FileView): "image" | "pdf" | "text" | "unknown" { + switch (file.attachment.fileName.split(".").at(-1)) { + case "gif": + case "jpeg": + case "jpg": + return "image"; + case "pdf": + return "pdf"; + case "txt": + return "text"; + default: + return "unknown"; + } + } + + async toBlob(file: FileView): Promise { + const { cipher, attachment } = file; + + let url: string; + try { + const attachmentDownloadResponse = await this.apiService.getAttachmentData( + 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; + } + } + + const response = await fetch(new Request(url, { cache: "no-store" })); + if (response.status !== 200) { + throw new Error("blob download error"); + } + + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = + attachment.key != null + ? attachment.key + : await this.keyService.getOrgKey(cipher.organizationId); + const decBuf = await this.encryptService.decryptToBytes(encBuf, key); + return new Blob([decBuf]); + } +} diff --git a/apps/web/src/app/vault/files/files-layout.component.html b/apps/web/src/app/vault/files/files-layout.component.html new file mode 100644 index 00000000000..efef0104b30 --- /dev/null +++ b/apps/web/src/app/vault/files/files-layout.component.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/src/app/vault/files/files-layout.component.ts b/apps/web/src/app/vault/files/files-layout.component.ts new file mode 100644 index 00000000000..5af1c02681d --- /dev/null +++ b/apps/web/src/app/vault/files/files-layout.component.ts @@ -0,0 +1,13 @@ +import { Component } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { HeaderModule } from "../../layouts/header/header.module"; +import { WebLayoutModule } from "../../layouts/web-layout.module"; +import { SharedModule } from "../../shared"; + +@Component({ + standalone: true, + templateUrl: "files-layout.component.html", + imports: [SharedModule, HeaderModule, WebLayoutModule, RouterModule], +}) +export class FilesLayoutComponent {} diff --git a/apps/web/src/app/vault/files/files.component.html b/apps/web/src/app/vault/files/files.component.html new file mode 100644 index 00000000000..61a3dd273e8 --- /dev/null +++ b/apps/web/src/app/vault/files/files.component.html @@ -0,0 +1,41 @@ + + + + + + + + + + + Name + Size + + + + + + + + + {{ row.attachment.sizeName }} + + + +
+ {{ + sub.storageName + " / " + sub.maxStorageGb + " GB" + }} + +
+
diff --git a/apps/web/src/app/vault/files/files.component.ts b/apps/web/src/app/vault/files/files.component.ts new file mode 100644 index 00000000000..da600564c21 --- /dev/null +++ b/apps/web/src/app/vault/files/files.component.ts @@ -0,0 +1,47 @@ +import { Component, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + DialogService, + NoItemsModule, + SearchModule, + TableDataSource, + TableModule, +} from "@bitwarden/components"; + +import { HeaderModule } from "../../layouts/header/header.module"; +import { SharedModule } from "../../shared"; + +import { FilePreviewDialogComponent } from "./file-preview-dialog.component"; +import { FileService } from "./file.service"; + +export type FileView = { + cipher: CipherView; + attachment: AttachmentView; +}; + +@Component({ + standalone: true, + templateUrl: "files.component.html", + imports: [SharedModule, HeaderModule, TableModule, SearchModule, NoItemsModule], +}) +export class FilesComponent implements OnInit { + protected fileService = inject(FileService); + private dialogService = inject(DialogService); + + protected tableDataSource = new TableDataSource(); + + constructor() { + this.fileService.files$.pipe(takeUntilDestroyed()).subscribe((files) => { + this.tableDataSource.data = files; + }); + } + + async ngOnInit() {} + + protected openFilePreview(file: FileView) { + FilePreviewDialogComponent.open(this.dialogService, file); + } +} diff --git a/apps/web/webpack.config.js b/apps/web/webpack.config.js index df325015aad..e5cf618994f 100644 --- a/apps/web/webpack.config.js +++ b/apps/web/webpack.config.js @@ -265,6 +265,8 @@ const devServer = https://www.paypalobjects.com https://q.stripe.com https://haveibeenpwned.com + https://localhost:8080 + blob: ;child-src 'self' https://js.stripe.com diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index 521d38a1f47..b09f4c954a1 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -158,6 +158,7 @@ export class AttachmentsComponent implements OnInit { this.emergencyAccessId, ); url = attachmentDownloadResponse.url; + alert(url); } catch (e) { if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) { url = attachment.url; @@ -239,6 +240,7 @@ export class AttachmentsComponent implements OnInit { this.reuploadPromises[attachment.id] = Promise.resolve().then(async () => { // 1. Download a.downloading = true; + alert(attachment.url); const response = await fetch(new Request(attachment.url, { cache: "no-store" })); if (response.status !== 200) { this.platformUtilsService.showToast("error", null, this.i18nService.t("errorOccurred"));