mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
[PM-12049] Remove usage of ActiveUserState from folder service (#11880)
* Migrated folder service from using active user state to single user state Added extra test cases for encrypted folder and decrypted folders Updated derived state to use decrypt with key * Update callers in the web * Update callers in the browser * Update callers in libs * Update callers in cli * Fixed test * Fixed folder state test * Fixed test * removed duplicate activeUserId * Added takewhile operator to only make calls when userId is present * Simplified to accept a single user id instead of an observable * Required userid to be passed from notification service * [PM-15635] Folders not working on desktop (#12333) * Added folders memory state definition * added decrypted folders state * Refactored service to remove derived state * removed combinedstate and added clear decrypted folders to methods * Fixed test * Fixed issue with editing folder on the desktop app * Fixed test * Changed state name * fixed ts strict issue * fixed ts strict issue * fixed ts strict issue * removed unnecessasry null encrypteed folder check * Handle null folderdata * [PM-16197] "Items with No Folder" shows as a folder to edit name and delete (#12470) * Force redcryption anytime encryption state changes * Fixed text file * revert changes * create new object with nofolder instead of modifying exisiting object * Fixed failing test * switched to use memory-large-object * Fixed ts sctrict issue --------- Co-authored-by: Matt Bishop <mbishop@bitwarden.com> Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com>
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, firstValueFrom, map, shareReplay } from "rxjs";
|
||||
import { Observable, Subject, firstValueFrom, map, shareReplay, switchMap, merge } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { KeyService } from "../../../../../key-management/src/abstractions/key.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
@@ -21,11 +21,18 @@ import { FolderWithIdRequest } from "../../models/request/folder-with-id.request
|
||||
import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state";
|
||||
|
||||
export class FolderService implements InternalFolderServiceAbstraction {
|
||||
folders$: Observable<Folder[]>;
|
||||
folderViews$: Observable<FolderView[]>;
|
||||
/**
|
||||
* Ensures we reuse the same observable stream for each userId rather than
|
||||
* creating a new one on each folderViews$ call.
|
||||
*/
|
||||
private folderViewCache = new Map<UserId, Observable<FolderView[]>>();
|
||||
|
||||
private encryptedFoldersState: ActiveUserState<Record<string, FolderData>>;
|
||||
private decryptedFoldersState: DerivedState<FolderView[]>;
|
||||
/**
|
||||
* Used to force the folderviews$ Observable to re-emit with a provided value.
|
||||
* Required because shareReplay with refCount: false maintains last emission.
|
||||
* Used during cleanup to force emit empty arrays, ensuring stale data isn't retained.
|
||||
*/
|
||||
private forceFolderViews: Record<UserId, Subject<FolderView[]>> = {};
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
@@ -33,23 +40,44 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
private i18nService: I18nService,
|
||||
private cipherService: CipherService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.encryptedFoldersState = this.stateProvider.getActive(FOLDER_ENCRYPTED_FOLDERS);
|
||||
this.decryptedFoldersState = this.stateProvider.getDerived(
|
||||
this.encryptedFoldersState.state$,
|
||||
FOLDER_DECRYPTED_FOLDERS,
|
||||
{ folderService: this, keyService: this.keyService },
|
||||
);
|
||||
) {}
|
||||
|
||||
this.folders$ = this.encryptedFoldersState.state$.pipe(
|
||||
map((folderData) => Object.values(folderData).map((f) => new Folder(f))),
|
||||
);
|
||||
folders$(userId: UserId): Observable<Folder[]> {
|
||||
return this.encryptedFoldersState(userId).state$.pipe(
|
||||
map((folders) => {
|
||||
if (folders == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
this.folderViews$ = this.decryptedFoldersState.state$;
|
||||
return Object.values(folders).map((f) => new Folder(f));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async clearCache(): Promise<void> {
|
||||
await this.decryptedFoldersState.forceValue([]);
|
||||
/**
|
||||
* Returns an Observable of decrypted folder views for the given userId.
|
||||
* Uses folderViewCache to maintain a single Observable instance per user,
|
||||
* combining normal folder state updates with forced updates.
|
||||
*/
|
||||
folderViews$(userId: UserId): Observable<FolderView[]> {
|
||||
if (!this.folderViewCache.has(userId)) {
|
||||
if (!this.forceFolderViews[userId]) {
|
||||
this.forceFolderViews[userId] = new Subject<FolderView[]>();
|
||||
}
|
||||
|
||||
const observable = merge(
|
||||
this.forceFolderViews[userId],
|
||||
this.encryptedFoldersState(userId).state$.pipe(
|
||||
switchMap((folderData) => {
|
||||
return this.decryptFolders(userId, folderData);
|
||||
}),
|
||||
),
|
||||
).pipe(shareReplay({ refCount: false, bufferSize: 1 }));
|
||||
|
||||
this.folderViewCache.set(userId, observable);
|
||||
}
|
||||
|
||||
return this.folderViewCache.get(userId);
|
||||
}
|
||||
|
||||
// TODO: This should be moved to EncryptService or something
|
||||
@@ -60,29 +88,29 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
return folder;
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Folder> {
|
||||
const folders = await firstValueFrom(this.folders$);
|
||||
async get(id: string, userId: UserId): Promise<Folder> {
|
||||
const folders = await firstValueFrom(this.folders$(userId));
|
||||
|
||||
return folders.find((folder) => folder.id === id);
|
||||
}
|
||||
|
||||
getDecrypted$(id: string): Observable<FolderView | undefined> {
|
||||
return this.folderViews$.pipe(
|
||||
getDecrypted$(id: string, userId: UserId): Observable<FolderView | undefined> {
|
||||
return this.folderViews$(userId).pipe(
|
||||
map((folders) => folders.find((folder) => folder.id === id)),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
async getAllFromState(): Promise<Folder[]> {
|
||||
return await firstValueFrom(this.folders$);
|
||||
async getAllFromState(userId: UserId): Promise<Folder[]> {
|
||||
return await firstValueFrom(this.folders$(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated For the CLI only
|
||||
* @param id id of the folder
|
||||
*/
|
||||
async getFromState(id: string): Promise<Folder> {
|
||||
const folder = await this.get(id);
|
||||
async getFromState(id: string, userId: UserId): Promise<Folder> {
|
||||
const folder = await this.get(id, userId);
|
||||
if (!folder) {
|
||||
return null;
|
||||
}
|
||||
@@ -93,12 +121,13 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
/**
|
||||
* @deprecated Only use in CLI!
|
||||
*/
|
||||
async getAllDecryptedFromState(): Promise<FolderView[]> {
|
||||
return await firstValueFrom(this.folderViews$);
|
||||
async getAllDecryptedFromState(userId: UserId): Promise<FolderView[]> {
|
||||
return await firstValueFrom(this.folderViews$(userId));
|
||||
}
|
||||
|
||||
async upsert(folderData: FolderData | FolderData[]): Promise<void> {
|
||||
await this.encryptedFoldersState.update((folders) => {
|
||||
async upsert(folderData: FolderData | FolderData[], userId: UserId): Promise<void> {
|
||||
await this.clearDecryptedFolderState(userId);
|
||||
await this.encryptedFoldersState(userId).update((folders) => {
|
||||
if (folders == null) {
|
||||
folders = {};
|
||||
}
|
||||
@@ -120,24 +149,31 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
if (!folders) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.clearDecryptedFolderState(userId);
|
||||
await this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS).update(() => {
|
||||
const newFolders: Record<string, FolderData> = { ...folders };
|
||||
return newFolders;
|
||||
});
|
||||
}
|
||||
|
||||
async clear(userId?: UserId): Promise<void> {
|
||||
async clearDecryptedFolderState(userId: UserId): Promise<void> {
|
||||
if (userId == null) {
|
||||
await this.encryptedFoldersState.update(() => ({}));
|
||||
await this.decryptedFoldersState.forceValue([]);
|
||||
} else {
|
||||
await this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS).update(() => ({}));
|
||||
throw new Error("User ID is required.");
|
||||
}
|
||||
|
||||
await this.setDecryptedFolders([], userId);
|
||||
}
|
||||
|
||||
async delete(id: string | string[]): Promise<any> {
|
||||
await this.encryptedFoldersState.update((folders) => {
|
||||
async clear(userId: UserId): Promise<void> {
|
||||
this.forceFolderViews[userId]?.next([]);
|
||||
|
||||
await this.encryptedFoldersState(userId).update(() => ({}));
|
||||
await this.clearDecryptedFolderState(userId);
|
||||
}
|
||||
|
||||
async delete(id: string | string[], userId: UserId): Promise<any> {
|
||||
await this.clearDecryptedFolderState(userId);
|
||||
await this.encryptedFoldersState(userId).update((folders) => {
|
||||
if (folders == null) {
|
||||
return;
|
||||
}
|
||||
@@ -164,25 +200,11 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
}
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
// 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.cipherService.upsert(updates.map((c) => c.toCipherData()));
|
||||
await this.cipherService.upsert(updates.map((c) => c.toCipherData()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async decryptFolders(folders: Folder[]) {
|
||||
const decryptFolderPromises = folders.map((f) => f.decrypt());
|
||||
const decryptedFolders = await Promise.all(decryptFolderPromises);
|
||||
|
||||
decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
|
||||
const noneFolder = new FolderView();
|
||||
noneFolder.name = this.i18nService.t("noneFolder");
|
||||
decryptedFolders.push(noneFolder);
|
||||
return decryptedFolders;
|
||||
}
|
||||
|
||||
async getRotatedData(
|
||||
originalUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
@@ -193,7 +215,7 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
}
|
||||
|
||||
let encryptedFolders: FolderWithIdRequest[] = [];
|
||||
const folders = await firstValueFrom(this.folderViews$);
|
||||
const folders = await firstValueFrom(this.folderViews$(userId));
|
||||
if (!folders) {
|
||||
return encryptedFolders;
|
||||
}
|
||||
@@ -205,4 +227,63 @@ export class FolderService implements InternalFolderServiceAbstraction {
|
||||
);
|
||||
return encryptedFolders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the folders for a user.
|
||||
* @param userId the user id
|
||||
* @param folderData encrypted folders
|
||||
* @returns a list of decrypted folders
|
||||
*/
|
||||
private async decryptFolders(
|
||||
userId: UserId,
|
||||
folderData: Record<string, FolderData>,
|
||||
): Promise<FolderView[]> {
|
||||
// Check if the decrypted folders are already cached
|
||||
const decrypted = await firstValueFrom(
|
||||
this.stateProvider.getUser(userId, FOLDER_DECRYPTED_FOLDERS).state$,
|
||||
);
|
||||
if (decrypted?.length) {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
if (folderData == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const folders = Object.values(folderData).map((f) => new Folder(f));
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (!userKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const decryptFolderPromises = folders.map((f) =>
|
||||
f.decryptWithKey(userKey, this.encryptService),
|
||||
);
|
||||
const decryptedFolders = await Promise.all(decryptFolderPromises);
|
||||
decryptedFolders.sort(Utils.getSortFunction(this.i18nService, "name"));
|
||||
|
||||
const noneFolder = new FolderView();
|
||||
noneFolder.name = this.i18nService.t("noneFolder");
|
||||
decryptedFolders.push(noneFolder);
|
||||
|
||||
// Cache the decrypted folders
|
||||
await this.setDecryptedFolders(decryptedFolders, userId);
|
||||
return decryptedFolders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a SingleUserState for the encrypted folders.
|
||||
*/
|
||||
private encryptedFoldersState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, FOLDER_ENCRYPTED_FOLDERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the decrypted folders state for a user.
|
||||
* @param folders the decrypted folders
|
||||
* @param userId the user id
|
||||
*/
|
||||
private async setDecryptedFolders(folders: FolderView[], userId: UserId): Promise<void> {
|
||||
await this.stateProvider.setUserState(FOLDER_DECRYPTED_FOLDERS, folders, userId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user