From 90dcb33a442881239a81b56dbc588f0295faf574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Wed, 9 Aug 2023 13:16:19 +0200 Subject: [PATCH] chore: remove calling payments server for subscriptions if using third party api hosts (#2398) --- .../Domain/Api/LegacyApiServiceInterface.ts | 8 +-- .../Application/ApplicationInterface.ts | 1 - .../Subscription/SubscriptionManager.spec.ts | 8 ++- .../Subscription/SubscriptionManager.ts | 12 +++- .../IsApplicationUsingThirdPartyHost.spec.ts | 60 +++++++++++++++++++ .../IsApplicationUsingThirdPartyHost.ts | 31 ++++++++++ packages/services/src/Domain/index.ts | 1 + packages/snjs/lib/Application/Application.ts | 4 -- .../Application/Dependencies/Dependencies.ts | 8 +++ .../lib/Application/Dependencies/Types.ts | 1 + packages/snjs/lib/Hosts.ts | 32 ---------- packages/snjs/lib/Services/Api/ApiService.ts | 19 +++--- .../Services/Features/FeaturesService.spec.ts | 10 +++- .../lib/Services/Features/FeaturesService.ts | 34 ++++++++--- ...MigrateFeatureRepoToOfflineEntitlements.ts | 11 ++-- .../lib/Services/Session/SessionManager.ts | 10 +++- packages/snjs/mocha/features.test.js | 20 +++---- .../Application/Dependencies/Types.ts | 2 + .../Dependencies/WebDependencies.ts | 16 ++++- .../Application/UseCase/GetPurchaseFlowUrl.ts | 17 +++++- .../Panes/Account/AccountPreferences.tsx | 2 +- .../Preferences/Panes/Account/Files.tsx | 9 ++- .../Preferences/Panes/Backups/Backups.tsx | 2 +- .../General/Advanced/OfflineSubscription.tsx | 4 +- 24 files changed, 233 insertions(+), 89 deletions(-) create mode 100644 packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.spec.ts create mode 100644 packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.ts delete mode 100644 packages/snjs/lib/Hosts.ts diff --git a/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts b/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts index 149c05054..27083fb1b 100644 --- a/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts +++ b/packages/services/src/Domain/Api/LegacyApiServiceInterface.ts @@ -9,13 +9,13 @@ import { AnyFeatureDescription } from '@standardnotes/features' export interface LegacyApiServiceInterface extends AbstractService, FilesApiInterface { - isThirdPartyHostUsed(): boolean setHost(host: string): Promise getHost(): string - downloadOfflineFeaturesFromRepo( - repo: SNFeatureRepo, - ): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> + downloadOfflineFeaturesFromRepo(dto: { + repo: SNFeatureRepo + trustedFeatureHosts: string[] + }): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> downloadFeatureUrl(url: string): Promise diff --git a/packages/services/src/Domain/Application/ApplicationInterface.ts b/packages/services/src/Domain/Application/ApplicationInterface.ts index 8520484bd..e7a56fbef 100644 --- a/packages/services/src/Domain/Application/ApplicationInterface.ts +++ b/packages/services/src/Domain/Application/ApplicationInterface.ts @@ -66,7 +66,6 @@ export interface ApplicationInterface { hasAccount(): boolean setCustomHost(host: string): Promise - isThirdPartyHostUsed(): boolean isUsingHomeServer(): Promise importData(data: BackupFile, awaitSync?: boolean): Promise diff --git a/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts b/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts index ab6f960db..d000ef1b8 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionManager.spec.ts @@ -7,14 +7,17 @@ import { SubscriptionApiServiceInterface } from '@standardnotes/api' import { Invitation } from '@standardnotes/models' import { InternalEventBusInterface } from '..' import { SubscriptionManager } from './SubscriptionManager' +import { IsApplicationUsingThirdPartyHost } from '../UseCase/IsApplicationUsingThirdPartyHost' +import { Result } from '@standardnotes/domain-core' describe('SubscriptionManager', () => { let subscriptionApiService: SubscriptionApiServiceInterface let internalEventBus: InternalEventBusInterface let sessions: SessionsClientInterface let storage: StorageServiceInterface + let isApplicationUsingThirdPartyHostUseCase: IsApplicationUsingThirdPartyHost - const createManager = () => new SubscriptionManager(subscriptionApiService, sessions, storage, internalEventBus) + const createManager = () => new SubscriptionManager(subscriptionApiService, sessions, storage, isApplicationUsingThirdPartyHostUseCase, internalEventBus) beforeEach(() => { subscriptionApiService = {} as jest.Mocked @@ -31,6 +34,9 @@ describe('SubscriptionManager', () => { internalEventBus = {} as jest.Mocked internalEventBus.addEventHandler = jest.fn() internalEventBus.publish = jest.fn() + + isApplicationUsingThirdPartyHostUseCase = {} as jest.Mocked + isApplicationUsingThirdPartyHostUseCase.execute = jest.fn().mockReturnValue(Result.ok(false)) }) describe('event handling', () => { diff --git a/packages/services/src/Domain/Subscription/SubscriptionManager.ts b/packages/services/src/Domain/Subscription/SubscriptionManager.ts index 4f7df120e..9a5617a4b 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionManager.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionManager.ts @@ -22,6 +22,7 @@ import { } from '@standardnotes/responses' import { SubscriptionManagerEvent } from './SubscriptionManagerEvent' import { ApplicationStageChangedEventPayload } from '../Event/ApplicationStageChangedEventPayload' +import { IsApplicationUsingThirdPartyHost } from '../UseCase/IsApplicationUsingThirdPartyHost' export class SubscriptionManager extends AbstractService @@ -34,6 +35,7 @@ export class SubscriptionManager private subscriptionApiService: SubscriptionApiServiceInterface, private sessions: SessionsClientInterface, private storage: StorageServiceInterface, + private isApplicationUsingThirdPartyHostUseCase: IsApplicationUsingThirdPartyHost, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -43,7 +45,15 @@ export class SubscriptionManager switch (event.type) { case ApplicationEvent.Launched: { void this.fetchOnlineSubscription() - void this.fetchAvailableSubscriptions() + + const isThirdPartyHostUsedOrError = this.isApplicationUsingThirdPartyHostUseCase.execute() + if (isThirdPartyHostUsedOrError.isFailed()) { + break + } + const isThirdPartyHostUsed = isThirdPartyHostUsedOrError.getValue() + if (!isThirdPartyHostUsed) { + void this.fetchAvailableSubscriptions() + } break } diff --git a/packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.spec.ts b/packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.spec.ts new file mode 100644 index 000000000..0471a4d92 --- /dev/null +++ b/packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.spec.ts @@ -0,0 +1,60 @@ +import { Result } from '@standardnotes/domain-core' + +import { GetHost } from '../..' +import { IsApplicationUsingThirdPartyHost } from './IsApplicationUsingThirdPartyHost' + +describe('IsApplicationUsingThirdPartyHost', () => { + let getHostUseCase: GetHost + + const createUseCase = () => new IsApplicationUsingThirdPartyHost(getHostUseCase) + + beforeEach(() => { + getHostUseCase = {} as jest.Mocked + getHostUseCase.execute = jest.fn().mockReturnValue(Result.ok('https://api.standardnotes.com')) + }) + + it('returns true if host is localhost', () => { + getHostUseCase.execute = jest.fn().mockReturnValue(Result.ok('http://localhost:3000')) + + const useCase = createUseCase() + const result = useCase.execute() + + expect(result.getValue()).toBe(true) + }) + + it('returns false if host is api.standardnotes.com', () => { + getHostUseCase.execute = jest.fn().mockReturnValue(Result.ok('https://api.standardnotes.com')) + + const useCase = createUseCase() + const result = useCase.execute() + + expect(result.getValue()).toBe(false) + }) + + it('returns false if host is sync.standardnotes.org', () => { + getHostUseCase.execute = jest.fn().mockReturnValue(Result.ok('https://sync.standardnotes.org')) + + const useCase = createUseCase() + const result = useCase.execute() + + expect(result.getValue()).toBe(false) + }) + + it('returns false if host is files.standardnotes.com', () => { + getHostUseCase.execute = jest.fn().mockReturnValue(Result.ok('https://files.standardnotes.com')) + + const useCase = createUseCase() + const result = useCase.execute() + + expect(result.getValue()).toBe(false) + }) + + it('returns true if host is not first party', () => { + getHostUseCase.execute = jest.fn().mockReturnValue(Result.ok('https://example.com')) + + const useCase = createUseCase() + const result = useCase.execute() + + expect(result.getValue()).toBe(true) + }) +}) diff --git a/packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.ts b/packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.ts new file mode 100644 index 000000000..31f27a82b --- /dev/null +++ b/packages/services/src/Domain/UseCase/IsApplicationUsingThirdPartyHost.ts @@ -0,0 +1,31 @@ +import { Result, SyncUseCaseInterface } from '@standardnotes/domain-core' + +import { GetHost } from './GetHost' + +export class IsApplicationUsingThirdPartyHost implements SyncUseCaseInterface { + private readonly APPLICATION_DEFAULT_HOSTS = ['api.standardnotes.com', 'sync.standardnotes.org'] + + private readonly FILES_DEFAULT_HOSTS = ['files.standardnotes.com'] + + constructor(private getHostUseCase: GetHost) {} + + execute(): Result { + const result = this.getHostUseCase.execute() + if (result.isFailed()) { + return Result.fail(result.getError()) + } + + const host = result.getValue() + + return Result.ok(!this.isUrlFirstParty(host)) + } + + private isUrlFirstParty(url: string): boolean { + try { + const { host } = new URL(url) + return this.APPLICATION_DEFAULT_HOSTS.includes(host) || this.FILES_DEFAULT_HOSTS.includes(host) + } catch (error) { + return false + } + } +} diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index 158380ae8..63249ab6b 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -180,6 +180,7 @@ export * from './UseCase/ChangeAndSaveItem' export * from './UseCase/DiscardItemsLocally' export * from './UseCase/GenerateUuid' export * from './UseCase/GetHost' +export * from './UseCase/IsApplicationUsingThirdPartyHost' export * from './UseCase/SetHost' export * from './User/AccountEvent' export * from './User/AccountEventData' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 51515bd62..944487a53 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -962,10 +962,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli } } - public isThirdPartyHostUsed(): boolean { - return this.legacyApi.isThirdPartyHostUsed() - } - async isUsingHomeServer(): Promise { const homeServerService = this.dependencies.get(TYPES.HomeServerService) diff --git a/packages/snjs/lib/Application/Dependencies/Dependencies.ts b/packages/snjs/lib/Application/Dependencies/Dependencies.ts index 45001b572..9cb6cacb3 100644 --- a/packages/snjs/lib/Application/Dependencies/Dependencies.ts +++ b/packages/snjs/lib/Application/Dependencies/Dependencies.ts @@ -133,6 +133,7 @@ import { GenerateUuid, GetVaultItems, ValidateVaultPassword, + IsApplicationUsingThirdPartyHost, } from '@standardnotes/services' import { ItemManager } from '../../Services/Items/ItemManager' import { PayloadManager } from '../../Services/Payloads/PayloadManager' @@ -243,6 +244,10 @@ export class Dependencies { return new GetHost(this.get(TYPES.LegacyApiService)) }) + this.factory.set(TYPES.IsApplicationUsingThirdPartyHost, () => { + return new IsApplicationUsingThirdPartyHost(this.get(TYPES.GetHost)) + }) + this.factory.set(TYPES.SetHost, () => { return new SetHost(this.get(TYPES.HttpService), this.get(TYPES.LegacyApiService)) }) @@ -1159,6 +1164,7 @@ export class Dependencies { this.get(TYPES.SessionManager), this.get(TYPES.Crypto), this.get(TYPES.Logger), + this.get(TYPES.IsApplicationUsingThirdPartyHost), this.get(TYPES.InternalEventBus), ) }) @@ -1267,6 +1273,7 @@ export class Dependencies { this.get(TYPES.SubscriptionApiService), this.get(TYPES.SessionManager), this.get(TYPES.DiskStorageService), + this.get(TYPES.IsApplicationUsingThirdPartyHost), this.get(TYPES.InternalEventBus), ) }) @@ -1286,6 +1293,7 @@ export class Dependencies { this.get(TYPES.LegacySessionStorageMapper), this.options.identifier, this.get(TYPES.GetKeyPairs), + this.get(TYPES.IsApplicationUsingThirdPartyHost), this.get(TYPES.InternalEventBus), ) }) diff --git a/packages/snjs/lib/Application/Dependencies/Types.ts b/packages/snjs/lib/Application/Dependencies/Types.ts index 8b046f291..eb2231f33 100644 --- a/packages/snjs/lib/Application/Dependencies/Types.ts +++ b/packages/snjs/lib/Application/Dependencies/Types.ts @@ -158,6 +158,7 @@ export const TYPES = { ChangeVaultStorageMode: Symbol.for('ChangeVaultStorageMode'), ChangeAndSaveItem: Symbol.for('ChangeAndSaveItem'), GetHost: Symbol.for('GetHost'), + IsApplicationUsingThirdPartyHost: Symbol.for('IsApplicationUsingThirdPartyHost'), SetHost: Symbol.for('SetHost'), GenerateUuid: Symbol.for('GenerateUuid'), GetVaultItems: Symbol.for('GetVaultItems'), diff --git a/packages/snjs/lib/Hosts.ts b/packages/snjs/lib/Hosts.ts deleted file mode 100644 index 83763652c..000000000 --- a/packages/snjs/lib/Hosts.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const APPLICATION_DEFAULT_HOSTS = ['api.standardnotes.com', 'sync.standardnotes.org'] - -export const FILES_DEFAULT_HOSTS = ['files.standardnotes.com'] - -export const TRUSTED_FEATURE_HOSTS = [ - 'api.standardnotes.com', - 'extensions.standardnotes.com', - 'extensions.standardnotes.org', - 'features.standardnotes.com', - 'localhost', -] - -export enum ExtensionsServerURL { - Prod = 'https://extensions.standardnotes.org', -} - -const LocalHost = 'localhost' - -export function isUrlFirstParty(url: string): boolean { - try { - const { host } = new URL(url) - return host.startsWith(LocalHost) || APPLICATION_DEFAULT_HOSTS.includes(host) || FILES_DEFAULT_HOSTS.includes(host) - } catch (_err) { - return false - } -} - -export const PROD_OFFLINE_FEATURES_URL = 'https://api.standardnotes.com/v1/offline/features' - -export const LEGACY_PROD_EXT_ORIGIN = 'https://extensions.standardnotes.org' - -export const TRUSTED_CUSTOM_EXTENSIONS_HOSTS = ['listed.to'] diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index 868917cb8..f250cfca9 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -73,7 +73,6 @@ import { LegacySession, MapperInterface, Session, SessionToken } from '@standard import { HttpServiceInterface } from '@standardnotes/api' import { SNRootKeyParams } from '@standardnotes/encryption' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' -import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts' import { Paths } from './Paths' import { DiskStorageService } from '../Storage/DiskStorageService' import { UuidString } from '../../Types/UuidString' @@ -157,11 +156,6 @@ export class LegacyApiService return this.host } - public isThirdPartyHostUsed(): boolean { - const applicationHost = this.getHost() || '' - return !isUrlFirstParty(applicationHost) - } - public getFilesHost(): string { if (!this.filesHost) { throw Error('Attempting to access undefined filesHost') @@ -620,19 +614,20 @@ export class LegacyApiService return response.data.token } - public async downloadOfflineFeaturesFromRepo( - repo: SNFeatureRepo, - ): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> { + public async downloadOfflineFeaturesFromRepo(dto: { + repo: SNFeatureRepo + trustedFeatureHosts: string[] + }): Promise<{ features: AnyFeatureDescription[]; roles: string[] } | ClientDisplayableError> { try { - const featuresUrl = repo.offlineFeaturesUrl - const extensionKey = repo.offlineKey + const featuresUrl = dto.repo.offlineFeaturesUrl + const extensionKey = dto.repo.offlineKey if (!featuresUrl || !extensionKey) { throw Error('Cannot download offline repo without url and offlineKEy') } const { hostname } = new URL(featuresUrl) - if (!TRUSTED_FEATURE_HOSTS.includes(hostname)) { + if (!dto.trustedFeatureHosts.includes(hostname)) { return new ClientDisplayableError(`The offline features host ${hostname} is not in the trusted allowlist.`) } diff --git a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts index 1cb4d0bcf..26129b745 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts @@ -2,7 +2,7 @@ import { ItemInterface, SNFeatureRepo } from '@standardnotes/models' import { SyncService } from '../Sync/SyncService' import { SettingName } from '@standardnotes/settings' import { FeaturesService } from '@Lib/Services/Features' -import { RoleName, ContentType, Uuid } from '@standardnotes/domain-core' +import { RoleName, ContentType, Uuid, Result } from '@standardnotes/domain-core' import { NativeFeatureIdentifier, GetFeatures } from '@standardnotes/features' import { WebSocketsService } from '../Api/WebsocketsService' import { SettingsService } from '../Settings' @@ -22,6 +22,7 @@ import { SyncServiceInterface, UserServiceInterface, UserService, + IsApplicationUsingThirdPartyHost, } from '@standardnotes/services' import { LegacyApiService, SessionManager } from '../Api' import { ItemManager } from '../Items' @@ -47,6 +48,7 @@ describe('FeaturesService', () => { let internalEventBus: InternalEventBusInterface let featureService: FeaturesService let logger: LoggerInterface + let isApplicationUsingThirdPartyHostUseCase: IsApplicationUsingThirdPartyHost beforeEach(() => { logger = {} as jest.Mocked @@ -62,7 +64,6 @@ describe('FeaturesService', () => { apiService = {} as jest.Mocked apiService.addEventObserver = jest.fn() - apiService.isThirdPartyHostUsed = jest.fn().mockReturnValue(false) itemManager = {} as jest.Mocked itemManager.getItems = jest.fn().mockReturnValue(items) @@ -107,6 +108,9 @@ describe('FeaturesService', () => { internalEventBus.publish = jest.fn() internalEventBus.addEventHandler = jest.fn() + isApplicationUsingThirdPartyHostUseCase = {} as jest.Mocked + isApplicationUsingThirdPartyHostUseCase.execute = jest.fn().mockReturnValue(Result.ok(false)) + featureService = new FeaturesService( storageService, itemManager, @@ -121,6 +125,7 @@ describe('FeaturesService', () => { sessionManager, crypto, logger, + isApplicationUsingThirdPartyHostUseCase, internalEventBus, ) }) @@ -202,6 +207,7 @@ describe('FeaturesService', () => { sessionManager, crypto, logger, + isApplicationUsingThirdPartyHostUseCase, internalEventBus, ) } diff --git a/packages/snjs/lib/Services/Features/FeaturesService.ts b/packages/snjs/lib/Services/Features/FeaturesService.ts index 2a7ffb3a4..7ae10aa56 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.ts @@ -2,11 +2,9 @@ import { MigrateFeatureRepoToUserSettingUseCase } from './UseCase/MigrateFeature import { arraysEqual, removeFromArray, lastElement, LoggerInterface } from '@standardnotes/utils' import { ClientDisplayableError } from '@standardnotes/responses' import { RoleName, ContentType, Uuid } from '@standardnotes/domain-core' -import { PROD_OFFLINE_FEATURES_URL } from '../../Hosts' import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { WebSocketsService } from '../Api/WebsocketsService' import { WebSocketsServiceEvent } from '../Api/WebSocketsServiceEvent' -import { TRUSTED_CUSTOM_EXTENSIONS_HOSTS, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts' import { UserRolesChangedEvent } from '@standardnotes/domain-events' import { ExperimentalFeatures, FindNativeFeature, NativeFeatureIdentifier } from '@standardnotes/features' import { @@ -48,6 +46,7 @@ import { SubscriptionManagerEvent, ApplicationEvent, ApplicationStageChangedEventPayload, + IsApplicationUsingThirdPartyHost, } from '@standardnotes/services' import { DownloadRemoteThirdPartyFeatureUseCase } from './UseCase/DownloadRemoteThirdPartyFeature' @@ -67,6 +66,18 @@ export class FeaturesService private getFeatureStatusUseCase = new GetFeatureStatusUseCase(this.items) + private readonly TRUSTED_FEATURE_HOSTS = [ + 'api.standardnotes.com', + 'extensions.standardnotes.com', + 'extensions.standardnotes.org', + 'features.standardnotes.com', + 'localhost', + ] + + private readonly TRUSTED_CUSTOM_EXTENSIONS_HOSTS = ['listed.to'] + + private readonly PROD_OFFLINE_FEATURES_URL = 'https://api.standardnotes.com/v1/offline/features' + constructor( private storage: StorageServiceInterface, private items: ItemManagerInterface, @@ -81,6 +92,7 @@ export class FeaturesService private sessions: SessionsClientInterface, private crypto: PureCryptoInterface, private logger: LoggerInterface, + private isApplicationUsingThirdPartyHostUseCase: IsApplicationUsingThirdPartyHost, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -128,7 +140,12 @@ export class FeaturesService if (eventName === AccountEvent.SignedInOrRegistered) { const featureRepos = this.items.getItems(ContentType.TYPES.ExtensionRepo) as SNFeatureRepo[] - if (!this.api.isThirdPartyHostUsed()) { + const isThirdPartyHostUsedOrError = this.isApplicationUsingThirdPartyHostUseCase.execute() + if (isThirdPartyHostUsedOrError.isFailed()) { + return + } + const isThirdPartyHostUsed = isThirdPartyHostUsedOrError.getValue() + if (!isThirdPartyHostUsed) { void this.migrateFeatureRepoToUserSetting(featureRepos) } } @@ -285,7 +302,10 @@ export class FeaturesService } private async downloadOfflineRoles(repo: SNFeatureRepo): Promise { - const result = await this.api.downloadOfflineFeaturesFromRepo(repo) + const result = await this.api.downloadOfflineFeaturesFromRepo({ + repo, + trustedFeatureHosts: this.TRUSTED_FEATURE_HOSTS, + }) if (result instanceof ClientDisplayableError) { return result @@ -301,7 +321,7 @@ export class FeaturesService public async migrateFeatureRepoToOfflineEntitlements(featureRepos: SNFeatureRepo[] = []): Promise { const usecase = new MigrateFeatureRepoToOfflineEntitlementsUseCase(this.mutator) - const updatedRepos = await usecase.execute(featureRepos) + const updatedRepos = await usecase.execute({ featureRepos, prodOfflineFeaturesUrl: this.PROD_OFFLINE_FEATURES_URL }) if (updatedRepos.length > 0) { await this.downloadOfflineRoles(updatedRepos[0]) @@ -322,7 +342,7 @@ export class FeaturesService return false } - const hasFirstPartyOfflineSubscription = offlineRepo.content.offlineFeaturesUrl === PROD_OFFLINE_FEATURES_URL + const hasFirstPartyOfflineSubscription = offlineRepo.content.offlineFeaturesUrl === this.PROD_OFFLINE_FEATURES_URL return hasFirstPartyOfflineSubscription || new URL(offlineRepo.content.offlineFeaturesUrl).hostname === 'localhost' } @@ -427,7 +447,7 @@ export class FeaturesService } try { - const trustedCustomExtensionsUrls = [...TRUSTED_FEATURE_HOSTS, ...TRUSTED_CUSTOM_EXTENSIONS_HOSTS] + const trustedCustomExtensionsUrls = [...this.TRUSTED_FEATURE_HOSTS, ...this.TRUSTED_CUSTOM_EXTENSIONS_HOSTS] const { host } = new URL(url) const usecase = new DownloadRemoteThirdPartyFeatureUseCase(this.api, this.items, this.alerts) diff --git a/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToOfflineEntitlements.ts b/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToOfflineEntitlements.ts index e18d4e014..fcb46e974 100644 --- a/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToOfflineEntitlements.ts +++ b/packages/snjs/lib/Services/Features/UseCase/MigrateFeatureRepoToOfflineEntitlements.ts @@ -1,13 +1,14 @@ -import { LEGACY_PROD_EXT_ORIGIN, PROD_OFFLINE_FEATURES_URL } from '@Lib/Hosts' import { SNFeatureRepo } from '@standardnotes/models' import { MutatorClientInterface } from '@standardnotes/services' export class MigrateFeatureRepoToOfflineEntitlementsUseCase { + private readonly LEGACY_PROD_EXT_ORIGIN = 'https://extensions.standardnotes.org' + constructor(private mutator: MutatorClientInterface) {} - async execute(featureRepos: SNFeatureRepo[] = []): Promise { + async execute(dto: { featureRepos: SNFeatureRepo[]; prodOfflineFeaturesUrl: string }): Promise { const updatedRepos: SNFeatureRepo[] = [] - for (const item of featureRepos) { + for (const item of dto.featureRepos) { if (item.migratedToOfflineEntitlements) { continue } @@ -19,7 +20,7 @@ export class MigrateFeatureRepoToOfflineEntitlementsUseCase { const repoUrl = item.onlineUrl const { origin } = new URL(repoUrl) - if (!origin.includes(LEGACY_PROD_EXT_ORIGIN)) { + if (!origin.includes(this.LEGACY_PROD_EXT_ORIGIN)) { continue } @@ -28,7 +29,7 @@ export class MigrateFeatureRepoToOfflineEntitlementsUseCase { const userKey = userKeyMatch[0] const updatedRepo = await this.mutator.changeFeatureRepo(item, (m) => { - m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL + m.offlineFeaturesUrl = dto.prodOfflineFeaturesUrl m.offlineKey = userKey m.migratedToOfflineEntitlements = true }) diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index bacca08d9..086b6c9ae 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -33,6 +33,7 @@ import { ApplicationStageChangedEventPayload, ApplicationStage, GetKeyPairs, + IsApplicationUsingThirdPartyHost, } from '@standardnotes/services' import { Base64String, PureCryptoInterface } from '@standardnotes/sncrypto-common' import { @@ -105,6 +106,7 @@ export class SessionManager private legacySessionStorageMapper: MapperInterface>, private workspaceIdentifier: string, private _getKeyPairs: GetKeyPairs, + private isApplicationUsingThirdPartyHostUseCase: IsApplicationUsingThirdPartyHost, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -274,7 +276,13 @@ export class SessionManager } public isSignedIntoFirstPartyServer(): boolean { - return this.isSignedIn() && !this.apiService.isThirdPartyHostUsed() + const isThirdPartyHostUsedOrError = this.isApplicationUsingThirdPartyHostUseCase.execute() + if (isThirdPartyHostUsedOrError.isFailed()) { + return false + } + const isThirdPartyHostUsed = isThirdPartyHostUsedOrError.getValue() + + return this.isSignedIn() && !isThirdPartyHostUsed } public async reauthenticateInvalidSession( diff --git a/packages/snjs/mocha/features.test.js b/packages/snjs/mocha/features.test.js index a9d95ce4a..0ac41c296 100644 --- a/packages/snjs/mocha/features.test.js +++ b/packages/snjs/mocha/features.test.js @@ -47,8 +47,8 @@ describe('features', () => { describe('extension repo items observer', () => { it('should migrate to user setting when extension repo is added', async () => { - sinon.stub(application.legacyApi, 'isThirdPartyHostUsed').callsFake(() => { - return false + sinon.stub(application.features.isApplicationUsingThirdPartyHostUseCase, 'execute').callsFake(() => { + return Result.ok(false) }) expect( @@ -74,8 +74,8 @@ describe('features', () => { }) it('signing into account with ext repo should migrate it', async () => { - sinon.stub(application.legacyApi, 'isThirdPartyHostUsed').callsFake(() => { - return false + sinon.stub(application.features.isApplicationUsingThirdPartyHostUseCase, 'execute').callsFake(() => { + return Result.ok(false) }) /** Attach an ExtensionRepo object to an account, but prevent it from being migrated. * Then sign out, sign back in, and ensure the item is migrated. */ @@ -96,8 +96,8 @@ describe('features', () => { application = await Factory.signOutApplicationAndReturnNew(application) sinon.restore() - sinon.stub(application.legacyApi, 'isThirdPartyHostUsed').callsFake(() => { - return false + sinon.stub(application.features.isApplicationUsingThirdPartyHostUseCase, 'execute').callsFake(() => { + return Result.ok(false) }) const promise = new Promise((resolve) => { sinon.stub(application.features, 'migrateFeatureRepoToUserSetting').callsFake(resolve) @@ -112,8 +112,8 @@ describe('features', () => { it('having an ext repo with no account, then signing into account, should migrate it', async () => { application = await Factory.signOutApplicationAndReturnNew(application) - sinon.stub(application.legacyApi, 'isThirdPartyHostUsed').callsFake(() => { - return false + sinon.stub(application.features.isApplicationUsingThirdPartyHostUseCase, 'execute').callsFake(() => { + return Result.ok(false) }) const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') await application.mutator.createItem( @@ -137,8 +137,8 @@ describe('features', () => { }) it('migrated ext repo should have property indicating it was migrated', async () => { - sinon.stub(application.legacyApi, 'isThirdPartyHostUsed').callsFake(() => { - return false + sinon.stub(application.features.isApplicationUsingThirdPartyHostUseCase, 'execute').callsFake(() => { + return Result.ok(false) }) const setting = SettingName.create(SettingName.NAMES.ExtensionKey).getValue() expect(await application.settings.getDoesSensitiveSettingExist(setting)).to.equal(false) diff --git a/packages/web/src/javascripts/Application/Dependencies/Types.ts b/packages/web/src/javascripts/Application/Dependencies/Types.ts index 6477b63a1..915398a43 100644 --- a/packages/web/src/javascripts/Application/Dependencies/Types.ts +++ b/packages/web/src/javascripts/Application/Dependencies/Types.ts @@ -52,4 +52,6 @@ export const Web_TYPES = { LoadPurchaseFlowUrl: Symbol.for('LoadPurchaseFlowUrl'), OpenSubscriptionDashboard: Symbol.for('OpenSubscriptionDashboard'), PanesForLayout: Symbol.for('PanesForLayout'), + GetHost: Symbol.for('GetHost'), + IsApplicationUsingThirdPartyHost: Symbol.for('IsApplicationUsingThirdPartyHost'), } diff --git a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts index c601541a8..4a64c22ef 100644 --- a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts +++ b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts @@ -17,7 +17,7 @@ import { } from '@standardnotes/ui-services' import { DependencyContainer } from '@standardnotes/utils' import { Web_TYPES } from './Types' -import { BackupServiceInterface, isDesktopDevice } from '@standardnotes/snjs' +import { BackupServiceInterface, GetHost, IsApplicationUsingThirdPartyHost, isDesktopDevice } from '@standardnotes/snjs' import { DesktopManager } from '../Device/DesktopManager' import { MomentsService } from '@/Controllers/Moments/MomentsService' import { PersistenceService } from '@/Controllers/Abstract/PersistenceService' @@ -195,6 +195,14 @@ export class WebDependencies extends DependencyContainer { return new PanesForLayout(this.get(Web_TYPES.IsTabletOrMobileScreen)) }) + this.bind(Web_TYPES.GetHost, () => { + return new GetHost(application.legacyApi) + }) + + this.bind(Web_TYPES.IsApplicationUsingThirdPartyHost, () => { + return new IsApplicationUsingThirdPartyHost(this.get(Web_TYPES.GetHost)) + }) + this.bind(Web_TYPES.IsTabletOrMobileScreen, () => { return new IsTabletOrMobileScreen(application.environment) }) @@ -320,7 +328,11 @@ export class WebDependencies extends DependencyContainer { }) this.bind(Web_TYPES.GetPurchaseFlowUrl, () => { - return new GetPurchaseFlowUrl(application, application.legacyApi) + return new GetPurchaseFlowUrl( + application, + application.legacyApi, + this.get(Web_TYPES.IsApplicationUsingThirdPartyHost), + ) }) this.bind(Web_TYPES.SyncStatusController, () => { diff --git a/packages/web/src/javascripts/Application/UseCase/GetPurchaseFlowUrl.ts b/packages/web/src/javascripts/Application/UseCase/GetPurchaseFlowUrl.ts index 1524f1aec..306863711 100644 --- a/packages/web/src/javascripts/Application/UseCase/GetPurchaseFlowUrl.ts +++ b/packages/web/src/javascripts/Application/UseCase/GetPurchaseFlowUrl.ts @@ -1,17 +1,30 @@ import { isDesktopApplication } from '@/Utils' -import { ApplicationInterface, LegacyApiServiceInterface, Result, UseCaseInterface } from '@standardnotes/snjs' +import { + ApplicationInterface, + IsApplicationUsingThirdPartyHost, + LegacyApiServiceInterface, + Result, + UseCaseInterface, +} from '@standardnotes/snjs' export class GetPurchaseFlowUrl implements UseCaseInterface { constructor( private application: ApplicationInterface, private legacyApi: LegacyApiServiceInterface, + private isApplicationUsingThirdPartyHostUseCase: IsApplicationUsingThirdPartyHost, ) {} async execute(): Promise> { const currentUrl = window.location.origin const successUrl = isDesktopApplication() ? 'standardnotes://' : currentUrl - if (this.application.sessions.isSignedOut() || this.application.isThirdPartyHostUsed()) { + const isThirdPartyHostUsedOrError = this.isApplicationUsingThirdPartyHostUseCase.execute() + if (isThirdPartyHostUsedOrError.isFailed()) { + return Result.fail(isThirdPartyHostUsedOrError.getError()!) + } + const isThirdPartyHostUsed = isThirdPartyHostUsedOrError.getValue() + + if (this.application.sessions.isSignedOut() || isThirdPartyHostUsed) { return Result.ok(`${window.purchaseUrl}/offline?&success_url=${successUrl}`) } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx index 4a9e20519..7fe9aa7da 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/AccountPreferences.tsx @@ -17,7 +17,7 @@ type Props = { } const AccountPreferences = ({ application }: Props) => { - const isUsingThirdPartyServer = application.isThirdPartyHostUsed() + const isUsingThirdPartyServer = !application.sessions.isSignedIntoFirstPartyServer() return ( diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx index 4fac219a1..015782c0d 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx @@ -25,7 +25,7 @@ const FilesSection: FunctionComponent = ({ application }) => { setFilesQuotaUsed(parseFloat(filesQuotaUsed)) } - if (!application.isThirdPartyHostUsed()) { + if (application.sessions.isSignedIntoFirstPartyServer()) { const filesQuotaTotal = await application.settings.getSubscriptionSetting( SettingName.create(SettingName.NAMES.FileUploadBytesLimit).getValue(), ) @@ -54,7 +54,12 @@ const FilesSection: FunctionComponent = ({ application }) => { <>
{formatSizeToReadableString(filesQuotaUsed)} of{' '} - {application.isThirdPartyHostUsed() ? '∞' : formatSizeToReadableString(filesQuotaTotal)} used + + {application.sessions.isSignedIntoFirstPartyServer() + ? formatSizeToReadableString(filesQuotaTotal) + : '∞'} + {' '} + used
= ({ application }) => { - const isUsingThirdPartyServer = application.isThirdPartyHostUsed() + const isUsingThirdPartyServer = !application.sessions.isSignedIntoFirstPartyServer() return ( diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/OfflineSubscription.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/OfflineSubscription.tsx index 2173129ea..6175f3898 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/OfflineSubscription.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Advanced/OfflineSubscription.tsx @@ -26,7 +26,9 @@ const OfflineSubscription: FunctionComponent = ({ application, onSuccess }, [application]) const shouldShowOfflineSubscription = () => { - return !application.hasAccount() || application.isThirdPartyHostUsed() || hasUserPreviouslyStoredCode + return ( + !application.hasAccount() || !application.sessions.isSignedIntoFirstPartyServer() || hasUserPreviouslyStoredCode + ) } const handleSubscriptionCodeSubmit = async (event: React.FormEvent) => {