1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-31 22:51:28 +01:00

Rebase: Implement FolderService

This commit is contained in:
Justin Baur 2023-09-29 21:14:24 -04:00
parent d1d12ce61f
commit 5e8feb22d0
No known key found for this signature in database
GPG Key ID: 46438BBD28B69008
24 changed files with 607 additions and 399 deletions

View File

@ -56,6 +56,7 @@ import { ConsoleLogService } from "@bitwarden/common/platform/services/console-l
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { DefaultActiveUserStateProviderService } from "@bitwarden/common/platform/services/default-active-user-state-provider.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
@ -332,11 +333,21 @@ export default class MainBackground {
this.encryptService,
this.cipherFileUploadService
);
// TODO: This is just to make it compile
const activeUserStateProviderService = new DefaultActiveUserStateProviderService(
this.stateService,
this.memoryStorageService,
this.storageService,
this.secureStorageService
);
this.folderService = new BrowserFolderService(
this.cryptoService,
this.i18nService,
this.cipherService,
this.stateService
this.stateService,
activeUserStateProviderService
);
this.folderApiService = new FolderApiService(this.folderService, this.apiService);
this.collectionService = new CollectionService(

View File

@ -64,6 +64,7 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { ActiveUserStateProvider } from "@bitwarden/common/platform/abstractions/active-user-state.provider";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
@ -91,9 +92,9 @@ import { ConsoleLogService } from "@bitwarden/common/platform/services/console-l
import { CryptoService } from "@bitwarden/common/platform/services/crypto.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/services/default-active-user-state.provider";
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";
@ -303,6 +304,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
CryptoServiceAbstraction,
I18nServiceAbstraction,
CipherServiceAbstraction,
ActiveUserStateProvider,
StateServiceAbstraction,
],
},
@ -720,12 +722,15 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction";
deps: [CryptoServiceAbstraction],
},
{
provide: ActiveUserStateProviderService,
useClass: BaseActiveUserStateProviderService,
provide: ActiveUserStateProvider,
useClass: DefaultActiveUserStateProvider,
deps: [
// TODO: Do other storage services
StateServiceAbstraction,
AbstractStorageService
EncryptService,
MEMORY_STORAGE,
AbstractStorageService,
SECURE_STORAGE
]
}
],

View File

@ -1,3 +0,0 @@
export abstract class ActiveUserStateProviderService {
create: <T>(location: StorageLocation, domainToken: DomainToken<T>) => State<T>;
}

View File

@ -0,0 +1,6 @@
import { ActiveUserState } from "../interfaces/active-user-state";
import { KeyDefinition } from "../types/key-definition";
export abstract class ActiveUserStateProvider {
create: <T>(keyDefinition: KeyDefinition<T>) => ActiveUserState<T>;
}

View File

@ -1,3 +0,0 @@
export abstract class GlobalStateProviderService {
create: <T>(location: StorageLocation, domainToken: DomainToken<T>) => State<T>;
}

View File

@ -0,0 +1,6 @@
import { GlobalState } from "../interfaces/global-state";
import { KeyDefinition } from "../types/key-definition";
export abstract class GlobalStateProvider {
create: <T>(keyDefinition: KeyDefinition<T>) => GlobalState<T>;
}

View File

@ -0,0 +1,11 @@
import { Observable } from "rxjs";
import { DerivedActiveUserState } from "../services/default-active-user-state.provider";
import { DerivedStateDefinition } from "../types/derived-state-definition";
export interface ActiveUserState<T> {
readonly state$: Observable<T>
readonly getFromState: () => Promise<T>
readonly update: (configureState: (state: T) => void) => Promise<void>
createDerived: <TTo>(derivedStateDefinition: DerivedStateDefinition<T, TTo>) => DerivedActiveUserState<T, TTo>
}

View File

