mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 21:33:27 +00:00
Add Web Push Support (#11346)
* WIP: PoC with lots of terrible code with web push * fix service worker building * Work on WebPush Tailored to Browser * Clean Up Web And MV2 * Fix Merge Conflicts * Prettier * Use Unsupported for MV2 * Add Doc Comments * Remove Permission Button * Fix Type Test * Write Time In More Readable Format * Add SignalR Logger * `sheduleReconnect` -> `scheduleReconnect` Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * Capture Support Context In Connector * Remove Unneeded CSP Change * Fix Build * Simplify `getOrCreateSubscription` * Add More Docs to Matrix * Update libs/common/src/platform/notifications/internal/worker-webpush-connection.service.ts Co-authored-by: Matt Gibson <mgibson@bitwarden.com> * Move API Service Into Notifications Folder * Allow Connection When Account Is Locked * Add Comments to NotificationsService * Only Change Support Status If Public Key Changes * Move Service Choice Out To Method * Use Named Constant For Disabled Notification Url * Add Test & Cleanup * Flatten * Move Tests into `beforeEach` & `afterEach` * Add Tests * Test `distinctUntilChanged`'s Operators More * Make Helper And Cleanup Chain * Add Back Cast * Add extra safety to incoming config check * Put data through response object * Apply TS Strict Rules * Finish PushTechnology comment * Use `instanceof` check * Do Safer Worker Based Registration for MV3 * Remove TODO * Switch to SignalR on any WebPush Error * Fix Manifest Permissions * Add Back `webNavigation` * Sorry, Remove `webNavigation` * Fixed merge conflicts. --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com> Co-authored-by: Todd Martin <tmartin@bitwarden.com> Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com>
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type";
|
||||
|
||||
const IdleInterval = 60 * 5; // 5 minutes
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
@@ -109,6 +108,14 @@ import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize";
|
||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import {
|
||||
DefaultNotificationsService,
|
||||
WorkerWebPushConnectionService,
|
||||
SignalRConnectionService,
|
||||
UnsupportedWebPushConnectionService,
|
||||
WebPushNotificationsApiService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
import { ScheduledTaskNames } from "@bitwarden/common/platform/scheduling";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
@@ -159,7 +166,6 @@ import { ApiService } from "@bitwarden/common/services/api.service";
|
||||
import { AuditService } from "@bitwarden/common/services/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
|
||||
import { SearchService } from "@bitwarden/common/services/search.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
|
||||
import {
|
||||
@@ -314,7 +320,7 @@ export default class MainBackground {
|
||||
importService: ImportServiceAbstraction;
|
||||
exportService: VaultExportServiceAbstraction;
|
||||
searchService: SearchServiceAbstraction;
|
||||
notificationsService: NotificationsServiceAbstraction;
|
||||
notificationsService: NotificationsService;
|
||||
stateService: StateServiceAbstraction;
|
||||
userNotificationSettingsService: UserNotificationSettingsServiceAbstraction;
|
||||
autofillSettingsService: AutofillSettingsServiceAbstraction;
|
||||
@@ -378,6 +384,8 @@ export default class MainBackground {
|
||||
kdfConfigService: KdfConfigService;
|
||||
offscreenDocumentService: OffscreenDocumentService;
|
||||
syncServiceListener: SyncServiceListener;
|
||||
|
||||
webPushConnectionService: WorkerWebPushConnectionService | UnsupportedWebPushConnectionService;
|
||||
themeStateService: DefaultThemeStateService;
|
||||
autoSubmitLoginBackground: AutoSubmitLoginBackground;
|
||||
sdkService: SdkService;
|
||||
@@ -408,11 +416,6 @@ export default class MainBackground {
|
||||
constructor() {
|
||||
// Services
|
||||
const lockedCallback = async (userId?: string) => {
|
||||
if (this.notificationsService != null) {
|
||||
// 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.notificationsService.updateConnection(false);
|
||||
}
|
||||
await this.refreshBadge();
|
||||
await this.refreshMenu(true);
|
||||
if (this.systemService != null) {
|
||||
@@ -1034,17 +1037,34 @@ export default class MainBackground {
|
||||
this.organizationVaultExportService,
|
||||
);
|
||||
|
||||
this.notificationsService = new NotificationsService(
|
||||
this.logService,
|
||||
if (BrowserApi.isManifestVersion(3)) {
|
||||
const registration = (self as unknown as { registration: ServiceWorkerRegistration })
|
||||
?.registration;
|
||||
|
||||
if (registration != null) {
|
||||
this.webPushConnectionService = new WorkerWebPushConnectionService(
|
||||
this.configService,
|
||||
new WebPushNotificationsApiService(this.apiService, this.appIdService),
|
||||
registration,
|
||||
);
|
||||
} else {
|
||||
this.webPushConnectionService = new UnsupportedWebPushConnectionService();
|
||||
}
|
||||
} else {
|
||||
this.webPushConnectionService = new UnsupportedWebPushConnectionService();
|
||||
}
|
||||
|
||||
this.notificationsService = new DefaultNotificationsService(
|
||||
this.syncService,
|
||||
this.appIdService,
|
||||
this.apiService,
|
||||
this.environmentService,
|
||||
logoutCallback,
|
||||
this.stateService,
|
||||
this.authService,
|
||||
this.messagingService,
|
||||
this.taskSchedulerService,
|
||||
this.accountService,
|
||||
new SignalRConnectionService(this.apiService, this.logService),
|
||||
this.authService,
|
||||
this.webPushConnectionService,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
|
||||
@@ -1259,6 +1279,9 @@ export default class MainBackground {
|
||||
}
|
||||
|
||||
async bootstrap() {
|
||||
if (this.webPushConnectionService instanceof WorkerWebPushConnectionService) {
|
||||
this.webPushConnectionService.start();
|
||||
}
|
||||
this.containerService.attachToGlobal(self);
|
||||
|
||||
await this.sdkLoadService.load();
|
||||
@@ -1324,12 +1347,7 @@ export default class MainBackground {
|
||||
setTimeout(async () => {
|
||||
await this.refreshBadge();
|
||||
await this.fullSync(false);
|
||||
this.taskSchedulerService.setInterval(
|
||||
ScheduledTaskNames.scheduleNextSyncInterval,
|
||||
5 * 60 * 1000, // check every 5 minutes
|
||||
);
|
||||
setTimeout(() => this.notificationsService.init(), 2500);
|
||||
await this.taskSchedulerService.verifyAlarmsState();
|
||||
this.notificationsService.startListening();
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
@@ -1408,7 +1426,6 @@ export default class MainBackground {
|
||||
ForceSetPasswordReason.None;
|
||||
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.notificationsService.updateConnection(false);
|
||||
|
||||
if (nextAccountStatus === AuthenticationStatus.LoggedOut) {
|
||||
this.messagingService.send("goHome");
|
||||
@@ -1512,7 +1529,6 @@ export default class MainBackground {
|
||||
}
|
||||
await this.refreshBadge();
|
||||
await this.mainContextMenuHandler?.noAccess();
|
||||
await this.notificationsService.updateConnection(false);
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.processReloadService.startProcessReload(this.authService);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { firstValueFrom, map, mergeMap } from "rxjs";
|
||||
|
||||
import { LockService } from "@bitwarden/auth/common";
|
||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AutofillOverlayVisibility, ExtensionCommand } from "@bitwarden/common/autofill/constants";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
@@ -16,6 +15,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/messaging";
|
||||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { BiometricsCommands } from "@bitwarden/key-management";
|
||||
|
||||
@@ -240,7 +240,6 @@ export default class RuntimeBackground {
|
||||
await closeUnlockPopout();
|
||||
}
|
||||
|
||||
await this.notificationsService.updateConnection(msg.command === "loggedIn");
|
||||
this.processReloadSerivce.cancelProcessReload();
|
||||
|
||||
if (item) {
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
"unlimitedStorage",
|
||||
"webNavigation",
|
||||
"webRequest",
|
||||
"webRequestAuthProvider"
|
||||
"webRequestAuthProvider",
|
||||
"notifications"
|
||||
],
|
||||
"__safari__permissions": [
|
||||
"activeTab",
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
@@ -45,6 +44,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
import { clearCaches } from "@bitwarden/common/platform/misc/sequentialize";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -192,17 +192,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.recordActivity();
|
||||
// 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.notificationsService.updateConnection();
|
||||
// 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.updateAppMenu();
|
||||
this.processReloadService.cancelProcessReload();
|
||||
break;
|
||||
case "loggedOut":
|
||||
this.modalService.closeAll();
|
||||
if (message.userId == null || message.userId === this.activeUserId) {
|
||||
await this.notificationsService.updateConnection();
|
||||
}
|
||||
// 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.updateAppMenu();
|
||||
@@ -246,9 +240,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
) {
|
||||
await this.router.navigate(["lock"]);
|
||||
}
|
||||
// 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.notificationsService.updateConnection();
|
||||
await this.updateAppMenu();
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.processReloadService.startProcessReload(this.authService);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { firstValueFrom } from "rxjs";
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
@@ -13,6 +12,7 @@ import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platfor
|
||||
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/platform/sync";
|
||||
@@ -36,7 +36,7 @@ export class InitService {
|
||||
private i18nService: I18nServiceAbstraction,
|
||||
private eventUploadService: EventUploadServiceAbstraction,
|
||||
private twoFactorService: TwoFactorServiceAbstraction,
|
||||
private notificationsService: NotificationsServiceAbstraction,
|
||||
private notificationsService: NotificationsService,
|
||||
private platformUtilsService: PlatformUtilsServiceAbstraction,
|
||||
private stateService: StateServiceAbstraction,
|
||||
private keyService: KeyServiceAbstraction,
|
||||
@@ -78,7 +78,7 @@ export class InitService {
|
||||
await (this.i18nService as I18nRendererService).init();
|
||||
(this.eventUploadService as EventUploadService).init(true);
|
||||
this.twoFactorService.init();
|
||||
setTimeout(() => this.notificationsService.init(), 3000);
|
||||
this.notificationsService.startListening();
|
||||
const htmlEl = this.win.document.documentElement;
|
||||
htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString());
|
||||
this.themingService.applyThemeChangesTo(this.document);
|
||||
|
||||
@@ -7,8 +7,8 @@ import * as jq from "jquery";
|
||||
import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
@@ -19,11 +19,13 @@ import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-con
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -89,6 +91,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
private stateEventRunnerService: StateEventRunnerService,
|
||||
private organizationService: InternalOrganizationServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private appIdService: AppIdService,
|
||||
private processReloadService: ProcessReloadServiceAbstraction,
|
||||
) {}
|
||||
|
||||
@@ -117,24 +121,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.ngZone.run(async () => {
|
||||
switch (message.command) {
|
||||
case "loggedIn":
|
||||
// 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.notificationsService.updateConnection(false);
|
||||
break;
|
||||
case "loggedOut":
|
||||
if (
|
||||
message.userId == null ||
|
||||
message.userId === (await firstValueFrom(this.accountService.activeAccount$))
|
||||
) {
|
||||
await this.notificationsService.updateConnection(false);
|
||||
}
|
||||
break;
|
||||
case "unlocked":
|
||||
// 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.notificationsService.updateConnection(false);
|
||||
break;
|
||||
case "authBlocked":
|
||||
// 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
|
||||
@@ -148,10 +134,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
await this.vaultTimeoutService.lock();
|
||||
break;
|
||||
case "locked":
|
||||
// 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.notificationsService.updateConnection(false);
|
||||
|
||||
await this.processReloadService.startProcessReload(this.authService);
|
||||
break;
|
||||
case "lockedUrl":
|
||||
|
||||
@@ -69,6 +69,10 @@ import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sd
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import {
|
||||
UnsupportedWebPushConnectionService,
|
||||
WebPushConnectionService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
import { AppIdService as DefaultAppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
|
||||
// eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage
|
||||
@@ -245,6 +249,12 @@ const safeProviders: SafeProvider[] = [
|
||||
PolicyService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebPushConnectionService,
|
||||
// We can support web in the future by creating a worker
|
||||
useClass: UnsupportedWebPushConnectionService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LockComponentService,
|
||||
useClass: WebLockComponentService,
|
||||
|
||||
@@ -5,13 +5,13 @@ import { firstValueFrom } from "rxjs";
|
||||
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
|
||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
|
||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||
@@ -24,7 +24,7 @@ import { VersionService } from "../platform/version.service";
|
||||
export class InitService {
|
||||
constructor(
|
||||
@Inject(WINDOW) private win: Window,
|
||||
private notificationsService: NotificationsServiceAbstraction,
|
||||
private notificationsService: NotificationsService,
|
||||
private vaultTimeoutService: VaultTimeoutService,
|
||||
private i18nService: I18nServiceAbstraction,
|
||||
private eventUploadService: EventUploadServiceAbstraction,
|
||||
@@ -52,7 +52,7 @@ export class InitService {
|
||||
await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id);
|
||||
}
|
||||
|
||||
setTimeout(() => this.notificationsService.init(), 3000);
|
||||
this.notificationsService.startListening();
|
||||
await this.vaultTimeoutService.init(true);
|
||||
await this.i18nService.init();
|
||||
(this.eventUploadService as EventUploadService).init(true);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { concat, defer, fromEvent, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { SupportStatus } from "@bitwarden/common/platform/misc/support-status";
|
||||
import {
|
||||
WebPushConnector,
|
||||
WorkerWebPushConnectionService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export class PermissionsWebPushConnectionService extends WorkerWebPushConnectionService {
|
||||
override supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
|
||||
return this.notificationPermission$().pipe(
|
||||
switchMap((notificationPermission) => {
|
||||
if (notificationPermission === "denied") {
|
||||
return of<SupportStatus<WebPushConnector>>({
|
||||
type: "not-supported",
|
||||
reason: "permission-denied",
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationPermission === "default") {
|
||||
return of<SupportStatus<WebPushConnector>>({
|
||||
type: "needs-configuration",
|
||||
reason: "permission-not-requested",
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationPermission === "prompt") {
|
||||
return of<SupportStatus<WebPushConnector>>({
|
||||
type: "needs-configuration",
|
||||
reason: "prompt-must-be-granted",
|
||||
});
|
||||
}
|
||||
|
||||
// Delegate to default worker checks
|
||||
return super.supportStatus$(userId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private notificationPermission$() {
|
||||
return concat(
|
||||
of(Notification.permission),
|
||||
defer(async () => {
|
||||
return await window.navigator.permissions.query({ name: "notifications" });
|
||||
}).pipe(
|
||||
switchMap((permissionStatus) => {
|
||||
return fromEvent(permissionStatus, "change").pipe(map(() => permissionStatus.state));
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,6 @@ import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstracti
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "@bitwarden/common/abstractions/notifications.service";
|
||||
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
|
||||
import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
@@ -177,6 +176,16 @@ import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/inter
|
||||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import { NotificationsService } from "@bitwarden/common/platform/notifications";
|
||||
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
|
||||
import {
|
||||
DefaultNotificationsService,
|
||||
NoopNotificationsService,
|
||||
SignalRConnectionService,
|
||||
UnsupportedWebPushConnectionService,
|
||||
WebPushConnectionService,
|
||||
WebPushNotificationsApiService,
|
||||
} from "@bitwarden/common/platform/notifications/internal";
|
||||
import {
|
||||
TaskSchedulerService,
|
||||
DefaultTaskSchedulerService,
|
||||
@@ -194,7 +203,6 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
|
||||
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
||||
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
|
||||
import { NoopNotificationsService } from "@bitwarden/common/platform/services/noop-notifications.service";
|
||||
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
|
||||
import { StateService } from "@bitwarden/common/platform/services/state.service";
|
||||
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
|
||||
@@ -228,7 +236,6 @@ import { ApiService } from "@bitwarden/common/services/api.service";
|
||||
import { AuditService } from "@bitwarden/common/services/audit.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
|
||||
import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service";
|
||||
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
|
||||
import { SearchService } from "@bitwarden/common/services/search.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/services/vault-timeout/vault-timeout.service";
|
||||
@@ -879,19 +886,36 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [LogService, I18nServiceAbstraction, StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: NotificationsServiceAbstraction,
|
||||
useClass: devFlagEnabled("noopNotifications") ? NoopNotificationsService : NotificationsService,
|
||||
provide: WebPushNotificationsApiService,
|
||||
useClass: WebPushNotificationsApiService,
|
||||
deps: [ApiServiceAbstraction, AppIdServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SignalRConnectionService,
|
||||
useClass: SignalRConnectionService,
|
||||
deps: [ApiServiceAbstraction, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebPushConnectionService,
|
||||
useClass: UnsupportedWebPushConnectionService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: NotificationsService,
|
||||
useClass: devFlagEnabled("noopNotifications")
|
||||
? NoopNotificationsService
|
||||
: DefaultNotificationsService,
|
||||
deps: [
|
||||
LogService,
|
||||
SyncService,
|
||||
AppIdServiceAbstraction,
|
||||
ApiServiceAbstraction,
|
||||
EnvironmentService,
|
||||
LOGOUT_CALLBACK,
|
||||
StateServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
TaskSchedulerService,
|
||||
AccountServiceAbstraction,
|
||||
SignalRConnectionService,
|
||||
AuthServiceAbstraction,
|
||||
WebPushConnectionService,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
76
libs/common/spec/matrix.spec.ts
Normal file
76
libs/common/spec/matrix.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Matrix } from "./matrix";
|
||||
|
||||
class TestObject {
|
||||
value: number = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
increment() {
|
||||
this.value++;
|
||||
}
|
||||
}
|
||||
|
||||
describe("matrix", () => {
|
||||
it("caches entries in a matrix properly with a single argument", () => {
|
||||
const mockFunction = jest.fn<TestObject, [arg1: string]>();
|
||||
const getter = Matrix.autoMockMethod(mockFunction, () => new TestObject());
|
||||
|
||||
const obj = getter("test1");
|
||||
expect(obj.value).toBe(0);
|
||||
|
||||
// Change the state of the object
|
||||
obj.increment();
|
||||
|
||||
// Should return the same instance the second time this is called
|
||||
expect(getter("test1").value).toBe(1);
|
||||
|
||||
// Using the getter should not call the mock function
|
||||
expect(mockFunction).not.toHaveBeenCalled();
|
||||
|
||||
const mockedFunctionReturn1 = mockFunction("test1");
|
||||
expect(mockedFunctionReturn1.value).toBe(1);
|
||||
|
||||
// Totally new value
|
||||
const mockedFunctionReturn2 = mockFunction("test2");
|
||||
expect(mockedFunctionReturn2.value).toBe(0);
|
||||
|
||||
expect(mockFunction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches entries in matrix properly with multiple arguments", () => {
|
||||
const mockFunction = jest.fn<TestObject, [arg1: string, arg2: number]>();
|
||||
|
||||
const getter = Matrix.autoMockMethod(mockFunction, () => {
|
||||
return new TestObject();
|
||||
});
|
||||
|
||||
const obj = getter("test1", 4);
|
||||
expect(obj.value).toBe(0);
|
||||
|
||||
obj.increment();
|
||||
|
||||
expect(getter("test1", 4).value).toBe(1);
|
||||
|
||||
expect(mockFunction("test1", 3).value).toBe(0);
|
||||
});
|
||||
|
||||
it("should give original args in creator even if it has multiple key layers", () => {
|
||||
const mockFunction = jest.fn<TestObject, [arg1: string, arg2: number, arg3: boolean]>();
|
||||
|
||||
let invoked = false;
|
||||
|
||||
const getter = Matrix.autoMockMethod(mockFunction, (args) => {
|
||||
expect(args).toHaveLength(3);
|
||||
expect(args[0]).toBe("test");
|
||||
expect(args[1]).toBe(42);
|
||||
expect(args[2]).toBe(true);
|
||||
|
||||
invoked = true;
|
||||
|
||||
return new TestObject();
|
||||
});
|
||||
|
||||
getter("test", 42, true);
|
||||
expect(invoked).toBe(true);
|
||||
});
|
||||
});
|
||||
115
libs/common/spec/matrix.ts
Normal file
115
libs/common/spec/matrix.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
type PickFirst<Array> = Array extends [infer First, ...unknown[]] ? First : never;
|
||||
|
||||
type MatrixOrValue<Array extends unknown[], Value> = Array extends []
|
||||
? Value
|
||||
: Matrix<Array, Value>;
|
||||
|
||||
type RemoveFirst<T> = T extends [unknown, ...infer Rest] ? Rest : never;
|
||||
|
||||
/**
|
||||
* A matrix is intended to manage cached values for a set of method arguments.
|
||||
*/
|
||||
export class Matrix<TKeys extends unknown[], TValue> {
|
||||
private map: Map<PickFirst<TKeys>, MatrixOrValue<RemoveFirst<TKeys>, TValue>> = new Map();
|
||||
|
||||
/**
|
||||
* This is especially useful for methods on a service that take inputs but return Observables.
|
||||
* Generally when interacting with observables in tests, you want to use a simple SubjectLike
|
||||
* type to back it instead, so that you can easily `next` values to simulate an emission.
|
||||
*
|
||||
* @param mockFunction The function to have a Matrix based implementation added to it.
|
||||
* @param creator The function to use to create the underlying value to return for the given arguments.
|
||||
* @returns A "getter" function that allows you to retrieve the backing value that is used for the given arguments.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* interface MyService {
|
||||
* event$(userId: UserId) => Observable<UserEvent>
|
||||
* }
|
||||
*
|
||||
* // Test
|
||||
* const myService = mock<MyService>();
|
||||
* const eventGetter = Matrix.autoMockMethod(myService.event$, (userId) => BehaviorSubject<UserEvent>());
|
||||
*
|
||||
* eventGetter("userOne").next(new UserEvent());
|
||||
* eventGetter("userTwo").next(new UserEvent());
|
||||
* ```
|
||||
*
|
||||
* This replaces a more manual way of doing things like:
|
||||
*
|
||||
* ```ts
|
||||
* const myService = mock<MyService>();
|
||||
* const userOneSubject = new BehaviorSubject<UserEvent>();
|
||||
* const userTwoSubject = new BehaviorSubject<UserEvent>();
|
||||
* myService.event$.mockImplementation((userId) => {
|
||||
* if (userId === "userOne") {
|
||||
* return userOneSubject;
|
||||
* } else if (userId === "userTwo") {
|
||||
* return userTwoSubject;
|
||||
* }
|
||||
* return new BehaviorSubject<UserEvent>();
|
||||
* });
|
||||
*
|
||||
* userOneSubject.next(new UserEvent());
|
||||
* userTwoSubject.next(new UserEvent());
|
||||
* ```
|
||||
*/
|
||||
static autoMockMethod<TReturn, TArgs extends unknown[], TActualReturn extends TReturn>(
|
||||
mockFunction: jest.Mock<TReturn, TArgs>,
|
||||
creator: (args: TArgs) => TActualReturn,
|
||||
): (...args: TArgs) => TActualReturn {
|
||||
const matrix = new Matrix<TArgs, TActualReturn>();
|
||||
|
||||
const getter = (...args: TArgs) => {
|
||||
return matrix.getOrCreateEntry(args, creator);
|
||||
};
|
||||
|
||||
mockFunction.mockImplementation(getter);
|
||||
|
||||
return getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the ability to get or create an entry in the matrix via the given args.
|
||||
*
|
||||
* @note The args are evaulated using Javascript equality so primivites work best.
|
||||
*
|
||||
* @param args The arguments to use to evaluate if an entry in the matrix exists already,
|
||||
* or a value should be created and stored with those arguments.
|
||||
* @param creator The function to call with the arguments to build a value.
|
||||
* @returns The existing entry if one already exists or a new value created with the creator param.
|
||||
*/
|
||||
getOrCreateEntry(args: TKeys, creator: (args: TKeys) => TValue): TValue {
|
||||
if (args.length === 0) {
|
||||
throw new Error("Matrix is not for you.");
|
||||
}
|
||||
|
||||
if (args.length === 1) {
|
||||
const arg = args[0] as PickFirst<TKeys>;
|
||||
if (this.map.has(arg)) {
|
||||
// Get the cached value
|
||||
return this.map.get(arg) as TValue;
|
||||
} else {
|
||||
const value = creator(args);
|
||||
// Save the value for the next time
|
||||
this.map.set(arg, value as MatrixOrValue<RemoveFirst<TKeys>, TValue>);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// There are for sure 2 or more args
|
||||
const [first, ...rest] = args as unknown as [PickFirst<TKeys>, ...RemoveFirst<TKeys>];
|
||||
|
||||
let matrix: Matrix<RemoveFirst<TKeys>, TValue> | null = null;
|
||||
|
||||
if (this.map.has(first)) {
|
||||
// We've already created a map for this argument
|
||||
matrix = this.map.get(first) as Matrix<RemoveFirst<TKeys>, TValue>;
|
||||
} else {
|
||||
matrix = new Matrix<RemoveFirst<TKeys>, TValue>();
|
||||
this.map.set(first, matrix as MatrixOrValue<RemoveFirst<TKeys>, TValue>);
|
||||
}
|
||||
|
||||
return matrix.getOrCreateEntry(rest, () => creator(args));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
export abstract class NotificationsService {
|
||||
init: () => Promise<void>;
|
||||
updateConnection: (sync?: boolean) => Promise<void>;
|
||||
reconnectFromActivity: () => Promise<void>;
|
||||
disconnectFromInactivity: () => Promise<void>;
|
||||
}
|
||||
13
libs/common/src/enums/push-technology.enum.ts
Normal file
13
libs/common/src/enums/push-technology.enum.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The preferred push technology of the server.
|
||||
*/
|
||||
export enum PushTechnology {
|
||||
/**
|
||||
* Indicates that we should use SignalR over web sockets to receive push notifications from the server.
|
||||
*/
|
||||
SignalR = 0,
|
||||
/**
|
||||
* Indicatates that we should use WebPush to receive push notifications from the server.
|
||||
*/
|
||||
WebPush = 1,
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
|
||||
import { PushTechnology } from "../../../enums/push-technology.enum";
|
||||
import {
|
||||
ServerConfigData,
|
||||
ThirdPartyServerConfigData,
|
||||
@@ -10,6 +11,11 @@ import {
|
||||
} from "../../models/data/server-config.data";
|
||||
import { ServerSettings } from "../../models/domain/server-settings";
|
||||
|
||||
type PushConfig =
|
||||
| { pushTechnology: PushTechnology.SignalR }
|
||||
| { pushTechnology: PushTechnology.WebPush; vapidPublicKey: string }
|
||||
| undefined;
|
||||
|
||||
const dayInMilliseconds = 24 * 3600 * 1000;
|
||||
|
||||
export class ServerConfig {
|
||||
@@ -19,6 +25,7 @@ export class ServerConfig {
|
||||
environment?: EnvironmentServerConfigData;
|
||||
utcDate: Date;
|
||||
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
|
||||
push: PushConfig;
|
||||
settings: ServerSettings;
|
||||
|
||||
constructor(serverConfigData: ServerConfigData) {
|
||||
@@ -28,6 +35,15 @@ export class ServerConfig {
|
||||
this.utcDate = new Date(serverConfigData.utcDate);
|
||||
this.environment = serverConfigData.environment;
|
||||
this.featureStates = serverConfigData.featureStates;
|
||||
this.push =
|
||||
serverConfigData.push == null
|
||||
? {
|
||||
pushTechnology: PushTechnology.SignalR,
|
||||
}
|
||||
: {
|
||||
pushTechnology: serverConfigData.push.pushTechnology,
|
||||
vapidPublicKey: serverConfigData.push.vapidPublicKey,
|
||||
};
|
||||
this.settings = serverConfigData.settings;
|
||||
|
||||
if (this.server?.name == null && this.server?.url == null) {
|
||||
|
||||
48
libs/common/src/platform/misc/support-status.ts
Normal file
48
libs/common/src/platform/misc/support-status.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ObservableInput, OperatorFunction, switchMap } from "rxjs";
|
||||
|
||||
/**
|
||||
* Indicates that the given set of actions is not supported and there is
|
||||
* not anything the user can do to make it supported. The reason property
|
||||
* should contain a documented and machine readable string so more in
|
||||
* depth details can be shown to the user.
|
||||
*/
|
||||
export type NotSupported = { type: "not-supported"; reason: string };
|
||||
|
||||
/**
|
||||
* Indicates that the given set of actions does not currently work but
|
||||
* could be supported if configuration, either inside Bitwarden or outside,
|
||||
* is done. The reason property should contain a documented and
|
||||
* machine readable string so further instruction can be supplied to the caller.
|
||||
*/
|
||||
export type NeedsConfiguration = { type: "needs-configuration"; reason: string };
|
||||
|
||||
/**
|
||||
* Indicates that the actions in the service property are supported.
|
||||
*/
|
||||
export type Supported<T> = { type: "supported"; service: T };
|
||||
|
||||
/**
|
||||
* A type encapsulating the status of support for a service.
|
||||
*/
|
||||
export type SupportStatus<T> = Supported<T> | NeedsConfiguration | NotSupported;
|
||||
|
||||
/**
|
||||
* Projects each source value to one of the given projects defined in `selectors`.
|
||||
*
|
||||
* @param selectors.supported The function to run when the given item reports that it is supported
|
||||
* @param selectors.notSupported The function to run when the given item reports that it is either not-supported
|
||||
* or needs-configuration.
|
||||
* @returns A function that returns an Observable that emits the result of one of the given projection functions.
|
||||
*/
|
||||
export function supportSwitch<TService, TSupported, TNotSupported>(selectors: {
|
||||
supported: (service: TService, index: number) => ObservableInput<TSupported>;
|
||||
notSupported: (reason: string, index: number) => ObservableInput<TNotSupported>;
|
||||
}): OperatorFunction<SupportStatus<TService>, TSupported | TNotSupported> {
|
||||
return switchMap((supportStatus, index) => {
|
||||
if (supportStatus.type === "supported") {
|
||||
return selectors.supported(supportStatus.service, index);
|
||||
}
|
||||
|
||||
return selectors.notSupported(supportStatus.reason, index);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PushTechnology } from "../../../enums/push-technology.enum";
|
||||
import { Region } from "../../abstractions/environment.service";
|
||||
|
||||
import {
|
||||
@@ -29,6 +30,9 @@ describe("ServerConfigData", () => {
|
||||
},
|
||||
utcDate: "2020-01-01T00:00:00.000Z",
|
||||
featureStates: { feature: "state" },
|
||||
push: {
|
||||
pushTechnology: PushTechnology.SignalR,
|
||||
},
|
||||
};
|
||||
const serverConfigData = ServerConfigData.fromJSON(json);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ServerConfigResponse,
|
||||
ThirdPartyServerConfigResponse,
|
||||
EnvironmentServerConfigResponse,
|
||||
PushSettingsConfigResponse,
|
||||
} from "../response/server-config.response";
|
||||
|
||||
export class ServerConfigData {
|
||||
@@ -18,6 +19,7 @@ export class ServerConfigData {
|
||||
environment?: EnvironmentServerConfigData;
|
||||
utcDate: string;
|
||||
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
|
||||
push: PushSettingsConfigData;
|
||||
settings: ServerSettings;
|
||||
|
||||
constructor(serverConfigResponse: Partial<ServerConfigResponse>) {
|
||||
@@ -32,6 +34,9 @@ export class ServerConfigData {
|
||||
: null;
|
||||
this.featureStates = serverConfigResponse?.featureStates;
|
||||
this.settings = new ServerSettings(serverConfigResponse.settings);
|
||||
this.push = serverConfigResponse?.push
|
||||
? new PushSettingsConfigData(serverConfigResponse.push)
|
||||
: null;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<ServerConfigData>): ServerConfigData {
|
||||
@@ -42,6 +47,20 @@ export class ServerConfigData {
|
||||
}
|
||||
}
|
||||
|
||||
export class PushSettingsConfigData {
|
||||
pushTechnology: number;
|
||||
vapidPublicKey?: string;
|
||||
|
||||
constructor(response: Partial<PushSettingsConfigResponse>) {
|
||||
this.pushTechnology = response.pushTechnology;
|
||||
this.vapidPublicKey = response.vapidPublicKey;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<PushSettingsConfigData>): PushSettingsConfigData {
|
||||
return Object.assign(new PushSettingsConfigData({}), obj);
|
||||
}
|
||||
}
|
||||
|
||||
export class ThirdPartyServerConfigData {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ export class ServerConfigResponse extends BaseResponse {
|
||||
server: ThirdPartyServerConfigResponse;
|
||||
environment: EnvironmentServerConfigResponse;
|
||||
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
|
||||
push: PushSettingsConfigResponse;
|
||||
settings: ServerSettings;
|
||||
|
||||
constructor(response: any) {
|
||||
@@ -25,10 +26,27 @@ export class ServerConfigResponse extends BaseResponse {
|
||||
this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server"));
|
||||
this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
|
||||
this.featureStates = this.getResponseProperty("FeatureStates");
|
||||
this.push = new PushSettingsConfigResponse(this.getResponseProperty("Push"));
|
||||
this.settings = new ServerSettings(this.getResponseProperty("Settings"));
|
||||
}
|
||||
}
|
||||
|
||||
export class PushSettingsConfigResponse extends BaseResponse {
|
||||
pushTechnology: number;
|
||||
vapidPublicKey: string;
|
||||
|
||||
constructor(data: any = null) {
|
||||
super(data);
|
||||
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushTechnology = this.getResponseProperty("PushTechnology");
|
||||
this.vapidPublicKey = this.getResponseProperty("VapidPublicKey");
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvironmentServerConfigResponse extends BaseResponse {
|
||||
cloudRegion: Region;
|
||||
vault: string;
|
||||
|
||||
1
libs/common/src/platform/notifications/index.ts
Normal file
1
libs/common/src/platform/notifications/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NotificationsService } from "./notifications.service";
|
||||
@@ -0,0 +1,316 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { awaitAsync } from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { NotificationType } from "../../../enums";
|
||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { AppIdService } from "../../abstractions/app-id.service";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { MessageSender } from "../../messaging";
|
||||
import { SupportStatus } from "../../misc/support-status";
|
||||
import { SyncService } from "../../sync";
|
||||
|
||||
import {
|
||||
DefaultNotificationsService,
|
||||
DISABLED_NOTIFICATIONS_URL,
|
||||
} from "./default-notifications.service";
|
||||
import { SignalRNotification, SignalRConnectionService } from "./signalr-connection.service";
|
||||
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
|
||||
import { WorkerWebPushConnectionService } from "./worker-webpush-connection.service";
|
||||
|
||||
describe("NotificationsService", () => {
|
||||
let syncService: MockProxy<SyncService>;
|
||||
let appIdService: MockProxy<AppIdService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason]>;
|
||||
let messagingService: MockProxy<MessageSender>;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
|
||||
|
||||
let activeAccount: BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>;
|
||||
|
||||
let environment: BehaviorSubject<ObservedValueOf<EnvironmentService["environment$"]>>;
|
||||
|
||||
let authStatusGetter: (userId: UserId) => BehaviorSubject<AuthenticationStatus>;
|
||||
|
||||
let webPushSupportGetter: (userId: UserId) => BehaviorSubject<SupportStatus<WebPushConnector>>;
|
||||
|
||||
let signalrNotificationGetter: (
|
||||
userId: UserId,
|
||||
notificationsUrl: string,
|
||||
) => Subject<SignalRNotification>;
|
||||
|
||||
let sut: DefaultNotificationsService;
|
||||
|
||||
beforeEach(() => {
|
||||
syncService = mock<SyncService>();
|
||||
appIdService = mock<AppIdService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
logoutCallback = jest.fn<Promise<void>, [logoutReason: LogoutReason]>();
|
||||
messagingService = mock<MessageSender>();
|
||||
accountService = mock<AccountService>();
|
||||
signalRNotificationConnectionService = mock<SignalRConnectionService>();
|
||||
authService = mock<AuthService>();
|
||||
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
|
||||
|
||||
activeAccount = new BehaviorSubject<ObservedValueOf<AccountService["activeAccount$"]>>(null);
|
||||
accountService.activeAccount$ = activeAccount.asObservable();
|
||||
|
||||
environment = new BehaviorSubject<ObservedValueOf<EnvironmentService["environment$"]>>({
|
||||
getNotificationsUrl: () => "https://notifications.bitwarden.com",
|
||||
} as Environment);
|
||||
|
||||
environmentService.environment$ = environment;
|
||||
|
||||
authStatusGetter = Matrix.autoMockMethod(
|
||||
authService.authStatusFor$,
|
||||
() => new BehaviorSubject<AuthenticationStatus>(AuthenticationStatus.LoggedOut),
|
||||
);
|
||||
|
||||
webPushSupportGetter = Matrix.autoMockMethod(
|
||||
webPushNotificationConnectionService.supportStatus$,
|
||||
() =>
|
||||
new BehaviorSubject<SupportStatus<WebPushConnector>>({
|
||||
type: "not-supported",
|
||||
reason: "test",
|
||||
}),
|
||||
);
|
||||
|
||||
signalrNotificationGetter = Matrix.autoMockMethod(
|
||||
signalRNotificationConnectionService.connect$,
|
||||
() => new Subject<SignalRNotification>(),
|
||||
);
|
||||
|
||||
sut = new DefaultNotificationsService(
|
||||
syncService,
|
||||
appIdService,
|
||||
environmentService,
|
||||
logoutCallback,
|
||||
messagingService,
|
||||
accountService,
|
||||
signalRNotificationConnectionService,
|
||||
authService,
|
||||
webPushNotificationConnectionService,
|
||||
mock<LogService>(),
|
||||
);
|
||||
});
|
||||
|
||||
const mockUser1 = "user1" as UserId;
|
||||
const mockUser2 = "user2" as UserId;
|
||||
|
||||
function emitActiveUser(userId: UserId) {
|
||||
if (userId == null) {
|
||||
activeAccount.next(null);
|
||||
} else {
|
||||
activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true });
|
||||
}
|
||||
}
|
||||
|
||||
function emitNotificationUrl(url: string) {
|
||||
environment.next({
|
||||
getNotificationsUrl: () => url,
|
||||
} as Environment);
|
||||
}
|
||||
|
||||
const expectNotification = (
|
||||
notification: readonly [NotificationResponse, UserId],
|
||||
expectedUser: UserId,
|
||||
expectedType: NotificationType,
|
||||
) => {
|
||||
const [actualNotification, actualUser] = notification;
|
||||
expect(actualUser).toBe(expectedUser);
|
||||
expect(actualNotification.type).toBe(expectedType);
|
||||
};
|
||||
|
||||
it("emits notifications through WebPush when supported", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
|
||||
const webPush = mock<WebPushConnector>();
|
||||
const webPushSubject = new Subject<NotificationResponse>();
|
||||
webPush.notifications$ = webPushSubject;
|
||||
|
||||
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate }));
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderDelete }));
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate);
|
||||
expectNotification(notifications[1], mockUser1, NotificationType.SyncFolderDelete);
|
||||
});
|
||||
|
||||
it("switches to SignalR when web push is not supported.", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
|
||||
const webPush = mock<WebPushConnector>();
|
||||
const webPushSubject = new Subject<NotificationResponse>();
|
||||
webPush.notifications$ = webPushSubject;
|
||||
|
||||
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncFolderCreate }));
|
||||
|
||||
emitActiveUser(mockUser2);
|
||||
authStatusGetter(mockUser2).next(AuthenticationStatus.Unlocked);
|
||||
// Second user does not support web push
|
||||
webPushSupportGetter(mockUser2).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
signalrNotificationGetter(mockUser2, "http://test.example.com").next({
|
||||
type: "ReceiveMessage",
|
||||
message: new NotificationResponse({ type: NotificationType.SyncCipherUpdate }),
|
||||
});
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.SyncFolderCreate);
|
||||
expectNotification(notifications[1], mockUser2, NotificationType.SyncCipherUpdate);
|
||||
});
|
||||
|
||||
it("switches to WebPush when it becomes supported.", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(2)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
signalrNotificationGetter(mockUser1, "http://test.example.com").next({
|
||||
type: "ReceiveMessage",
|
||||
message: new NotificationResponse({ type: NotificationType.AuthRequest }),
|
||||
});
|
||||
|
||||
const webPush = mock<WebPushConnector>();
|
||||
const webPushSubject = new Subject<NotificationResponse>();
|
||||
webPush.notifications$ = webPushSubject;
|
||||
|
||||
webPushSupportGetter(mockUser1).next({ type: "supported", service: webPush });
|
||||
webPushSubject.next(new NotificationResponse({ type: NotificationType.SyncLoginDelete }));
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.AuthRequest);
|
||||
expectNotification(notifications[1], mockUser1, NotificationType.SyncLoginDelete);
|
||||
});
|
||||
|
||||
it("does not emit SignalR heartbeats", async () => {
|
||||
const notificationsPromise = firstValueFrom(sut.notifications$.pipe(bufferCount(1)));
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
signalrNotificationGetter(mockUser1, "http://test.example.com").next({ type: "Heartbeat" });
|
||||
signalrNotificationGetter(mockUser1, "http://test.example.com").next({
|
||||
type: "ReceiveMessage",
|
||||
message: new NotificationResponse({ type: NotificationType.AuthRequestResponse }),
|
||||
});
|
||||
|
||||
const notifications = await notificationsPromise;
|
||||
expectNotification(notifications[0], mockUser1, NotificationType.AuthRequestResponse);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Locked },
|
||||
{ initialStatus: AuthenticationStatus.Locked, updatedStatus: AuthenticationStatus.Locked },
|
||||
{ initialStatus: AuthenticationStatus.Unlocked, updatedStatus: AuthenticationStatus.Unlocked },
|
||||
])(
|
||||
"does not re-connect when the user transitions from $initialStatus to $updatedStatus",
|
||||
async ({ initialStatus, updatedStatus }) => {
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(initialStatus);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
const notificationsSubscriptions = sut.notifications$.subscribe();
|
||||
await awaitAsync(1);
|
||||
|
||||
authStatusGetter(mockUser1).next(updatedStatus);
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1);
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith(
|
||||
mockUser1,
|
||||
"http://test.example.com",
|
||||
);
|
||||
notificationsSubscriptions.unsubscribe();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked])(
|
||||
"connects when a user transitions from logged out to %s",
|
||||
async (newStatus: AuthenticationStatus) => {
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.LoggedOut);
|
||||
webPushSupportGetter(mockUser1).next({ type: "not-supported", reason: "test" });
|
||||
|
||||
const notificationsSubscriptions = sut.notifications$.subscribe();
|
||||
await awaitAsync(1);
|
||||
|
||||
authStatusGetter(mockUser1).next(newStatus);
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledTimes(1);
|
||||
expect(signalRNotificationConnectionService.connect$).toHaveBeenCalledWith(
|
||||
mockUser1,
|
||||
"http://test.example.com",
|
||||
);
|
||||
notificationsSubscriptions.unsubscribe();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not connect to any notification stream when notifications are disabled through special url", () => {
|
||||
const subscription = sut.notifications$.subscribe();
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl(DISABLED_NOTIFICATIONS_URL);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled();
|
||||
expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("does not connect to any notification stream when there is no active user", () => {
|
||||
const subscription = sut.notifications$.subscribe();
|
||||
emitActiveUser(null);
|
||||
|
||||
expect(signalRNotificationConnectionService.connect$).not.toHaveBeenCalled();
|
||||
expect(webPushNotificationConnectionService.supportStatus$).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it("does not reconnect if the same notification url is emitted", async () => {
|
||||
const subscription = sut.notifications$.subscribe();
|
||||
|
||||
emitActiveUser(mockUser1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
authStatusGetter(mockUser1).next(AuthenticationStatus.Unlocked);
|
||||
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1);
|
||||
emitNotificationUrl("http://test.example.com");
|
||||
|
||||
await awaitAsync(1);
|
||||
|
||||
expect(webPushNotificationConnectionService.supportStatus$).toHaveBeenCalledTimes(1);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
distinctUntilChanged,
|
||||
EMPTY,
|
||||
filter,
|
||||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { NotificationType } from "../../../enums";
|
||||
import {
|
||||
NotificationResponse,
|
||||
SyncCipherNotification,
|
||||
SyncFolderNotification,
|
||||
SyncSendNotification,
|
||||
} from "../../../models/response/notification.response";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||
import { AppIdService } from "../../abstractions/app-id.service";
|
||||
import { EnvironmentService } from "../../abstractions/environment.service";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { MessagingService } from "../../abstractions/messaging.service";
|
||||
import { supportSwitch } from "../../misc/support-status";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "../notifications.service";
|
||||
|
||||
import { ReceiveMessage, SignalRConnectionService } from "./signalr-connection.service";
|
||||
import { WebPushConnectionService } from "./webpush-connection.service";
|
||||
|
||||
export const DISABLED_NOTIFICATIONS_URL = "http://-";
|
||||
|
||||
export class DefaultNotificationsService implements NotificationsServiceAbstraction {
|
||||
notifications$: Observable<readonly [NotificationResponse, UserId]>;
|
||||
|
||||
private activitySubject = new BehaviorSubject<"active" | "inactive">("active");
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
private appIdService: AppIdService,
|
||||
private environmentService: EnvironmentService,
|
||||
private logoutCallback: (logoutReason: LogoutReason, userId: UserId) => Promise<void>,
|
||||
private messagingService: MessagingService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly signalRConnectionService: SignalRConnectionService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly webPushConnectionService: WebPushConnectionService,
|
||||
private readonly logService: LogService,
|
||||
) {
|
||||
this.notifications$ = this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
distinctUntilChanged(),
|
||||
switchMap((activeAccountId) => {
|
||||
if (activeAccountId == null) {
|
||||
// We don't emit notifications for inactive accounts currently
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.userNotifications$(activeAccountId).pipe(
|
||||
map((notification) => [notification, activeAccountId] as const),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a stream of push notifications for the given user.
|
||||
* @param userId The user id of the user to get the push notifications for.
|
||||
*/
|
||||
private userNotifications$(userId: UserId) {
|
||||
return this.environmentService.environment$.pipe(
|
||||
map((env) => env.getNotificationsUrl()),
|
||||
distinctUntilChanged(),
|
||||
switchMap((notificationsUrl) => {
|
||||
if (notificationsUrl === DISABLED_NOTIFICATIONS_URL) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.userNotificationsHelper$(userId, notificationsUrl);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private userNotificationsHelper$(userId: UserId, notificationsUrl: string) {
|
||||
return this.hasAccessToken$(userId).pipe(
|
||||
switchMap((hasAccessToken) => {
|
||||
if (!hasAccessToken) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.activitySubject;
|
||||
}),
|
||||
switchMap((activityStatus) => {
|
||||
if (activityStatus === "inactive") {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return this.webPushConnectionService.supportStatus$(userId);
|
||||
}),
|
||||
supportSwitch({
|
||||
supported: (service) =>
|
||||
service.notifications$.pipe(
|
||||
catchError((err: unknown) => {
|
||||
this.logService.warning("Issue with web push, falling back to SignalR", err);
|
||||
return this.connectSignalR$(userId, notificationsUrl);
|
||||
}),
|
||||
),
|
||||
notSupported: () => this.connectSignalR$(userId, notificationsUrl),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private connectSignalR$(userId: UserId, notificationsUrl: string) {
|
||||
return this.signalRConnectionService.connect$(userId, notificationsUrl).pipe(
|
||||
filter((n) => n.type === "ReceiveMessage"),
|
||||
map((n) => (n as ReceiveMessage).message),
|
||||
);
|
||||
}
|
||||
|
||||
private hasAccessToken$(userId: UserId) {
|
||||
return this.authService.authStatusFor$(userId).pipe(
|
||||
map(
|
||||
(authStatus) =>
|
||||
authStatus === AuthenticationStatus.Locked ||
|
||||
authStatus === AuthenticationStatus.Unlocked,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
}
|
||||
|
||||
private async processNotification(notification: NotificationResponse, userId: UserId) {
|
||||
const appId = await this.appIdService.getAppId();
|
||||
if (notification == null || notification.contextId === appId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadUserId = notification.payload?.userId || notification.payload?.UserId;
|
||||
if (payloadUserId != null && payloadUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.SyncCipherCreate:
|
||||
case NotificationType.SyncCipherUpdate:
|
||||
await this.syncService.syncUpsertCipher(
|
||||
notification.payload as SyncCipherNotification,
|
||||
notification.type === NotificationType.SyncCipherUpdate,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncCipherDelete:
|
||||
case NotificationType.SyncLoginDelete:
|
||||
await this.syncService.syncDeleteCipher(notification.payload as SyncCipherNotification);
|
||||
break;
|
||||
case NotificationType.SyncFolderCreate:
|
||||
case NotificationType.SyncFolderUpdate:
|
||||
await this.syncService.syncUpsertFolder(
|
||||
notification.payload as SyncFolderNotification,
|
||||
notification.type === NotificationType.SyncFolderUpdate,
|
||||
userId,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncFolderDelete:
|
||||
await this.syncService.syncDeleteFolder(
|
||||
notification.payload as SyncFolderNotification,
|
||||
userId,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncVault:
|
||||
case NotificationType.SyncCiphers:
|
||||
case NotificationType.SyncSettings:
|
||||
await this.syncService.fullSync(false);
|
||||
|
||||
break;
|
||||
case NotificationType.SyncOrganizations:
|
||||
// An organization update may not have bumped the user's account revision date, so force a sync
|
||||
await this.syncService.fullSync(true);
|
||||
break;
|
||||
case NotificationType.SyncOrgKeys:
|
||||
await this.syncService.fullSync(true);
|
||||
this.activitySubject.next("inactive"); // Force a disconnect
|
||||
this.activitySubject.next("active"); // Allow a reconnect
|
||||
break;
|
||||
case NotificationType.LogOut:
|
||||
this.logService.info("[Notifications Service] Received logout notification");
|
||||
await this.logoutCallback("logoutNotification", userId);
|
||||
break;
|
||||
case NotificationType.SyncSendCreate:
|
||||
case NotificationType.SyncSendUpdate:
|
||||
await this.syncService.syncUpsertSend(
|
||||
notification.payload as SyncSendNotification,
|
||||
notification.type === NotificationType.SyncSendUpdate,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncSendDelete:
|
||||
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
|
||||
break;
|
||||
case NotificationType.AuthRequest:
|
||||
{
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case NotificationType.SyncOrganizationStatusChanged:
|
||||
await this.syncService.fullSync(true);
|
||||
break;
|
||||
case NotificationType.SyncOrganizationCollectionSettingChanged:
|
||||
await this.syncService.fullSync(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
startListening() {
|
||||
return this.notifications$
|
||||
.pipe(
|
||||
mergeMap(async ([notification, userId]) => this.processNotification(notification, userId)),
|
||||
)
|
||||
.subscribe({
|
||||
error: (e: unknown) => this.logService.warning("Error in notifications$ observable", e),
|
||||
});
|
||||
}
|
||||
|
||||
reconnectFromActivity(): void {
|
||||
this.activitySubject.next("active");
|
||||
}
|
||||
|
||||
disconnectFromInactivity(): void {
|
||||
this.activitySubject.next("inactive");
|
||||
}
|
||||
}
|
||||
8
libs/common/src/platform/notifications/internal/index.ts
Normal file
8
libs/common/src/platform/notifications/internal/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./worker-webpush-connection.service";
|
||||
export * from "./signalr-connection.service";
|
||||
export * from "./default-notifications.service";
|
||||
export * from "./noop-notifications.service";
|
||||
export * from "./unsupported-webpush-connection.service";
|
||||
export * from "./webpush-connection.service";
|
||||
export * from "./websocket-webpush-connection.service";
|
||||
export * from "./web-push-notifications-api.service";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Subscription } from "rxjs";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { NotificationsService } from "../notifications.service";
|
||||
|
||||
export class NoopNotificationsService implements NotificationsService {
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
startListening(): Subscription {
|
||||
this.logService.info(
|
||||
"Initializing no-op notification service, no push notifications will be received",
|
||||
);
|
||||
return Subscription.EMPTY;
|
||||
}
|
||||
|
||||
reconnectFromActivity(): void {
|
||||
this.logService.info("Reconnecting notification service from activity");
|
||||
}
|
||||
|
||||
disconnectFromInactivity(): void {
|
||||
this.logService.info("Disconnecting notification service from inactivity");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
HttpTransportType,
|
||||
HubConnectionBuilder,
|
||||
HubConnectionState,
|
||||
ILogger,
|
||||
LogLevel,
|
||||
} from "@microsoft/signalr";
|
||||
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
|
||||
// 2 Minutes
|
||||
const MIN_RECONNECT_TIME = 2 * 60 * 1000;
|
||||
// 5 Minutes
|
||||
const MAX_RECONNECT_TIME = 5 * 60 * 1000;
|
||||
|
||||
export type Heartbeat = { type: "Heartbeat" };
|
||||
export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResponse };
|
||||
|
||||
export type SignalRNotification = Heartbeat | ReceiveMessage;
|
||||
|
||||
class SignalRLogger implements ILogger {
|
||||
constructor(private readonly logService: LogService) {}
|
||||
|
||||
log(logLevel: LogLevel, message: string): void {
|
||||
switch (logLevel) {
|
||||
case LogLevel.Critical:
|
||||
this.logService.error(message);
|
||||
break;
|
||||
case LogLevel.Error:
|
||||
this.logService.error(message);
|
||||
break;
|
||||
case LogLevel.Warning:
|
||||
this.logService.warning(message);
|
||||
break;
|
||||
case LogLevel.Information:
|
||||
this.logService.info(message);
|
||||
break;
|
||||
case LogLevel.Debug:
|
||||
this.logService.debug(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SignalRConnectionService {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
connect$(userId: UserId, notificationsUrl: string) {
|
||||
return new Observable<SignalRNotification>((subsciber) => {
|
||||
const connection = new HubConnectionBuilder()
|
||||
.withUrl(notificationsUrl + "/hub", {
|
||||
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
|
||||
skipNegotiation: true,
|
||||
transport: HttpTransportType.WebSockets,
|
||||
})
|
||||
.withHubProtocol(new MessagePackHubProtocol())
|
||||
.configureLogging(new SignalRLogger(this.logService))
|
||||
.build();
|
||||
|
||||
connection.on("ReceiveMessage", (data: any) => {
|
||||
subsciber.next({ type: "ReceiveMessage", message: new NotificationResponse(data) });
|
||||
});
|
||||
|
||||
connection.on("Heartbeat", () => {
|
||||
subsciber.next({ type: "Heartbeat" });
|
||||
});
|
||||
|
||||
let reconnectSubscription: Subscription | null = null;
|
||||
|
||||
// Create schedule reconnect function
|
||||
const scheduleReconnect = (): Subscription => {
|
||||
if (
|
||||
connection == null ||
|
||||
connection.state !== HubConnectionState.Disconnected ||
|
||||
(reconnectSubscription != null && !reconnectSubscription.closed)
|
||||
) {
|
||||
return Subscription.EMPTY;
|
||||
}
|
||||
|
||||
const randomTime = this.random();
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
connection
|
||||
.start()
|
||||
.then(() => (reconnectSubscription = null))
|
||||
.catch(() => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
});
|
||||
}, randomTime);
|
||||
|
||||
return new Subscription(() => clearTimeout(timeoutHandler));
|
||||
};
|
||||
|
||||
connection.onclose((error) => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
});
|
||||
|
||||
// Start connection
|
||||
connection.start().catch(() => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
});
|
||||
|
||||
return () => {
|
||||
connection?.stop().catch((error) => {
|
||||
this.logService.error("Error while stopping SignalR connection", error);
|
||||
// TODO: Does calling stop call `onclose`?
|
||||
reconnectSubscription?.unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private random() {
|
||||
return (
|
||||
Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { SupportStatus } from "../../misc/support-status";
|
||||
|
||||
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
|
||||
|
||||
/**
|
||||
* An implementation of {@see WebPushConnectionService} for clients that do not have support for WebPush
|
||||
*/
|
||||
export class UnsupportedWebPushConnectionService implements WebPushConnectionService {
|
||||
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
|
||||
return of({ type: "not-supported", reason: "client-not-supported" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AppIdService } from "../../abstractions/app-id.service";
|
||||
|
||||
import { WebPushRequest } from "./web-push.request";
|
||||
|
||||
export class WebPushNotificationsApiService {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly appIdService: AppIdService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Posts a device-user association to the server and ensures it's installed for push notifications
|
||||
*/
|
||||
async putSubscription(pushSubscription: PushSubscriptionJSON): Promise<void> {
|
||||
const request = WebPushRequest.from(pushSubscription);
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
`/devices/identifier/${await this.appIdService.getAppId()}/web-push-auth`,
|
||||
request,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class WebPushRequest {
|
||||
endpoint: string | undefined;
|
||||
p256dh: string | undefined;
|
||||
auth: string | undefined;
|
||||
|
||||
static from(pushSubscription: PushSubscriptionJSON): WebPushRequest {
|
||||
const result = new WebPushRequest();
|
||||
result.endpoint = pushSubscription.endpoint;
|
||||
result.p256dh = pushSubscription.keys?.p256dh;
|
||||
result.auth = pushSubscription.keys?.auth;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { SupportStatus } from "../../misc/support-status";
|
||||
|
||||
export interface WebPushConnector {
|
||||
notifications$: Observable<NotificationResponse>;
|
||||
}
|
||||
|
||||
export abstract class WebPushConnectionService {
|
||||
abstract supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Observable, of } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { SupportStatus } from "../../misc/support-status";
|
||||
|
||||
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
|
||||
|
||||
export class WebSocketWebPushConnectionService implements WebPushConnectionService {
|
||||
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
|
||||
return of({ type: "not-supported", reason: "work-in-progress" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
concat,
|
||||
concatMap,
|
||||
defer,
|
||||
distinctUntilChanged,
|
||||
fromEvent,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PushTechnology } from "../../../enums/push-technology.enum";
|
||||
import { NotificationResponse } from "../../../models/response/notification.response";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { SupportStatus } from "../../misc/support-status";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { WebPushNotificationsApiService } from "./web-push-notifications-api.service";
|
||||
import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service";
|
||||
|
||||
// Ref: https://w3c.github.io/push-api/#the-pushsubscriptionchange-event
|
||||
interface PushSubscriptionChangeEvent {
|
||||
readonly newSubscription?: PushSubscription;
|
||||
readonly oldSubscription?: PushSubscription;
|
||||
}
|
||||
|
||||
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData
|
||||
interface PushMessageData {
|
||||
json(): any;
|
||||
}
|
||||
|
||||
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/PushEvent
|
||||
interface PushEvent {
|
||||
data: PushMessageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation for connecting to web push based notifications running in a Worker.
|
||||
*/
|
||||
export class WorkerWebPushConnectionService implements WebPushConnectionService {
|
||||
private pushEvent = new Subject<PushEvent>();
|
||||
private pushChangeEvent = new Subject<PushSubscriptionChangeEvent>();
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly webPushApiService: WebPushNotificationsApiService,
|
||||
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
|
||||
) {}
|
||||
|
||||
start(): Subscription {
|
||||
const subscription = new Subscription(() => {
|
||||
this.pushEvent.complete();
|
||||
this.pushChangeEvent.complete();
|
||||
this.pushEvent = new Subject<PushEvent>();
|
||||
this.pushChangeEvent = new Subject<PushSubscriptionChangeEvent>();
|
||||
});
|
||||
|
||||
const pushEventSubscription = fromEvent<PushEvent>(self, "push").subscribe(this.pushEvent);
|
||||
|
||||
const pushChangeEventSubscription = fromEvent<PushSubscriptionChangeEvent>(
|
||||
self,
|
||||
"pushsubscriptionchange",
|
||||
).subscribe(this.pushChangeEvent);
|
||||
|
||||
subscription.add(pushEventSubscription);
|
||||
subscription.add(pushChangeEventSubscription);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
supportStatus$(userId: UserId): Observable<SupportStatus<WebPushConnector>> {
|
||||
// Check the server config to see if it supports sending WebPush notifications
|
||||
// FIXME: get config of server for the specified userId, once ConfigService supports it
|
||||
return this.configService.serverConfig$.pipe(
|
||||
map((config) =>
|
||||
config?.push?.pushTechnology === PushTechnology.WebPush ? config.push.vapidPublicKey : null,
|
||||
),
|
||||
// No need to re-emit when there is new server config if the vapidPublicKey is still there and the exact same
|
||||
distinctUntilChanged(),
|
||||
map((publicKey) => {
|
||||
if (publicKey == null) {
|
||||
return {
|
||||
type: "not-supported",
|
||||
reason: "server-not-configured",
|
||||
} satisfies SupportStatus<WebPushConnector>;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "supported",
|
||||
service: new MyWebPushConnector(
|
||||
publicKey,
|
||||
userId,
|
||||
this.webPushApiService,
|
||||
this.serviceWorkerRegistration,
|
||||
this.pushEvent,
|
||||
this.pushChangeEvent,
|
||||
),
|
||||
} satisfies SupportStatus<WebPushConnector>;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyWebPushConnector implements WebPushConnector {
|
||||
notifications$: Observable<NotificationResponse>;
|
||||
|
||||
constructor(
|
||||
private readonly vapidPublicKey: string,
|
||||
private readonly userId: UserId,
|
||||
private readonly webPushApiService: WebPushNotificationsApiService,
|
||||
private readonly serviceWorkerRegistration: ServiceWorkerRegistration,
|
||||
private readonly pushEvent$: Observable<PushEvent>,
|
||||
private readonly pushChangeEvent$: Observable<PushSubscriptionChangeEvent>,
|
||||
) {
|
||||
this.notifications$ = this.getOrCreateSubscription$(this.vapidPublicKey).pipe(
|
||||
concatMap((subscription) => {
|
||||
return defer(() => {
|
||||
if (subscription == null) {
|
||||
throw new Error("Expected a non-null subscription.");
|
||||
}
|
||||
return this.webPushApiService.putSubscription(subscription.toJSON());
|
||||
}).pipe(
|
||||
switchMap(() => this.pushEvent$),
|
||||
map((e) => new NotificationResponse(e.data.json().data)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async pushManagerSubscribe(key: string) {
|
||||
return await this.serviceWorkerRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: key,
|
||||
});
|
||||
}
|
||||
|
||||
private getOrCreateSubscription$(key: string) {
|
||||
return concat(
|
||||
defer(async () => {
|
||||
const existingSubscription =
|
||||
await this.serviceWorkerRegistration.pushManager.getSubscription();
|
||||
|
||||
if (existingSubscription == null) {
|
||||
return await this.pushManagerSubscribe(key);
|
||||
}
|
||||
|
||||
const subscriptionKey = Utils.fromBufferToUrlB64(
|
||||
// REASON: `Utils.fromBufferToUrlB64` handles null by returning null back to it.
|
||||
// its annotation should be updated and then this assertion can be removed.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
existingSubscription.options?.applicationServerKey!,
|
||||
);
|
||||
|
||||
if (subscriptionKey !== key) {
|
||||
// There is a subscription, but it's not for the current server, unsubscribe and then make a new one
|
||||
await existingSubscription.unsubscribe();
|
||||
return await this.pushManagerSubscribe(key);
|
||||
}
|
||||
|
||||
return existingSubscription;
|
||||
}),
|
||||
this.pushChangeEvent$.pipe(map((event) => event.newSubscription)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Subscription } from "rxjs";
|
||||
|
||||
/**
|
||||
* A service offering abilities to interact with push notifications from the server.
|
||||
*/
|
||||
export abstract class NotificationsService {
|
||||
/**
|
||||
* Starts automatic listening and processing of notifications, should only be called once per application,
|
||||
* or you will risk notifications being processed multiple times.
|
||||
*/
|
||||
abstract startListening(): Subscription;
|
||||
// TODO: Delete this method in favor of an `ActivityService` that notifications can depend on.
|
||||
// https://bitwarden.atlassian.net/browse/PM-14264
|
||||
abstract reconnectFromActivity(): void;
|
||||
// TODO: Delete this method in favor of an `ActivityService` that notifications can depend on.
|
||||
// https://bitwarden.atlassian.net/browse/PM-14264
|
||||
abstract disconnectFromInactivity(): void;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "../../abstractions/notifications.service";
|
||||
import { LogService } from "../abstractions/log.service";
|
||||
|
||||
export class NoopNotificationsService implements NotificationsServiceAbstraction {
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
init(): Promise<void> {
|
||||
this.logService.info(
|
||||
"Initializing no-op notification service, no push notifications will be received",
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
updateConnection(sync?: boolean): Promise<void> {
|
||||
this.logService.info("Updating notification service connection");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
reconnectFromActivity(): Promise<void> {
|
||||
this.logService.info("Reconnecting notification service from activity");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
disconnectFromInactivity(): Promise<void> {
|
||||
this.logService.info("Disconnecting notification service from inactivity");
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import * as signalR from "@microsoft/signalr";
|
||||
import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack";
|
||||
import { firstValueFrom, Subscription } from "rxjs";
|
||||
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
|
||||
import { ApiService } from "../abstractions/api.service";
|
||||
import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service";
|
||||
import { AuthService } from "../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../auth/enums/authentication-status";
|
||||
import { NotificationType } from "../enums";
|
||||
import {
|
||||
NotificationResponse,
|
||||
SyncCipherNotification,
|
||||
SyncFolderNotification,
|
||||
SyncSendNotification,
|
||||
} from "../models/response/notification.response";
|
||||
import { AppIdService } from "../platform/abstractions/app-id.service";
|
||||
import { EnvironmentService } from "../platform/abstractions/environment.service";
|
||||
import { LogService } from "../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../platform/abstractions/messaging.service";
|
||||
import { StateService } from "../platform/abstractions/state.service";
|
||||
import { ScheduledTaskNames } from "../platform/scheduling/scheduled-task-name.enum";
|
||||
import { TaskSchedulerService } from "../platform/scheduling/task-scheduler.service";
|
||||
import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction";
|
||||
|
||||
export class NotificationsService implements NotificationsServiceAbstraction {
|
||||
private signalrConnection: signalR.HubConnection;
|
||||
private url: string;
|
||||
private connected = false;
|
||||
private inited = false;
|
||||
private inactive = false;
|
||||
private reconnectTimerSubscription: Subscription;
|
||||
private isSyncingOnReconnect = true;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private syncService: SyncService,
|
||||
private appIdService: AppIdService,
|
||||
private apiService: ApiService,
|
||||
private environmentService: EnvironmentService,
|
||||
private logoutCallback: (logoutReason: LogoutReason) => Promise<void>,
|
||||
private stateService: StateService,
|
||||
private authService: AuthService,
|
||||
private messagingService: MessagingService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
ScheduledTaskNames.notificationsReconnectTimeout,
|
||||
() => this.reconnect(this.isSyncingOnReconnect),
|
||||
);
|
||||
this.environmentService.environment$.subscribe(() => {
|
||||
if (!this.inited) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.init();
|
||||
});
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.inited = false;
|
||||
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
||||
|
||||
// Set notifications server URL to `https://-` to effectively disable communication
|
||||
// with the notifications server from the client app
|
||||
if (this.url === "https://-") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.signalrConnection != null) {
|
||||
this.signalrConnection.off("ReceiveMessage");
|
||||
this.signalrConnection.off("Heartbeat");
|
||||
await this.signalrConnection.stop();
|
||||
this.connected = false;
|
||||
this.signalrConnection = null;
|
||||
}
|
||||
|
||||
this.signalrConnection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(this.url + "/hub", {
|
||||
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
|
||||
skipNegotiation: true,
|
||||
transport: signalR.HttpTransportType.WebSockets,
|
||||
})
|
||||
.withHubProtocol(new signalRMsgPack.MessagePackHubProtocol() as signalR.IHubProtocol)
|
||||
// .configureLogging(signalR.LogLevel.Trace)
|
||||
.build();
|
||||
|
||||
this.signalrConnection.on("ReceiveMessage", (data: any) =>
|
||||
this.processNotification(new NotificationResponse(data)),
|
||||
);
|
||||
// eslint-disable-next-line
|
||||
this.signalrConnection.on("Heartbeat", (data: any) => {
|
||||
/*console.log('Heartbeat!');*/
|
||||
});
|
||||
this.signalrConnection.onclose(() => {
|
||||
this.connected = false;
|
||||
// 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.reconnect(true);
|
||||
});
|
||||
this.inited = true;
|
||||
if (await this.isAuthedAndUnlocked()) {
|
||||
await this.reconnect(false);
|
||||
}
|
||||
}
|
||||
|
||||
async updateConnection(sync = false): Promise<void> {
|
||||
if (!this.inited) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (await this.isAuthedAndUnlocked()) {
|
||||
await this.reconnect(sync);
|
||||
} else {
|
||||
await this.signalrConnection.stop();
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
async reconnectFromActivity(): Promise<void> {
|
||||
this.inactive = false;
|
||||
if (this.inited && !this.connected) {
|
||||
await this.reconnect(true);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectFromInactivity(): Promise<void> {
|
||||
this.inactive = true;
|
||||
if (this.inited && this.connected) {
|
||||
await this.signalrConnection.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async processNotification(notification: NotificationResponse) {
|
||||
const appId = await this.appIdService.getAppId();
|
||||
if (notification == null || notification.contextId === appId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isAuthenticated = await this.stateService.getIsAuthenticated();
|
||||
const payloadUserId = notification.payload.userId || notification.payload.UserId;
|
||||
const myUserId = await this.stateService.getUserId();
|
||||
if (isAuthenticated && payloadUserId != null && payloadUserId !== myUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.SyncCipherCreate:
|
||||
case NotificationType.SyncCipherUpdate:
|
||||
await this.syncService.syncUpsertCipher(
|
||||
notification.payload as SyncCipherNotification,
|
||||
notification.type === NotificationType.SyncCipherUpdate,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncCipherDelete:
|
||||
case NotificationType.SyncLoginDelete:
|
||||
await this.syncService.syncDeleteCipher(notification.payload as SyncCipherNotification);
|
||||
break;
|
||||
case NotificationType.SyncFolderCreate:
|
||||
case NotificationType.SyncFolderUpdate:
|
||||
await this.syncService.syncUpsertFolder(
|
||||
notification.payload as SyncFolderNotification,
|
||||
notification.type === NotificationType.SyncFolderUpdate,
|
||||
payloadUserId,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncFolderDelete:
|
||||
await this.syncService.syncDeleteFolder(
|
||||
notification.payload as SyncFolderNotification,
|
||||
payloadUserId,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncVault:
|
||||
case NotificationType.SyncCiphers:
|
||||
case NotificationType.SyncSettings:
|
||||
if (isAuthenticated) {
|
||||
await this.syncService.fullSync(false);
|
||||
}
|
||||
break;
|
||||
case NotificationType.SyncOrganizations:
|
||||
if (isAuthenticated) {
|
||||
// An organization update may not have bumped the user's account revision date, so force a sync
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
break;
|
||||
case NotificationType.SyncOrgKeys:
|
||||
if (isAuthenticated) {
|
||||
await this.syncService.fullSync(true);
|
||||
// Stop so a reconnect can be made
|
||||
await this.signalrConnection.stop();
|
||||
}
|
||||
break;
|
||||
case NotificationType.LogOut:
|
||||
if (isAuthenticated) {
|
||||
this.logService.info("[Notifications Service] Received logout notification");
|
||||
// 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.logoutCallback("logoutNotification");
|
||||
}
|
||||
break;
|
||||
case NotificationType.SyncSendCreate:
|
||||
case NotificationType.SyncSendUpdate:
|
||||
await this.syncService.syncUpsertSend(
|
||||
notification.payload as SyncSendNotification,
|
||||
notification.type === NotificationType.SyncSendUpdate,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncSendDelete:
|
||||
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
|
||||
break;
|
||||
case NotificationType.AuthRequest:
|
||||
{
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case NotificationType.SyncOrganizationStatusChanged:
|
||||
if (isAuthenticated) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
break;
|
||||
case NotificationType.SyncOrganizationCollectionSettingChanged:
|
||||
if (isAuthenticated) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async reconnect(sync: boolean) {
|
||||
this.reconnectTimerSubscription?.unsubscribe();
|
||||
|
||||
if (this.connected || !this.inited || this.inactive) {
|
||||
return;
|
||||
}
|
||||
const authedAndUnlocked = await this.isAuthedAndUnlocked();
|
||||
if (!authedAndUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.signalrConnection.start();
|
||||
this.connected = true;
|
||||
if (sync) {
|
||||
await this.syncService.fullSync(false);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
|
||||
if (!this.connected) {
|
||||
this.isSyncingOnReconnect = sync;
|
||||
this.reconnectTimerSubscription = this.taskSchedulerService.setTimeout(
|
||||
ScheduledTaskNames.notificationsReconnectTimeout,
|
||||
this.random(120000, 300000),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async isAuthedAndUnlocked() {
|
||||
const authStatus = await this.authService.getAuthStatus();
|
||||
return authStatus >= AuthenticationStatus.Unlocked;
|
||||
}
|
||||
|
||||
private random(min: number, max: number) {
|
||||
min = Math.ceil(min);
|
||||
max = Math.floor(max);
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user