From d1d12ce61f443d4172ef3a167e364d3b78ec0ab0 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 25 Sep 2023 14:53:27 -0400 Subject: [PATCH] Rebase: Bad Commit --- .../src/services/jslib-services.module.ts | 11 ++ .../active-user-state-provider.service.ts | 3 + .../global-state-provider.service.ts | 3 + libs/common/src/platform/interfaces/state.ts | 6 + ...ault-active-user-state-provider.service.ts | 125 ++++++++++++++++++ .../default-global-state-provider.service.ts | 81 ++++++++++++ .../services/state-provider.service.spec.ts | 90 +++++++++++++ .../src/state-migrations/owned-migrator.ts | 59 +++++++++ .../vault/services/folder/folder.service.ts | 15 ++- 9 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 libs/common/src/platform/abstractions/active-user-state-provider.service.ts create mode 100644 libs/common/src/platform/abstractions/global-state-provider.service.ts create mode 100644 libs/common/src/platform/interfaces/state.ts create mode 100644 libs/common/src/platform/services/default-active-user-state-provider.service.ts create mode 100644 libs/common/src/platform/services/default-global-state-provider.service.ts create mode 100644 libs/common/src/platform/services/state-provider.service.spec.ts create mode 100644 libs/common/src/state-migrations/owned-migrator.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 02f6278fec..413f384fad 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -93,6 +93,7 @@ import { EncryptServiceImplementation } from "@bitwarden/common/platform/service import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation"; import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service"; import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service"; +import { ActiveUserStateProviderService, BaseActiveUserStateProviderService } from "@bitwarden/common/platform/services/state-provider.service"; import { StateService } from "@bitwarden/common/platform/services/state.service"; import { ValidationService } from "@bitwarden/common/platform/services/validation.service"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; @@ -174,6 +175,7 @@ import { ModalService } from "./modal.service"; import { ThemingService } from "./theming/theming.service"; import { AbstractThemingService } from "./theming/theming.service.abstraction"; + @NgModule({ declarations: [], providers: [ @@ -717,6 +719,15 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; useClass: AuthRequestCryptoServiceImplementation, deps: [CryptoServiceAbstraction], }, + { + provide: ActiveUserStateProviderService, + useClass: BaseActiveUserStateProviderService, + deps: [ + // TODO: Do other storage services + StateServiceAbstraction, + AbstractStorageService + ] + } ], }) export class JslibServicesModule {} diff --git a/libs/common/src/platform/abstractions/active-user-state-provider.service.ts b/libs/common/src/platform/abstractions/active-user-state-provider.service.ts new file mode 100644 index 0000000000..538812c086 --- /dev/null +++ b/libs/common/src/platform/abstractions/active-user-state-provider.service.ts @@ -0,0 +1,3 @@ +export abstract class ActiveUserStateProviderService { + create: (location: StorageLocation, domainToken: DomainToken) => State; +} diff --git a/libs/common/src/platform/abstractions/global-state-provider.service.ts b/libs/common/src/platform/abstractions/global-state-provider.service.ts new file mode 100644 index 0000000000..c82fbedea1 --- /dev/null +++ b/libs/common/src/platform/abstractions/global-state-provider.service.ts @@ -0,0 +1,3 @@ +export abstract class GlobalStateProviderService { + create: (location: StorageLocation, domainToken: DomainToken) => State; +} diff --git a/libs/common/src/platform/interfaces/state.ts b/libs/common/src/platform/interfaces/state.ts new file mode 100644 index 0000000000..28aab8f444 --- /dev/null +++ b/libs/common/src/platform/interfaces/state.ts @@ -0,0 +1,6 @@ +import { Observable } from "rxjs" + +export interface State { + update: (configureState: (state: T) => void) => Promise + state$: Observable +} diff --git a/libs/common/src/platform/services/default-active-user-state-provider.service.ts b/libs/common/src/platform/services/default-active-user-state-provider.service.ts new file mode 100644 index 0000000000..c81401d2bd --- /dev/null +++ b/libs/common/src/platform/services/default-active-user-state-provider.service.ts @@ -0,0 +1,125 @@ +import { BehaviorSubject, Observable, defer, distinctUntilChanged, filter, firstValueFrom, map, share, switchMap, tap } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { ActiveUserStateProviderService } from "../abstractions/active-user-state-provider.service"; +import { StateService } from "../abstractions/state.service"; +import { AbstractMemoryStorageService, AbstractStorageService } from "../abstractions/storage.service"; +import { State } from "../interfaces/state"; + +import { DomainToken, StorageLocation } from "./default-global-state-provider.service"; + +class DefaultActiveUserState implements State { + private formattedKey$: Observable; + + // TODO: Use BitSubject + protected stateSubject: BehaviorSubject = new BehaviorSubject(null); + private stateSubject$ = this.stateSubject.asObservable(); + + state$: Observable; + + // Global: + // FolderService = + + // User (super flat) + // FolderService_{userId}_someData = + // FolderService_{userId}_moreData = + // -- or -- + // User (not as flat) + // FolderService_{userId} = + + constructor( + private stateService: StateService, + private storageLocation: AbstractStorageService, + domainToken: DomainToken) { + + const unformattedKey = `${domainToken.domainName}_{userId}`; + + // startWith? + this.formattedKey$ = this.stateService.activeAccount$ + .pipe( + distinctUntilChanged(), + filter(account => account != null), + map(accountId => unformattedKey.replace("{userId}", accountId)) + ); + + // TODO: Don't use async if possible + const activeAccountData$ = this.formattedKey$ + .pipe(switchMap(async key => { + // TODO: Force this in the storages so I don't have to `as` + const jsonData = await this.storageLocation.get(key) as Jsonify; + const data = domainToken.serializer(jsonData); + return data; + }), + tap(data => this.stateSubject.next(data)), + // Share the execution + share() + ); + + // Whomever subscribes to this data, should be notified of updated data + // if someone calls my update() method, or the active user changes. + this.state$ = defer(() => { + const subscription = activeAccountData$.subscribe(); + return this.stateSubject$ + .pipe(tap({ + complete: () => subscription.unsubscribe(), + })); + }); + } + + async update(configureState: (state: T) => void): Promise { + // wait for lock + try { + const currentState = this.stateSubject.getValue(); + configureState(currentState); + await this.storageLocation.save(await this.createKey(), currentState); + this.stateSubject.next(currentState); + } + finally { + // TODO: Free lock + } + } + + private async createKey(): Promise { + return await firstValueFrom(this.formattedKey$); + } +} + + +export class DefaultActiveUserStateProviderService implements ActiveUserStateProviderService { + private userStateCache: Record> = {}; + + constructor( + private stateService: StateService, // Inject the lightest weight service that provides accountUserId$ + private memoryStorage: AbstractMemoryStorageService, + private diskStorage: AbstractStorageService, + private secureStorage: AbstractStorageService) { + } + + create(location: StorageLocation, domainToken: DomainToken): DefaultActiveUserState { + const locationDomainKey = `${location}_${domainToken.domainName}`; + const existingActiveUserState = this.userStateCache[locationDomainKey]; + if (existingActiveUserState != null) { + // I have to cast out of the unknown generic but this should be safe if rules + // around domain token are made + return existingActiveUserState as DefaultActiveUserState; + } + + const newActiveUserState = new DefaultActiveUserState( + this.stateService, + this.getLocation(location), + domainToken); + this.userStateCache[locationDomainKey] = newActiveUserState; + return newActiveUserState; + } + + private getLocation(location: StorageLocation) { + switch (location) { + case "disk": + return this.diskStorage; + case "secure": + return this.secureStorage; + case "memory": + return this.memoryStorage; + } + } +} diff --git a/libs/common/src/platform/services/default-global-state-provider.service.ts b/libs/common/src/platform/services/default-global-state-provider.service.ts new file mode 100644 index 0000000000..1399b93222 --- /dev/null +++ b/libs/common/src/platform/services/default-global-state-provider.service.ts @@ -0,0 +1,81 @@ +import { BehaviorSubject, Observable, defer, distinctUntilChanged, filter, firstValueFrom, forkJoin, map, merge, share, switchMap, tap } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { ActiveUserStateProviderService } from "../abstractions/active-user-state-provider.service"; +import { GlobalStateProviderService } from "../abstractions/global-state-provider.service"; +import { StateService } from "../abstractions/state.service"; +import { AbstractMemoryStorageService, AbstractStorageService } from "../abstractions/storage.service"; +import { State } from "../interfaces/state"; + +// TODO: Move type +// TODO: How can we protect the creation of these so that platform can maintain the allowed creations? +export class DomainToken { + constructor( + public domainName: string, + public serializer: (jsonData: Jsonify) => T) { + + } +} + +// TODO: Move type +export type StorageLocation = "memory" | "disk" | "secure"; + +class DefaultGlobalState implements State { + protected stateSubject: BehaviorSubject = new BehaviorSubject(null); + + state$: Observable; + + constructor(private storageLocation: AbstractStorageService, private domainToken: DomainToken) { + this.state$ = this.stateSubject.asObservable(); + } + + async update(configureState: (state: T) => void): Promise { + // wait for lock + try { + const currentState = this.stateSubject.getValue(); + configureState(currentState); + await this.storageLocation.save(this.domainToken.domainName, currentState); + this.stateSubject.next(currentState); + } + finally { + // TODO: Free lock + } + } +} + +export class DefaultGlobalStateProviderService implements GlobalStateProviderService { + private globalStateCache: Record> = {}; + + constructor( + private memoryStorage: AbstractMemoryStorageService, + private diskStorage: AbstractStorageService, + private secureStorage: AbstractStorageService) { + } + + create(location: StorageLocation, domainToken: DomainToken): DefaultGlobalState { + const locationDomainKey = `${location}_${domainToken.domainName}`; + const existingGlobalState = this.globalStateCache[locationDomainKey]; + if (existingGlobalState != null) { + // I have to cast out of the unknown generic but this should be safe if rules + // around domain token are made + return existingGlobalState as DefaultGlobalState; + } + + + const newGlobalState = new DefaultGlobalState(this.getLocation(location), domainToken); + + this.globalStateCache[locationDomainKey] = newGlobalState; + return newGlobalState; + } + + private getLocation(location: StorageLocation) { + switch (location) { + case "disk": + return this.diskStorage; + case "secure": + return this.secureStorage; + case "memory": + return this.memoryStorage; + } + } +} diff --git a/libs/common/src/platform/services/state-provider.service.spec.ts b/libs/common/src/platform/services/state-provider.service.spec.ts new file mode 100644 index 0000000000..ce8ca232a6 --- /dev/null +++ b/libs/common/src/platform/services/state-provider.service.spec.ts @@ -0,0 +1,90 @@ +import { matches, mock, mockReset, notNull } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { StateService } from "../abstractions/state.service" +import { AbstractMemoryStorageService } from "../abstractions/storage.service"; + +import { DomainToken, BaseActiveUserStateProviderService } from "./default-global-state-provider.service" + + + +class TestState { + constructor() { + + } + + date: Date; + array: string[] + // TODO: More complex data types + + static fromJSON(jsonState: Jsonify) { + const state = new TestState(); + state.date = new Date(jsonState.date); + state.array = jsonState.array; + return state; + } +} + +const fakeDomainToken = new DomainToken("fake", TestState.fromJSON); + +describe("BaseStateProviderService", () => { + const stateService = mock(); + const memoryStorageService = mock(); + const diskStorageService = mock(); + + const activeAccountSubject = new BehaviorSubject(undefined); + + let stateProviderService: BaseActiveUserStateProviderService; + + beforeEach(() => { + mockReset(stateService); + mockReset(memoryStorageService); + mockReset(diskStorageService); + + stateService.activeAccount$ = activeAccountSubject; + + stateProviderService = new BaseActiveUserStateProviderService(stateService, diskStorageService); + }); + + it("createUserState", async () => { + diskStorageService.get + .mockImplementation(async (key, options) => { + if (key == "fake_1") { + return {date: "2023-09-21T13:14:17.648Z", array: ["value1", "value2"]} + } + return undefined; + }); + + const fakeDomainState = stateProviderService.create(fakeDomainToken); + + const subscribeCallback = jest.fn(); + const subscription = fakeDomainState.state$.subscribe(subscribeCallback); + + // User signs in + activeAccountSubject.next("1"); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Service does an update + fakeDomainState.update(state => state.array.push("value3")); + await new Promise(resolve => setTimeout(resolve, 1)); + + subscription.unsubscribe(); + + // No user at the start, no data + expect(subscribeCallback).toHaveBeenNthCalledWith(1, null); + + // Gotten starter user data + expect(subscribeCallback).toHaveBeenNthCalledWith(2, matches(value => { + console.log("Called", value); + return true; + })); + + // Gotten update callback data + expect(subscribeCallback).toHaveBeenNthCalledWith(3, matches((value) => { + return typeof value.date == "object" && + value.date.getFullYear() == 2023 && + value.array.length == 3 + })); + }); +}); diff --git a/libs/common/src/state-migrations/owned-migrator.ts b/libs/common/src/state-migrations/owned-migrator.ts new file mode 100644 index 0000000000..44e02001a2 --- /dev/null +++ b/libs/common/src/state-migrations/owned-migrator.ts @@ -0,0 +1,59 @@ +import { LogService } from "../platform/abstractions/log.service"; +import { DomainToken } from "../platform/services/default-global-state-provider.service"; + +import { MigrationHelper } from "./migration-helper"; +import { Migrator } from "./migrator"; + +export class OwnedMigrationHelper { + + currentVersion: number; + logService: LogService; + + constructor( + public domainToken: DomainToken, + private migrationHelper: MigrationHelper) { + this.currentVersion = migrationHelper.currentVersion; + this.logService = migrationHelper.logService; + } + + get(key: string): Promise { + return this.migrationHelper.get(key); + } + + getFromOwned(key: string): Promise { + + } + + set(key: string, value: T): Promise { + // Create the key + return undefined; + } + info(message: string): void { + // Add domain info? + this.migrationHelper.info("$OwnedMigratonHelper: {message}"); + } + + getAccounts(): Promise<{ userId: string; account: ExpectedAccountType; }[]> { + return this.migrationHelper.getAccounts(); + } +} + +export abstract class OwnedMigrator extends Migrator { + constructor(public domainToken: DomainToken, + fromVersion: TFrom, toVersion: TTo) { + super(fromVersion, toVersion) + } + + override async migrate(helper: MigrationHelper): Promise { + // Create our custom helper + const ownedHelper = new OwnedMigrationHelper(this.domainToken, helper); + await this.migrateOwned(ownedHelper); + const accounts = await ownedHelper.getAccounts(); + for (const account of accounts) { + await this.migrateUserOwned(account.userId, ownedHelper); + } + } + + abstract migrateOwned(helper: OwnedMigrationHelper): Promise; + abstract migrateUserOwned(userId: string, helper: OwnedMigrationHelper): Promise; +} diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index 2244d7a458..3a562601bf 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -1,10 +1,12 @@ import { BehaviorSubject, concatMap } from "rxjs"; +import { Jsonify } from "type-fest"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { ActiveUserStateProviderService, DomainToken, State } from "../../../platform/services/default-global-state-provider.service"; import { CipherService } from "../../../vault/abstractions/cipher.service"; import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction"; import { CipherData } from "../../../vault/models/data/cipher.data"; @@ -12,6 +14,13 @@ import { FolderData } from "../../../vault/models/data/folder.data"; import { Folder } from "../../../vault/models/domain/folder"; import { FolderView } from "../../../vault/models/view/folder.view"; +class UserFolderState { + static fromJSON(jsonState: Jsonify) { + const state = new UserFolderState(); + return state; + } +} + export class FolderService implements InternalFolderServiceAbstraction { protected _folders: BehaviorSubject = new BehaviorSubject([]); protected _folderViews: BehaviorSubject = new BehaviorSubject([]); @@ -19,12 +28,16 @@ export class FolderService implements InternalFolderServiceAbstraction { folders$ = this._folders.asObservable(); folderViews$ = this._folderViews.asObservable(); + private folderState: State; + constructor( private cryptoService: CryptoService, private i18nService: I18nService, private cipherService: CipherService, - private stateService: StateService + private stateService: StateService, + private activeUserStateProviderService: ActiveUserStateProviderService ) { + this.folderState = activeUserStateProviderService.create(new DomainToken("folder", UserFolderState.fromJSON)); this.stateService.activeAccountUnlocked$ .pipe( concatMap(async (unlocked) => {