bitwarden-estensione-browser/libs/common/src/platform/services/config/default-config.service.ts

182 lines
6.4 KiB
TypeScript

import {
NEVER,
Observable,
Subject,
combineLatest,
firstValueFrom,
map,
mergeWith,
of,
shareReplay,
switchMap,
tap,
} from "rxjs";
import { SemVer } from "semver";
import {
DefaultFeatureFlagValue,
FeatureFlag,
FeatureFlagValueType,
} from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ConfigService } from "../../abstractions/config/config.service";
import { ServerConfig } from "../../abstractions/config/server-config";
import { EnvironmentService, Region } from "../../abstractions/environment.service";
import { LogService } from "../../abstractions/log.service";
import { ServerConfigData } from "../../models/data/server-config.data";
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
export const RETRIEVAL_INTERVAL = 3_600_000; // 1 hour
export type ApiUrl = string;
export const USER_SERVER_CONFIG = new UserKeyDefinition<ServerConfig>(CONFIG_DISK, "serverConfig", {
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
clearOn: ["logout"],
});
export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, ApiUrl>(
CONFIG_DISK,
"byServer",
{
deserializer: (data) => (data == null ? null : ServerConfig.fromJSON(data)),
},
);
// FIXME: currently we are limited to api requests for active users. Update to accept a UserId and APIUrl once ApiService supports it.
export class DefaultConfigService implements ConfigService {
private failedFetchFallbackSubject = new Subject<ServerConfig>();
serverConfig$: Observable<ServerConfig>;
cloudRegion$: Observable<Region>;
constructor(
private configApiService: ConfigApiServiceAbstraction,
private environmentService: EnvironmentService,
private logService: LogService,
private stateProvider: StateProvider,
) {
const apiUrl$ = this.environmentService.environment$.pipe(
map((environment) => environment.getApiUrl()),
);
this.serverConfig$ = combineLatest([this.stateProvider.activeUserId$, apiUrl$]).pipe(
switchMap(([userId, apiUrl]) => {
const config$ =
userId == null ? this.globalConfigFor$(apiUrl) : this.userConfigFor$(userId);
return config$.pipe(map((config) => [config, userId, apiUrl] as const));
}),
tap(async (rec) => {
const [existingConfig, userId, apiUrl] = rec;
// Grab new config if older retrieval interval
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
await this.renewConfig(existingConfig, userId, apiUrl);
}
}),
switchMap(([existingConfig]) => {
// If we needed to fetch, stop this emit, we'll get a new one after update
// This is split up with the above tap because we need to return an observable from a failed promise,
// which isn't very doable since promises are converted to observables in switchMap
if (!existingConfig || this.olderThanRetrievalInterval(existingConfig.utcDate)) {
return NEVER;
}
return of(existingConfig);
}),
// If fetch fails, we'll emit on this subject to fallback to the existing config
mergeWith(this.failedFetchFallbackSubject),
shareReplay({ refCount: true, bufferSize: 1 }),
);
this.cloudRegion$ = this.serverConfig$.pipe(
map((config) => config?.environment?.cloudRegion ?? Region.US),
);
}
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
return this.serverConfig$.pipe(
map((serverConfig) => {
if (serverConfig?.featureStates == null || serverConfig.featureStates[key] == null) {
return DefaultFeatureFlagValue[key];
}
return serverConfig.featureStates[key] as FeatureFlagValueType<Flag>;
}),
);
}
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {
return await firstValueFrom(this.getFeatureFlag$(key));
}
checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) {
return this.serverConfig$.pipe(
map((serverConfig) => {
if (serverConfig == null) {
return false;
}
const serverVersion = new SemVer(serverConfig.version);
return serverVersion.compare(minimumRequiredServerVersion) >= 0;
}),
);
}
async ensureConfigFetched() {
// Triggering a retrieval for the given user ensures that the config is less than RETRIEVAL_INTERVAL old
await firstValueFrom(this.serverConfig$);
}
private olderThanRetrievalInterval(date: Date) {
return new Date().getTime() - date.getTime() > RETRIEVAL_INTERVAL;
}
// Updates the on-disk configuration with a newly retrieved configuration
private async renewConfig(
existingConfig: ServerConfig,
userId: UserId,
apiUrl: string,
): Promise<void> {
try {
const response = await this.configApiService.get(userId);
const newConfig = new ServerConfig(new ServerConfigData(response));
// Update the environment region
if (
newConfig?.environment?.cloudRegion != null &&
existingConfig?.environment?.cloudRegion != newConfig.environment.cloudRegion
) {
// Null userId sets global, otherwise sets to the given user
await this.environmentService.setCloudRegion(userId, newConfig?.environment?.cloudRegion);
}
if (userId == null) {
// update global state with new pulled config
await this.stateProvider.getGlobal(GLOBAL_SERVER_CONFIGURATIONS).update((configs) => {
return { ...configs, [apiUrl]: newConfig };
});
} else {
// update state with new pulled config
await this.stateProvider.setUserState(USER_SERVER_CONFIG, newConfig, userId);
}
} catch (e) {
// mutate error to be handled by catchError
this.logService.error(
`Unable to fetch ServerConfig from ${apiUrl}: ${(e as Error)?.message}`,
);
// Emit the existing config
this.failedFetchFallbackSubject.next(existingConfig);
}
}
private globalConfigFor$(apiUrl: string): Observable<ServerConfig> {
return this.stateProvider
.getGlobal(GLOBAL_SERVER_CONFIGURATIONS)
.state$.pipe(map((configs) => configs?.[apiUrl]));
}
private userConfigFor$(userId: UserId): Observable<ServerConfig> {
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$;
}
}