1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 16:23:44 +00:00
Files
browser/libs/common/src/platform/services/default-environment.service.ts
Matt Gibson 9c1e2ebd67 Typescript-strict-plugin (#12235)
* Use typescript-strict-plugin to iteratively turn on strict

* Add strict testing to pipeline

Can be executed locally through either `npm run test:types` for full type checking including spec files, or `npx tsc-strict` for only tsconfig.json included files.

* turn on strict for scripts directory

* Use plugin for all tsconfigs in monorepo

vscode is capable of executing tsc with plugins, but uses the most relevant tsconfig to do so. If the plugin is not a part of that config, it is skipped and developers get no feedback of strict compile time issues. These updates remedy that at the cost of slightly more complex removal of the plugin when the time comes.

* remove plugin from configs that extend one that already has it

* Update workspace settings to honor strict plugin

* Apply strict-plugin to native message test runner

* Update vscode workspace to use root tsc version

* `./node_modules/.bin/update-strict-comments` 🤖

This is a one-time operation. All future files should adhere to strict type checking.

* Add fixme to `ts-strict-ignore` comments

* `update-strict-comments` 🤖

repeated for new merge files
2024-12-09 20:58:50 +01:00

459 lines
12 KiB
TypeScript

// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { distinctUntilChanged, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { Jsonify } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service";
import { UserId } from "../../types/guid";
import {
EnvironmentService,
Environment,
Region,
RegionConfig,
Urls,
CloudRegion,
} from "../abstractions/environment.service";
import { Utils } from "../misc/utils";
import {
ENVIRONMENT_DISK,
ENVIRONMENT_MEMORY,
GlobalState,
KeyDefinition,
StateProvider,
UserKeyDefinition,
} from "../state";
export class EnvironmentUrls {
base: string = null;
api: string = null;
identity: string = null;
icons: string = null;
notifications: string = null;
events: string = null;
webVault: string = null;
keyConnector: string = null;
}
class EnvironmentState {
region: Region;
urls: EnvironmentUrls;
static fromJSON(obj: Jsonify<EnvironmentState>): EnvironmentState {
return Object.assign(new EnvironmentState(), obj);
}
}
export const GLOBAL_ENVIRONMENT_KEY = new KeyDefinition<EnvironmentState>(
ENVIRONMENT_DISK,
"environment",
{
deserializer: EnvironmentState.fromJSON,
},
);
export const USER_ENVIRONMENT_KEY = new UserKeyDefinition<EnvironmentState>(
ENVIRONMENT_DISK,
"environment",
{
deserializer: EnvironmentState.fromJSON,
clearOn: ["logout"],
},
);
export const GLOBAL_CLOUD_REGION_KEY = new KeyDefinition<CloudRegion>(
ENVIRONMENT_MEMORY,
"cloudRegion",
{
deserializer: (b) => b,
},
);
export const USER_CLOUD_REGION_KEY = new UserKeyDefinition<CloudRegion>(
ENVIRONMENT_MEMORY,
"cloudRegion",
{
deserializer: (b) => b,
clearOn: ["logout"],
},
);
/**
* The production regions available for selection.
*
* In the future we desire to load these urls from the config endpoint.
*/
export const PRODUCTION_REGIONS: RegionConfig[] = [
{
key: Region.US,
domain: "bitwarden.com",
urls: {
base: null,
api: "https://api.bitwarden.com",
identity: "https://identity.bitwarden.com",
icons: "https://icons.bitwarden.net",
webVault: "https://vault.bitwarden.com",
notifications: "https://notifications.bitwarden.com",
events: "https://events.bitwarden.com",
scim: "https://scim.bitwarden.com",
},
},
{
key: Region.EU,
domain: "bitwarden.eu",
urls: {
base: null,
api: "https://api.bitwarden.eu",
identity: "https://identity.bitwarden.eu",
icons: "https://icons.bitwarden.eu",
webVault: "https://vault.bitwarden.eu",
notifications: "https://notifications.bitwarden.eu",
events: "https://events.bitwarden.eu",
scim: "https://scim.bitwarden.eu",
},
},
];
/**
* The default region when starting the app.
*/
const DEFAULT_REGION = Region.US;
/**
* The default region configuration.
*/
const DEFAULT_REGION_CONFIG = PRODUCTION_REGIONS.find((r) => r.key === DEFAULT_REGION);
export class DefaultEnvironmentService implements EnvironmentService {
private globalState: GlobalState<EnvironmentState | null>;
private globalCloudRegionState: GlobalState<CloudRegion | null>;
// We intentionally don't want the helper on account service, we want the null back if there is no active user
private activeAccountId$: Observable<UserId | null> = this.accountService.activeAccount$.pipe(
map((a) => a?.id),
);
environment$: Observable<Environment>;
cloudWebVaultUrl$: Observable<string>;
constructor(
private stateProvider: StateProvider,
private accountService: AccountService,
private additionalRegionConfigs: RegionConfig[] = [],
) {
this.globalState = this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY);
this.globalCloudRegionState = this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY);
const account$ = this.activeAccountId$.pipe(
// Use == here to not trigger on undefined -> null transition
distinctUntilChanged((oldUserId: UserId, newUserId: UserId) => oldUserId == newUserId),
);
this.environment$ = account$.pipe(
switchMap((userId) => {
const t = userId
? this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$
: this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY).state$;
return t;
}),
map((state) => {
return this.buildEnvironment(state?.region, state?.urls);
}),
);
this.cloudWebVaultUrl$ = account$.pipe(
switchMap((userId) => {
const t = userId
? this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).state$
: this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY).state$;
return t;
}),
map((region) => {
if (region != null) {
const config = this.getRegionConfig(region);
if (config != null) {
return config.urls.webVault;
}
}
return DEFAULT_REGION_CONFIG.urls.webVault;
}),
);
}
availableRegions(): RegionConfig[] {
return PRODUCTION_REGIONS.concat(this.additionalRegionConfigs);
}
/**
* Get the region configuration for the given region.
*/
private getRegionConfig(region: Region): RegionConfig | undefined {
return this.availableRegions().find((r) => r.key === region);
}
async setEnvironment(region: Region, urls?: Urls): Promise<Urls> {
// Unknown regions are treated as self-hosted
if (this.getRegionConfig(region) == null) {
region = Region.SelfHosted;
}
// If self-hosted ensure urls are valid else fallback to default region
if (region == Region.SelfHosted && isEmpty(urls)) {
region = DEFAULT_REGION;
}
if (region != Region.SelfHosted) {
await this.globalState.update(() => ({
region: region,
urls: null,
}));
return null;
} else {
// Clean the urls
urls.base = formatUrl(urls.base);
urls.webVault = formatUrl(urls.webVault);
urls.api = formatUrl(urls.api);
urls.identity = formatUrl(urls.identity);
urls.icons = formatUrl(urls.icons);
urls.notifications = formatUrl(urls.notifications);
urls.events = formatUrl(urls.events);
urls.keyConnector = formatUrl(urls.keyConnector);
urls.scim = null;
await this.globalState.update(() => ({
region: region,
urls: {
base: urls.base,
api: urls.api,
identity: urls.identity,
webVault: urls.webVault,
icons: urls.icons,
notifications: urls.notifications,
events: urls.events,
keyConnector: urls.keyConnector,
},
}));
return urls;
}
}
/**
* Helper for building the environment from state. Performs some general sanitization to avoid invalid regions and urls.
*/
protected buildEnvironment(region: Region, urls: Urls) {
// Unknown regions are treated as self-hosted
if (this.getRegionConfig(region) == null) {
region = Region.SelfHosted;
}
// If self-hosted ensure urls are valid else fallback to default region
if (region == Region.SelfHosted && isEmpty(urls)) {
region = DEFAULT_REGION;
}
// Load urls from region config
if (region != Region.SelfHosted) {
const regionConfig = this.getRegionConfig(region);
if (regionConfig != null) {
return new CloudEnvironment(regionConfig);
}
}
return new SelfHostedEnvironment(urls);
}
async setCloudRegion(userId: UserId, region: CloudRegion) {
if (userId == null) {
await this.globalCloudRegionState.update(() => region);
} else {
await this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).update(() => region);
}
}
async getEnvironment(userId?: UserId): Promise<Environment | undefined> {
if (userId == null) {
return await firstValueFrom(this.environment$);
}
const state = await this.getEnvironmentState(userId);
return this.buildEnvironment(state.region, state.urls);
}
private async getEnvironmentState(userId: UserId | null) {
// Previous rules dictated that we only get from user scoped state if there is an active user.
const activeUserId = await firstValueFrom(this.activeAccountId$);
return activeUserId == null
? await firstValueFrom(this.globalState.state$)
: await firstValueFrom(
this.stateProvider.getUser(userId ?? activeUserId, USER_ENVIRONMENT_KEY).state$,
);
}
async seedUserEnvironment(userId: UserId) {
const global = await firstValueFrom(this.globalState.state$);
await this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).update(() => global);
}
}
function formatUrl(url: string): string {
if (url == null || url === "") {
return null;
}
url = url.replace(/\/+$/g, "");
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://" + url;
}
return url.trim();
}
function isEmpty(u?: Urls): boolean {
if (u == null) {
return true;
}
return (
u.base == null &&
u.webVault == null &&
u.api == null &&
u.identity == null &&
u.icons == null &&
u.notifications == null &&
u.events == null
);
}
abstract class UrlEnvironment implements Environment {
constructor(
protected region: Region,
protected urls: Urls,
) {
// Scim is always null for self-hosted
if (region == Region.SelfHosted) {
this.urls.scim = null;
}
}
abstract getHostname(): string;
getRegion() {
return this.region;
}
getUrls() {
return {
base: this.urls.base,
webVault: this.urls.webVault,
api: this.urls.api,
identity: this.urls.identity,
icons: this.urls.icons,
notifications: this.urls.notifications,
events: this.urls.events,
keyConnector: this.urls.keyConnector,
scim: this.urls.scim,
};
}
hasBaseUrl() {
return this.urls.base != null;
}
getWebVaultUrl() {
return this.getUrl("webVault", "");
}
getApiUrl() {
return this.getUrl("api", "/api");
}
getEventsUrl() {
return this.getUrl("events", "/events");
}
getIconsUrl() {
return this.getUrl("icons", "/icons");
}
getIdentityUrl() {
return this.getUrl("identity", "/identity");
}
getKeyConnectorUrl() {
return this.urls.keyConnector;
}
getNotificationsUrl() {
return this.getUrl("notifications", "/notifications");
}
getScimUrl() {
if (this.urls.scim != null) {
return this.urls.scim + "/v2";
}
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
? "https://scim.bitwarden.com/v2"
: this.getWebVaultUrl() + "/scim/v2";
}
getSendUrl() {
return this.getWebVaultUrl() === "https://vault.bitwarden.com"
? "https://send.bitwarden.com/#"
: this.getWebVaultUrl() + "/#/send/";
}
/**
* Presume that if the region is not self-hosted, it is cloud.
*/
isCloud(): boolean {
return this.region !== Region.SelfHosted;
}
/**
* Helper for getting an URL.
*
* @param key Key of the URL to get from URLs
* @param baseSuffix Suffix to append to the base URL if the url is not set
* @returns
*/
private getUrl(key: keyof Urls, baseSuffix: string) {
if (this.urls[key] != null) {
return this.urls[key];
}
if (this.urls.base) {
return this.urls.base + baseSuffix;
}
return DEFAULT_REGION_CONFIG.urls[key];
}
}
/**
* Denote a cloud environment.
*/
export class CloudEnvironment extends UrlEnvironment {
constructor(private config: RegionConfig) {
super(config.key, config.urls);
}
/**
* Cloud always returns nice urls, i.e. bitwarden.com instead of vault.bitwarden.com.
*/
getHostname() {
return this.config.domain;
}
}
export class SelfHostedEnvironment extends UrlEnvironment {
constructor(urls: Urls) {
super(Region.SelfHosted, urls);
}
getHostname() {
return Utils.getHost(this.getWebVaultUrl());
}
}