@ -1,6 +1,6 @@
import { Observable } from "rxjs"
export interface State<T> {
export interface GlobalState<T> {
update: (configureState: (state: T) => void) => Promise<void>
state$: Observable<T>
}

View File

@ -0,0 +1,16 @@
import { KeyDefinition } from "../types/key-definition";
// TODO: Use Matt's `UserId` type
export function userKeyBuilder(
userId: string,
keyDefinition: KeyDefinition<unknown>
): string {
return `${keyDefinition.stateDefinition.name}_${userId}_${keyDefinition.key}`;
}
export function globalKeyBuilder(
keyDefinition: KeyDefinition<unknown>
): string {
// TODO: Do we want the _global_ part?
return `${keyDefinition.stateDefinition.name}_global_${keyDefinition.key}`;
}

View File

@ -1,125 +0,0 @@
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<T> implements State<T> {
private formattedKey$: Observable<string>;
// TODO: Use BitSubject
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
private stateSubject$ = this.stateSubject.asObservable();
state$: Observable<T>;
// Global:
// FolderService = <data>
// User (super flat)
// FolderService_{userId}_someData = <data>
// FolderService_{userId}_moreData = <data>
// -- or --
// User (not as flat)
// FolderService_{userId} = <data>
constructor(
private stateService: StateService,
private storageLocation: AbstractStorageService,
domainToken: DomainToken<T>) {
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<T>(key) as Jsonify<T>;
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<void> {
// 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<string> {
return await firstValueFrom(this.formattedKey$);
}
}
export class DefaultActiveUserStateProviderService implements ActiveUserStateProviderService {
private userStateCache: Record<string, DefaultActiveUserState<unknown>> = {};
constructor(
private stateService: StateService, // Inject the lightest weight service that provides accountUserId$
private memoryStorage: AbstractMemoryStorageService,
private diskStorage: AbstractStorageService,
private secureStorage: AbstractStorageService) {
}
create<T>(location: StorageLocation, domainToken: DomainToken<T>): DefaultActiveUserState<T> {
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<T>;
}
const newActiveUserState = new DefaultActiveUserState<T>(
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;
}
}
}

View File

@ -1,19 +1,15 @@
import { matches, mock, mockReset, notNull } from "jest-mock-extended";
import { matches, mock, mockReset } 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 { KeyDefinition } from "../types/key-definition";
import { StateDefinition } from "../types/state-definition";
import { DomainToken, BaseActiveUserStateProviderService } from "./default-global-state-provider.service"
import { DefaultActiveUserStateProvider } from "./default-active-user-state.provider";
class TestState {
constructor() {
}
date: Date;
array: string[]
// TODO: More complex data types
@ -26,16 +22,18 @@ class TestState {
}
}
const fakeDomainToken = new DomainToken<TestState>("fake", TestState.fromJSON);
const testStateDefinition = new StateDefinition("fake", "disk");
describe("BaseStateProviderService", () => {
const testKeyDefinition = new KeyDefinition<TestState>(testStateDefinition, "fake", TestState.fromJSON);
describe("DefaultStateProvider", () => {
const stateService = mock<StateService>();
const memoryStorageService = mock<AbstractMemoryStorageService>();
const diskStorageService = mock<AbstractMemoryStorageService>();
const activeAccountSubject = new BehaviorSubject<string>(undefined);
let stateProviderService: BaseActiveUserStateProviderService;
let activeUserStateProvider: DefaultActiveUserStateProvider;
beforeEach(() => {
mockReset(stateService);
@ -44,7 +42,13 @@ describe("BaseStateProviderService", () => {
stateService.activeAccount$ = activeAccountSubject;
stateProviderService = new BaseActiveUserStateProviderService(stateService, diskStorageService);
activeUserStateProvider = new DefaultActiveUserStateProvider(
stateService,
null, // Not testing derived state
null, // Not testing memory storage
diskStorageService,
null // Not testing secure storage
);
});
it("createUserState", async () => {
@ -56,7 +60,7 @@ describe("BaseStateProviderService", () => {
return undefined;
});
const fakeDomainState = stateProviderService.create(fakeDomainToken);
const fakeDomainState = activeUserStateProvider.create(testKeyDefinition);
const subscribeCallback = jest.fn<void, [TestState]>();
const subscription = fakeDomainState.state$.subscribe(subscribeCallback);
@ -66,8 +70,8 @@ describe("BaseStateProviderService", () => {
await new Promise<void>(resolve => setTimeout(resolve, 10));
// Service does an update
fakeDomainState.update(state => state.array.push("value3"));
await new Promise<void>(resolve => setTimeout(resolve, 1));
await fakeDomainState.update(state => state.array.push("value3"));
await new Promise<void>(resolve => setTimeout(resolve, 10));
subscription.unsubscribe();
@ -76,13 +80,13 @@ describe("BaseStateProviderService", () => {
// Gotten starter user data
expect(subscribeCallback).toHaveBeenNthCalledWith(2, matches<TestState>(value => {
console.log("Called", value);
return true;
}));
// Gotten update callback data
expect(subscribeCallback).toHaveBeenNthCalledWith(3, matches<TestState>((value) => {
return typeof value.date == "object" &&
return value != null &&
typeof value.date == "object" &&
value.date.getFullYear() == 2023 &&
value.array.length == 3
}));

View File

@ -0,0 +1,199 @@
import { BehaviorSubject, Observable, defer, firstValueFrom, map, share, switchMap, tap } from "rxjs";
import { Jsonify } from "type-fest";
import { ActiveUserStateProvider } from "../abstractions/active-user-state.provider";
import { EncryptService } from "../abstractions/encrypt.service";
import { StateService } from "../abstractions/state.service";
import { AbstractMemoryStorageService, AbstractStorageService } from "../abstractions/storage.service";
import { ActiveUserState } from "../interfaces/active-user-state";
import { userKeyBuilder } from "../misc/key-builders";
import { UserKey } from "../models/domain/symmetric-crypto-key";
import { KeyDefinition } from "../types/key-definition";
import { StorageLocation } from "./default-global-state.provider";
class ConverterContext {
constructor(
readonly activeUserKey: UserKey,
readonly encryptService: EncryptService
) { }
}
class DerivedStateDefinition<TFrom, TTo> {
constructor(
readonly converter: (data: TFrom, context: ConverterContext) => Promise<TTo>
) { }
}
export class DerivedActiveUserState<TFrom, TTo> {
state$: Observable<TTo>
// TODO: Probably needs to take state service
/**
*
*/
constructor(
private derivedStateDefinition: DerivedStateDefinition<TFrom, TTo>,
private encryptService: EncryptService,
private activeUserState: ActiveUserState<TFrom>
) {
this.state$ = activeUserState.state$
.pipe(switchMap(async from => {
// TODO: How do I get the key?
const convertedData = await derivedStateDefinition.converter(from, new ConverterContext(null, encryptService));
return convertedData;
}));
}
async getFromState(): Promise<TTo> {
const encryptedFromState = await this.activeUserState.getFromState();
const context = new ConverterContext(null, this.encryptService);
const decryptedData = await this.derivedStateDefinition.converter(encryptedFromState, context);
return decryptedData;
}
}
class DefaultActiveUserState<T> implements ActiveUserState<T> {
private seededInitial = false;
private formattedKey$: Observable<string>;
private chosenStorageLocation: AbstractStorageService;
// TODO: Use BitSubject
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
private stateSubject$ = this.stateSubject.asObservable();
state$: Observable<T>;
constructor(
private keyDefinition: KeyDefinition<T>,
private stateService: StateService,
private encryptService: EncryptService,
private memoryStorageService: AbstractMemoryStorageService,
private secureStorageService: AbstractStorageService,
private diskStorageService: AbstractStorageService
) {
this.chosenStorageLocation = this.chooseStorage(
this.keyDefinition.stateDefinition.storageLocation
);
const unformattedKey = `${this.keyDefinition.stateDefinition.name}_{userId}_${this.keyDefinition.key}`;
// startWith?
this.formattedKey$ = this.stateService.activeAccount$
.pipe(
map(accountId => accountId != null
? unformattedKey.replace("{userId}", accountId)
: null)
);
const activeAccountData$ = this.formattedKey$
.pipe(switchMap(async key => {
console.log("user emitted", key);
if (key == null) {
return null;
}
const jsonData = await this.chosenStorageLocation.get<Jsonify<T>>(key);
const data = keyDefinition.serializer(jsonData);
return data;
}),
tap(data => {
console.log("data:", 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(() => {
console.log("starting subscription.");
const subscription = activeAccountData$.subscribe();
return this.stateSubject$
.pipe(tap({
complete: () => subscription.unsubscribe(),
}));
});
}
async update(configureState: (state: T) => void): Promise<void> {
const currentState = await firstValueFrom(this.state$);
console.log("data to update:", currentState);
configureState(currentState);
const key = await this.createKey();
if (key == null) {
throw new Error("Attempting to active user state, when no user is active.");
}
console.log(`updating ${key} to ${currentState}`);
await this.chosenStorageLocation.save(await this.createKey(), currentState);
this.stateSubject.next(currentState);
}
async getFromState(): Promise<T> {
const activeUserId = await this.stateService.getUserId();
const key = userKeyBuilder(activeUserId, this.keyDefinition);
const data = await this.chosenStorageLocation.get(key) as Jsonify<T>;
return this.keyDefinition.serializer(data);
}
createDerived<TTo>(derivedStateDefinition: DerivedStateDefinition<T, TTo>): DerivedActiveUserState<T, TTo> {
return new DerivedActiveUserState<T, TTo>(
derivedStateDefinition,
this.encryptService,
this
);
}
private async createKey(): Promise<string> {
return `${(await firstValueFrom(this.formattedKey$))}`;
}
private chooseStorage(storageLocation: StorageLocation): AbstractStorageService {
switch (storageLocation) {
case "disk":
return this.diskStorageService;
case "secure":
return this.secureStorageService;
case "memory":
return this.memoryStorageService;
}
}
}
export class DefaultActiveUserStateProvider implements ActiveUserStateProvider {
private userStateCache: Record<string, DefaultActiveUserState<unknown>> = {};
constructor(
private stateService: StateService, // Inject the lightest weight service that provides accountUserId$
private encryptService: EncryptService,
private memoryStorage: AbstractMemoryStorageService,
private diskStorage: AbstractStorageService,
private secureStorage: AbstractStorageService) {
}
create<T>(keyDefinition: KeyDefinition<T>): DefaultActiveUserState<T> {
const locationDomainKey =
`${keyDefinition.stateDefinition.storageLocation}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`;
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<T>;
}
const newActiveUserState = new DefaultActiveUserState<T>(
keyDefinition,
this.stateService,
this.encryptService,
this.memoryStorage,
this.secureStorage,
this.diskStorage
);
this.userStateCache[locationDomainKey] = newActiveUserState;
return newActiveUserState;
}
}

View File

@ -1,81 +0,0 @@
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<T> {
constructor(
public domainName: string,
public serializer: (jsonData: Jsonify<T>) => T) {
}
}
// TODO: Move type
export type StorageLocation = "memory" | "disk" | "secure";
class DefaultGlobalState<T> implements State<T> {
protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
state$: Observable<T>;
constructor(private storageLocation: AbstractStorageService, private domainToken: DomainToken<T>) {
this.state$ = this.stateSubject.asObservable();
}
async update(configureState: (state: T) => void): Promise<void> {
// 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<string, DefaultGlobalState<unknown>> = {};
constructor(
private memoryStorage: AbstractMemoryStorageService,
private diskStorage: AbstractStorageService,
private secureStorage: AbstractStorageService) {
}
create<T>(location: StorageLocation, domainToken: DomainToken<T>): DefaultGlobalState<T> {
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<T>;
}
const newGlobalState = new DefaultGlobalState<T>(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;
}
}
}

View File

@ -0,0 +1,84 @@
import { BehaviorSubject, Observable, defer, firstValueFrom } from "rxjs";
import { GlobalStateProvider } from "../abstractions/global-state.provider";
import { AbstractMemoryStorageService, AbstractStorageService } from "../abstractions/storage.service";
import { ActiveUserState } from "../interfaces/active-user-state";
import { KeyDefinition } from "../types/key-definition";
import { Jsonify } from "type-fest";
import { globalKeyBuilder } from "../misc/key-builders";
// TODO: Move type
export type StorageLocation = "memory" | "disk" | "secure";
// class DefaultGlobalState<T> implements ActiveUserState<T> {
// private storageKey: string;
// protected stateSubject: BehaviorSubject<T | null> = new BehaviorSubject<T | null>(null);
// state$: Observable<T>;
// constructor(
// private keyDefinition: KeyDefinition<T>,
// private chosenLocation: AbstractStorageService
// ) {
// this.storageKey = globalKeyBuilder(this.keyDefinition);
// // TODO: When subsribed to, we need to read data from the chosen storage location
// // and give it back
// this.state$ = new Observable<T>()
// }
// async update(configureState: (state: T) => void): Promise<void> {
// const currentState = await firstValueFrom(this.state$);
// configureState(currentState);
// await this.chosenLocation.save(this.storageKey, currentState);
// this.stateSubject.next(currentState);
// }
// async getFromState(): Promise<T> {
// const data = await this.chosenLocation.get<Jsonify<T>>(this.storageKey);
// return this.keyDefinition.serializer(data);
// }
// }
// export class DefaultGlobalStateProvider implements GlobalStateProvider {
// private globalStateCache: Record<string, DefaultGlobalState<unknown>> = {};
// constructor(
// private memoryStorage: AbstractMemoryStorageService,
// private diskStorage: AbstractStorageService,
// private secureStorage: AbstractStorageService) {
// }
// create<T>(keyDefinition: KeyDefinition<T>): DefaultGlobalState<T> {
// const locationDomainKey = `${keyDefinition.stateDefinition.storageLocation}_${keyDefinition.stateDefinition.name}_${keyDefinition.key}`;
// 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<T>;
// }
// const newGlobalState = new DefaultGlobalState<T>(
// keyDefinition,
// this.getLocation(keyDefinition.stateDefinition.storageLocation)
// );
// 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;
// }
// }
// }

View File

@ -0,0 +1,18 @@
import { EncryptService } from "../abstractions/encrypt.service";
import { UserKey } from "../models/domain/symmetric-crypto-key";
import { StorageLocation } from "../services/default-global-state.provider";
// TODO: Move type
export class DeriveContext {
constructor(
readonly activeUserKey: UserKey,
readonly encryptService: EncryptService
) { }
}
export class DerivedStateDefinition<TFrom, TTo> {
constructor(
readonly location: StorageLocation,
readonly converter: (data: TFrom, context: DeriveContext) => Promise<TTo>
) {}
}

View File

@ -0,0 +1,52 @@
import { Jsonify } from "type-fest";
import { DeriveContext, DerivedStateDefinition } from "./derived-state-definition";
import { StateDefinition, StorageLocation } from "./state-definition";
/**
*
*/
// TODO Import Purpose type
export class KeyDefinition<T> {
/**
* Creates a new instance of a KeyDefinition
* @param stateDefinition The state definition for which this key belongs to.
* @param key The name of the key, this should be unique per domain
* @param serializer A function to use to safely convert your type from json to your expected type.
*/
constructor(
readonly stateDefinition: StateDefinition,
readonly key: string,
readonly serializer: (jsonValue: Jsonify<T>) => T
) { }
static array<T>(stateDefinition: StateDefinition, key: string, serializer: (jsonValue: Jsonify<T>) => T) {
return new KeyDefinition<T[]>(stateDefinition, key, (jsonValue) => {
// TODO: Should we handle null for them, I feel like we should discourage null for an array?
return jsonValue.map(v => serializer(v));
});
}
static record<T>(stateDefinition: StateDefinition, key: string, serializer: (jsonValue: Jsonify<T>) => T) {
return new KeyDefinition<Record<string, T>>(stateDefinition, key, (jsonValue) => {
const output: Record<string, T> = {};
for (const key in jsonValue) {
output[key] = serializer((jsonValue as Record<string, Jsonify<T>>)[key]);
}
return output;
});
}
/**
* Helper for defining a derived definition that will often be used alongside a given key
* @param storageLocation
* @param decrypt
* @returns
*/
createDerivedDefinition<TTo>(
storageLocation: StorageLocation,
decrypt: (data: T, context: DeriveContext) => Promise<TTo>
) {
return new DerivedStateDefinition(storageLocation, decrypt);
}
}

View File

@ -0,0 +1,19 @@
// TODO: How can we protect the creation of these so that platform can maintain the allowed creations?
// TODO: Where should this live
export type StorageLocation = "disk" | "memory" | "secure";
/**
*
*/
export class StateDefinition {
/**
*
* @param name The name of the state, this needs to be unique from all other {@link StateDefinition}'s.
* @param storageLocation The location of where this state should be stored.
*/
constructor(
readonly name: string,
readonly storageLocation: StorageLocation
) { }
}

View File

@ -0,0 +1,22 @@
import { StateDefinition } from "./state-definition";
import * as definitions from "./state-definitions";
it("has all unique definitions", () => {
const uniqueNames: string[] = [];
const keys = Object.keys(definitions);
for (const key of keys) {
const definition = (definitions as unknown as Record<string, StateDefinition>)[key];
if (Object.getPrototypeOf(definition) !== StateDefinition.prototype) {
throw new Error(`${key} from import ./state-definitions is expected to be a StateDefinition but wasn't.`);
}
const name = `${definition.name}_${definition.storageLocation}`;
if (uniqueNames.includes(name)) {
throw new Error(`Definition ${key} is invalid, it's elements have already been claimed. Please choose a unique name.`);
}
uniqueNames.push(name);
}
});

View File

@ -0,0 +1,3 @@
import { StateDefinition } from "./state-definition";
export const FOLDER_SERVICE_DISK = new StateDefinition("FolderService", "disk");

View File

@ -11,10 +11,11 @@ import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveFolderToOwnedMigrator } from "./migrations/9-move-folder-to-owned";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 2;
export const CURRENT_VERSION = 8;
export const CURRENT_VERSION = 9;
export type MinVersion = typeof MIN_VERSION;
export async function migrate(
@ -38,7 +39,8 @@ export async function migrate(
.with(AddKeyTypeToOrgKeysMigrator, 4, 5)
.with(RemoveLegacyEtmKeyMigrator, 5, 6)
.with(MoveBiometricAutoPromptToAccount, 6, 7)
.with(MoveStateVersionMigrator, 7, CURRENT_VERSION)
.with(MoveStateVersionMigrator, 7, 8)
.with(MoveFolderToOwnedMigrator, 8, CURRENT_VERSION)
.migrate(migrationHelper);
}

View File

@ -0,0 +1,52 @@
// TODO: Add message
// eslint-disable-next-line import/no-restricted-paths
import { userKeyBuilder } from "../../platform/misc/key-builders";
// TODO: Add message
// eslint-disable-next-line import/no-restricted-paths
import { KeyDefinition } from "../../platform/types/key-definition";
// TODO: Add message
// eslint-disable-next-line import/no-restricted-paths
import { StateDefinition } from "../../platform/types/state-definition";
import { MigrationHelper } from "../migration-helper";
import { IRREVERSIBLE, Migrator } from "../migrator";
type ExpectedAccountType = {
data: {
folders: {
encrypted: Record<string, { name: string, id: string, revisionDate: string }>
}
}
};
const FOLDER_STATE = new StateDefinition("FolderService", "disk");
const INITIAL_FOLDER_USER_KEY = new KeyDefinition<unknown>(FOLDER_STATE, "folders", (s) => s);
export class MoveFolderToOwnedMigrator extends Migrator<8, 9> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<ExpectedAccountType>();
async function updateAccount(userId: string, account: ExpectedAccountType) {
if (account == null) {
return;
}
const userKey = userKeyBuilder(userId, INITIAL_FOLDER_USER_KEY);
await helper.set(userKey, account.data.folders.encrypted);
// TODO: Is there ever anything more on the folders object than the encrypted prop
// delete account.data.folders;
helper.info(`Would delete: ${JSON.stringify(account.data.folders)}`);
// TODO:
// await helper.set("", account);
}
await Promise.all(
accounts.map(({ userId, account}) => updateAccount(userId, account))
);
}
rollback(helper: MigrationHelper): Promise<void> {
// TODO: This doesn't actually need to be irreversible
throw IRREVERSIBLE;
}
}

View File

@ -1,59 +0,0 @@
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<unknown>,
private migrationHelper: MigrationHelper) {
this.currentVersion = migrationHelper.currentVersion;
this.logService = migrationHelper.logService;
}
get<T>(key: string): Promise<T> {
return this.migrationHelper.get(key);
}
getFromOwned<T>(key: string): Promise<T> {
}
set<T>(key: string, value: T): Promise<void> {
// Create the key
return undefined;
}
info(message: string): void {
// Add domain info?
this.migrationHelper.info("$OwnedMigratonHelper: {message}");
}
getAccounts<ExpectedAccountType>(): Promise<{ userId: string; account: ExpectedAccountType; }[]> {
return this.migrationHelper.getAccounts();
}
}
export abstract class OwnedMigrator<TFrom extends number, TTo extends number, TState> extends Migrator<TFrom, TTo> {
constructor(public domainToken: DomainToken<TState>,
fromVersion: TFrom, toVersion: TTo) {
super(fromVersion, toVersion)
}
override async migrate(helper: MigrationHelper): Promise<void> {
// 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<void>;
abstract migrateUserOwned(userId: string, helper: OwnedMigrationHelper): Promise<void>;
}

View File

@ -1,66 +1,57 @@
import { BehaviorSubject, concatMap } from "rxjs";
import { Jsonify } from "type-fest";
import { Observable, firstValueFrom, map } from "rxjs";
import { ActiveUserStateProvider } from "../../../platform/abstractions/active-user-state.provider";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { ActiveUserState } from "../../../platform/interfaces/active-user-state";
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 { DerivedActiveUserState } from "../../../platform/services/default-active-user-state.provider";
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";
import { FolderData } from "../../../vault/models/data/folder.data";
import { Folder } from "../../../vault/models/domain/folder";
import { FolderView } from "../../../vault/models/view/folder.view";
import { FOLDERS } from "../../types/key-definitions";
class UserFolderState {
static fromJSON(jsonState: Jsonify<UserFolderState>) {
const state = new UserFolderState();
return state;
}
}
export class FolderService implements InternalFolderServiceAbstraction {
protected _folders: BehaviorSubject<Folder[]> = new BehaviorSubject([]);
protected _folderViews: BehaviorSubject<FolderView[]> = new BehaviorSubject([]);
folders$ = this._folders.asObservable();
folderViews$ = this._folderViews.asObservable();
folderState: ActiveUserState<Record<string, Folder>>;
decryptedFolderState: DerivedActiveUserState<Record<string, Folder>, FolderView[]>
private folderState: State<UserFolderState>;
folders$: Observable<Folder[]>;
folderViews$: Observable<FolderView[]>;
constructor(
private cryptoService: CryptoService,
private i18nService: I18nService,
private cipherService: CipherService,
private stateService: StateService,
private activeUserStateProviderService: ActiveUserStateProviderService
private activeUserStateProvider: ActiveUserStateProvider,
private stateService: StateService
) {
this.folderState = activeUserStateProviderService.create<UserFolderState>(new DomainToken("folder", UserFolderState.fromJSON));
this.stateService.activeAccountUnlocked$
.pipe(
concatMap(async (unlocked) => {
if (Utils.global.bitwardenContainerService == null) {
return;
}
const derivedFoldersDefinition = FOLDERS.createDerivedDefinition("memory", async (foldersMap) => {
const folders = this.flattenMap(foldersMap);
const decryptedFolders = await this.decryptFolders(folders);
return decryptedFolders;
})
if (!unlocked) {
this._folders.next([]);
this._folderViews.next([]);
return;
}
this.folderState = this.activeUserStateProvider.create(FOLDERS);
const data = await this.stateService.getEncryptedFolders();
this.folders$ = this.folderState.state$
.pipe(map(foldersMap => {
return this.flattenMap(foldersMap);
}));
await this.updateObservables(data);
})
)
.subscribe();
this.decryptedFolderState = this.folderState.createDerived(derivedFoldersDefinition);
this.folderViews$ = this.decryptedFolderState.state$;
}
async clearCache(): Promise<void> {
this._folderViews.next([]);
// TODO: I don't really have a replacement for this right now
// this._folderViews.next([]);
}
// TODO: This should be moved to EncryptService or something
@ -72,21 +63,13 @@ export class FolderService implements InternalFolderServiceAbstraction {
}
async get(id: string): Promise<Folder> {
const folders = this._folders.getValue();
return folders.find((folder) => folder.id === id);
const folders = await firstValueFrom(this.folderState.state$);
return folders[id];
}
async getAllFromState(): Promise<Folder[]> {
const folders = await this.stateService.getEncryptedFolders();
const response: Folder[] = [];
for (const id in folders) {
// eslint-disable-next-line
if (folders.hasOwnProperty(id)) {
response.push(new Folder(folders[id]));
}
}
return response;
const foldersMap = await this.folderState.getFromState();
return this.flattenMap(foldersMap);
}
/**
@ -94,76 +77,58 @@ export class FolderService implements InternalFolderServiceAbstraction {
* @param id id of the folder
*/
async getFromState(id: string): Promise<Folder> {
const foldersMap = await this.stateService.getEncryptedFolders();
const foldersMap = await this.folderState.getFromState();
const folder = foldersMap[id];
if (folder == null) {
return null;
}
return new Folder(folder);
return folder;
}
/**
* @deprecated Only use in CLI!
*/
async getAllDecryptedFromState(): Promise<FolderView[]> {
const data = await this.stateService.getEncryptedFolders();
const folders = Object.values(data || {}).map((f) => new Folder(f));
return this.decryptFolders(folders);
return await this.decryptedFolderState.getFromState();
}
async upsert(folder: FolderData | FolderData[]): Promise<void> {
let folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
folders = {};
}
if (folder instanceof FolderData) {
const f = folder as FolderData;
folders[f.id] = f;
} else {
(folder as FolderData[]).forEach((f) => {
folders[f.id] = f;
});
}
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
console.log("upsert", folder);
await this.folderState.update(folders => {
if (folder instanceof FolderData) {
const f = folder as FolderData;
folders[f.id] = new Folder(f);
} else {
(folder as FolderData[]).forEach((f) => {
folders[f.id] = new Folder(f);
});
}
});
}
async replace(folders: { [id: string]: FolderData }): Promise<void> {
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
const convertedFolders = Object.entries(folders).reduce((agg, [key, value]) => {
agg[key] = new Folder(value);
return agg;
}, {} as Record<string, Folder>);
console.log("replace", folders, convertedFolders);
await this.folderState.update(f => f = convertedFolders);
}
async clear(userId?: string): Promise<any> {
if (userId == null || userId == (await this.stateService.getUserId())) {
this._folders.next([]);
this._folderViews.next([]);
}
await this.stateService.setEncryptedFolders(null, { userId: userId });
console.log("clear", userId);
await this.folderState.update(f => f = null);
}
async delete(id: string | string[]): Promise<any> {
const folders = await this.stateService.getEncryptedFolders();
if (folders == null) {
return;
}
if (typeof id === "string") {
if (folders[id] == null) {
return;
async delete(id: string | string[]): Promise<void> {
const folderIds = typeof id === "string" ? [id] : id;
console.log("delete", folderIds);
await this.folderState.update(folders => {
for (const folderId in folderIds) {
delete folders[folderId];
}
delete folders[id];
} else {
(id as string[]).forEach((i) => {
delete folders[i];
});
}
await this.updateObservables(folders);
await this.stateService.setEncryptedFolders(folders);
});
// Items in a deleted folder are re-assigned to "No Folder"
const ciphers = await this.stateService.getEncryptedCiphers();
@ -181,16 +146,6 @@ export class FolderService implements InternalFolderServiceAbstraction {
}
}
private async updateObservables(foldersMap: { [id: string]: FolderData }) {
const folders = Object.values(foldersMap || {}).map((f) => new Folder(f));
this._folders.next(folders);
if (await this.cryptoService.hasUserKey()) {
this._folderViews.next(await this.decryptFolders(folders));
}
}
private async decryptFolders(folders: Folder[]) {
const decryptFolderPromises = folders.map((f) => f.decrypt());
const decryptedFolders = await Promise.all(decryptFolderPromises);
@ -203,4 +158,12 @@ export class FolderService implements InternalFolderServiceAbstraction {
return decryptedFolders;
}
private flattenMap(foldersMap: Record<string, Folder>): Folder[] {
const folders: Folder[] = [];
for (const id in foldersMap) {
folders.push(foldersMap[id]);
}
return folders;
}
}

View File

@ -0,0 +1,6 @@
import { KeyDefinition } from "../../platform/types/key-definition";
import { FOLDER_SERVICE_DISK } from "../../platform/types/state-definitions";
import { Folder } from "../models/domain/folder";
// FolderService Keys
export const FOLDERS = KeyDefinition.record<Folder>(FOLDER_SERVICE_DISK, "folders", Folder.fromJSON);