feat: add snjs package

This commit is contained in:
Karol Sójko
2022-07-06 14:04:18 +02:00
parent 321a055bae
commit 0e40469e2f
296 changed files with 46109 additions and 187 deletions

View File

@@ -0,0 +1,36 @@
import { FeatureStatus, SetOfflineFeaturesFunctionResponse } from './Types'
import { FeatureDescription, FeatureIdentifier } from '@standardnotes/features'
import { SNComponent } from '@standardnotes/models'
import { RoleName } from '@standardnotes/common'
export interface FeaturesClientInterface {
downloadExternalFeature(urlOrCode: string): Promise<SNComponent | undefined>
getUserFeature(featureId: FeatureIdentifier): FeatureDescription | undefined
getFeatureStatus(featureId: FeatureIdentifier): FeatureStatus
hasMinimumRole(role: RoleName): boolean
setOfflineFeaturesCode(code: string): Promise<SetOfflineFeaturesFunctionResponse>
hasOfflineRepo(): boolean
deleteOfflineFeatureRepo(): Promise<void>
isThirdPartyFeature(identifier: string): boolean
toggleExperimentalFeature(identifier: FeatureIdentifier): void
getExperimentalFeatures(): FeatureIdentifier[]
getEnabledExperimentalFeatures(): FeatureIdentifier[]
enableExperimentalFeature(identifier: FeatureIdentifier): void
disableExperimentalFeature(identifier: FeatureIdentifier): void
isExperimentalFeatureEnabled(identifier: FeatureIdentifier): boolean
isExperimentalFeature(identifier: FeatureIdentifier): boolean
}

View File

@@ -0,0 +1,799 @@
import { ItemInterface, SNComponent, SNFeatureRepo } from '@standardnotes/models'
import { SNSyncService } from '../Sync/SyncService'
import { SettingName } from '@standardnotes/settings'
import {
ItemManager,
AlertService,
SNApiService,
UserService,
SNSessionManager,
DiskStorageService,
StorageKey,
} from '@Lib/index'
import { FeatureStatus, SNFeaturesService } from '@Lib/Services/Features'
import { ContentType, RoleName } from '@standardnotes/common'
import { FeatureDescription, FeatureIdentifier, GetFeatures } from '@standardnotes/features'
import { SNWebSocketsService } from '../Api/WebsocketsService'
import { SNSettingsService } from '../Settings'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { convertTimestampToMilliseconds } from '@standardnotes/utils'
import { InternalEventBusInterface } from '@standardnotes/services'
describe('featuresService', () => {
let storageService: DiskStorageService
let apiService: SNApiService
let itemManager: ItemManager
let webSocketsService: SNWebSocketsService
let settingsService: SNSettingsService
let userService: UserService
let syncService: SNSyncService
let alertService: AlertService
let sessionManager: SNSessionManager
let crypto: PureCryptoInterface
let roles: RoleName[]
let features: FeatureDescription[]
let items: ItemInterface[]
let now: Date
let tomorrow_server: number
let tomorrow_client: number
let internalEventBus: InternalEventBusInterface
const expiredDate = new Date(new Date().getTime() - 1000).getTime()
const createService = () => {
return new SNFeaturesService(
storageService,
apiService,
itemManager,
webSocketsService,
settingsService,
userService,
syncService,
alertService,
sessionManager,
crypto,
internalEventBus,
)
}
beforeEach(() => {
roles = [RoleName.CoreUser, RoleName.PlusUser]
now = new Date()
tomorrow_client = now.setDate(now.getDate() + 1)
tomorrow_server = convertTimestampToMilliseconds(tomorrow_client * 1_000)
features = [
{
...GetFeatures().find((f) => f.identifier === FeatureIdentifier.MidnightTheme),
expires_at: tomorrow_server,
},
{
...GetFeatures().find((f) => f.identifier === FeatureIdentifier.PlusEditor),
expires_at: tomorrow_server,
},
] as jest.Mocked<FeatureDescription[]>
items = [] as jest.Mocked<ItemInterface[]>
storageService = {} as jest.Mocked<DiskStorageService>
storageService.setValue = jest.fn()
storageService.getValue = jest.fn()
apiService = {} as jest.Mocked<SNApiService>
apiService.addEventObserver = jest.fn()
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features,
},
})
apiService.downloadOfflineFeaturesFromRepo = jest.fn().mockReturnValue({
features,
})
apiService.isThirdPartyHostUsed = jest.fn().mockReturnValue(false)
itemManager = {} as jest.Mocked<ItemManager>
itemManager.getItems = jest.fn().mockReturnValue(items)
itemManager.createItem = jest.fn()
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<ItemInterface>)
itemManager.setItemsToBeDeleted = jest.fn()
itemManager.addObserver = jest.fn()
itemManager.changeItem = jest.fn()
itemManager.changeFeatureRepo = jest.fn()
webSocketsService = {} as jest.Mocked<SNWebSocketsService>
webSocketsService.addEventObserver = jest.fn()
settingsService = {} as jest.Mocked<SNSettingsService>
settingsService.updateSetting = jest.fn()
userService = {} as jest.Mocked<UserService>
userService.addEventObserver = jest.fn()
syncService = {} as jest.Mocked<SNSyncService>
syncService.sync = jest.fn()
alertService = {} as jest.Mocked<AlertService>
alertService.confirm = jest.fn().mockReturnValue(true)
alertService.alert = jest.fn()
sessionManager = {} as jest.Mocked<SNSessionManager>
sessionManager.isSignedIntoFirstPartyServer = jest.fn()
sessionManager.getUser = jest.fn()
crypto = {} as jest.Mocked<PureCryptoInterface>
crypto.base64Decode = jest.fn()
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publish = jest.fn()
})
describe('experimental features', () => {
it('enables/disables an experimental feature', async () => {
storageService.getValue = jest.fn().mockReturnValue(GetFeatures())
const featuresService = createService()
featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor])
featuresService.initializeFromDisk()
featuresService.enableExperimentalFeature(FeatureIdentifier.PlusEditor)
expect(featuresService.isExperimentalFeatureEnabled(FeatureIdentifier.PlusEditor)).toEqual(true)
featuresService.disableExperimentalFeature(FeatureIdentifier.PlusEditor)
expect(featuresService.isExperimentalFeatureEnabled(FeatureIdentifier.PlusEditor)).toEqual(false)
})
it('does not create a component for not enabled experimental feature', async () => {
const features = [
{
identifier: FeatureIdentifier.PlusEditor,
expires_at: tomorrow_server,
content_type: ContentType.Component,
},
]
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features,
},
})
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor])
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.createItem).not.toHaveBeenCalled()
})
it('does create a component for enabled experimental feature', async () => {
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features: GetFeatures(),
},
})
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor])
featuresService.getEnabledExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor])
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.createItem).toHaveBeenCalled()
})
})
describe('loadUserRoles()', () => {
it('retrieves user roles and features from storage', async () => {
await createService().initializeFromDisk()
expect(storageService.getValue).toHaveBeenCalledWith(StorageKey.UserRoles, undefined, [])
expect(storageService.getValue).toHaveBeenCalledWith(StorageKey.UserFeatures, undefined, [])
})
})
describe('updateRoles()', () => {
it('saves new roles to storage and fetches features if a role has been added', async () => {
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles)
expect(apiService.getUserFeatures).toHaveBeenCalledWith('123')
})
it('saves new roles to storage and fetches features if a role has been removed', async () => {
const newRoles = [RoleName.CoreUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles)
expect(apiService.getUserFeatures).toHaveBeenCalledWith('123')
})
it('saves features to storage when roles change', async () => {
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserFeatures, features)
})
it('creates items for non-expired features with content type if they do not exist', async () => {
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.createItem).toHaveBeenCalledTimes(2)
expect(itemManager.createItem).toHaveBeenCalledWith(
ContentType.Theme,
expect.objectContaining({
package_info: expect.objectContaining({
content_type: ContentType.Theme,
expires_at: tomorrow_client,
identifier: FeatureIdentifier.MidnightTheme,
}),
}),
true,
)
expect(itemManager.createItem).toHaveBeenCalledWith(
ContentType.Component,
expect.objectContaining({
package_info: expect.objectContaining({
content_type: ContentType.Component,
expires_at: tomorrow_client,
identifier: FeatureIdentifier.PlusEditor,
}),
}),
true,
)
})
it('if item for a feature exists updates its content', async () => {
const existingItem = new SNComponent({
uuid: '789',
content_type: ContentType.Component,
content: {
package_info: {
identifier: FeatureIdentifier.PlusEditor,
valid_until: new Date(),
},
},
} as never)
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
itemManager.getItems = jest.fn().mockReturnValue([existingItem])
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.changeComponent).toHaveBeenCalledWith(existingItem, expect.any(Function))
})
it('creates items for expired components if they do not exist', async () => {
const newRoles = [...roles, RoleName.PlusUser]
const now = new Date()
const yesterday_client = now.setDate(now.getDate() - 1)
const yesterday_server = yesterday_client * 1_000
storageService.getValue = jest.fn().mockReturnValue(roles)
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features: [
{
...features[1],
expires_at: yesterday_server,
},
],
},
})
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.createItem).toHaveBeenCalledWith(
ContentType.Component,
expect.objectContaining({
package_info: expect.objectContaining({
content_type: ContentType.Component,
expires_at: yesterday_client,
identifier: FeatureIdentifier.PlusEditor,
}),
}),
true,
)
})
it('deletes items for expired themes', async () => {
const existingItem = new SNComponent({
uuid: '456',
content_type: ContentType.Theme,
content: {
package_info: {
identifier: FeatureIdentifier.MidnightTheme,
valid_until: new Date(),
},
},
} as never)
const newRoles = [...roles, RoleName.PlusUser]
const now = new Date()
const yesterday = now.setDate(now.getDate() - 1)
itemManager.changeComponent = jest.fn().mockReturnValue(existingItem)
storageService.getValue = jest.fn().mockReturnValue(roles)
itemManager.getItems = jest.fn().mockReturnValue([existingItem])
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features: [
{
...features[0],
expires_at: yesterday,
},
],
},
})
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.setItemsToBeDeleted).toHaveBeenCalledWith([existingItem])
})
it('does not create an item for a feature without content type', async () => {
const features = [
{
identifier: FeatureIdentifier.TagNesting,
expires_at: tomorrow_server,
},
]
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features,
},
})
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.createItem).not.toHaveBeenCalled()
})
it('does not create an item for deprecated features', async () => {
const features = [
{
identifier: FeatureIdentifier.DeprecatedBoldEditor,
expires_at: tomorrow_server,
},
]
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features,
},
})
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(itemManager.createItem).not.toHaveBeenCalled()
})
it('does nothing after initial update if roles have not changed', async () => {
storageService.getValue = jest.fn().mockReturnValue(roles)
const featuresService = createService()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', roles)
await featuresService.updateRolesAndFetchFeatures('123', roles)
await featuresService.updateRolesAndFetchFeatures('123', roles)
await featuresService.updateRolesAndFetchFeatures('123', roles)
expect(storageService.setValue).toHaveBeenCalledTimes(2)
})
it('remote native features should be swapped with compiled version', async () => {
const remoteFeature = {
identifier: FeatureIdentifier.PlusEditor,
content_type: ContentType.Component,
expires_at: tomorrow_server,
} as FeatureDescription
const newRoles = [...roles, RoleName.PlusUser]
storageService.getValue = jest.fn().mockReturnValue(roles)
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features: [remoteFeature],
},
})
const featuresService = createService()
const nativeFeature = featuresService['mapRemoteNativeFeatureToStaticFeature'](remoteFeature)
featuresService['mapNativeFeatureToItem'] = jest.fn()
featuresService.initializeFromDisk()
await featuresService.updateRolesAndFetchFeatures('123', newRoles)
expect(featuresService['mapNativeFeatureToItem']).toHaveBeenCalledWith(
nativeFeature,
expect.anything(),
expect.anything(),
)
})
it('feature status', async () => {
const featuresService = createService()
features = [
{
identifier: FeatureIdentifier.MidnightTheme,
content_type: ContentType.Theme,
expires_at: tomorrow_server,
role_name: RoleName.PlusUser,
},
{
identifier: FeatureIdentifier.PlusEditor,
content_type: ContentType.Component,
expires_at: expiredDate,
role_name: RoleName.ProUser,
},
] as jest.Mocked<FeatureDescription[]>
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features,
},
})
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser])
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
expect(featuresService.getFeatureStatus(FeatureIdentifier.PlusEditor)).toBe(FeatureStatus.NotInCurrentPlan)
expect(featuresService.getFeatureStatus(FeatureIdentifier.SheetsEditor)).toBe(FeatureStatus.NotInCurrentPlan)
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser])
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription)
expect(featuresService.getFeatureStatus(FeatureIdentifier.PlusEditor)).toBe(FeatureStatus.NoUserSubscription)
expect(featuresService.getFeatureStatus(FeatureIdentifier.SheetsEditor)).toBe(FeatureStatus.NoUserSubscription)
features = [
{
identifier: FeatureIdentifier.MidnightTheme,
content_type: ContentType.Theme,
expires_at: expiredDate,
role_name: RoleName.PlusUser,
},
{
identifier: FeatureIdentifier.PlusEditor,
content_type: ContentType.Component,
expires_at: expiredDate,
role_name: RoleName.ProUser,
},
] as jest.Mocked<FeatureDescription[]>
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features,
},
})
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.PlusUser])
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(
FeatureStatus.InCurrentPlanButExpired,
)
expect(featuresService.getFeatureStatus(FeatureIdentifier.PlusEditor)).toBe(FeatureStatus.NotInCurrentPlan)
expect(featuresService.getFeatureStatus(FeatureIdentifier.SheetsEditor)).toBe(FeatureStatus.NotInCurrentPlan)
})
it('third party feature status', async () => {
const featuresService = createService()
const themeFeature = {
identifier: 'third-party-theme' as FeatureIdentifier,
content_type: ContentType.Theme,
expires_at: tomorrow_server,
role_name: RoleName.CoreUser,
}
const editorFeature = {
identifier: 'third-party-editor' as FeatureIdentifier,
content_type: ContentType.Component,
expires_at: expiredDate,
role_name: RoleName.PlusUser,
}
features = [themeFeature, editorFeature] as jest.Mocked<FeatureDescription[]>
featuresService['features'] = features
itemManager.getDisplayableComponents = jest.fn().mockReturnValue([
new SNComponent({
uuid: '123',
content_type: ContentType.Theme,
content: {
valid_until: themeFeature.expires_at,
package_info: {
...themeFeature,
},
},
} as never),
new SNComponent({
uuid: '456',
content_type: ContentType.Component,
content: {
valid_until: new Date(editorFeature.expires_at),
package_info: {
...editorFeature,
},
},
} as never),
])
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser])
expect(featuresService.getFeatureStatus(themeFeature.identifier)).toBe(FeatureStatus.Entitled)
expect(featuresService.getFeatureStatus(editorFeature.identifier)).toBe(FeatureStatus.InCurrentPlanButExpired)
expect(featuresService.getFeatureStatus('missing-feature-identifier' as FeatureIdentifier)).toBe(
FeatureStatus.NoUserSubscription,
)
})
it('feature status should be not entitled if no account or offline repo', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser])
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
featuresService['completedSuccessfulFeaturesRetrieval'] = false
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription)
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(
FeatureStatus.NoUserSubscription,
)
})
it('feature status should be entitled for subscriber until first successful features request made if no cached features', async () => {
const featuresService = createService()
apiService.getUserFeatures = jest.fn().mockReturnValue({
data: {
features: [],
},
})
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser])
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
featuresService['completedSuccessfulFeaturesRetrieval'] = false
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.Entitled)
await featuresService.didDownloadFeatures(features)
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan)
})
it('feature status should be dynamic for subscriber if cached features and no successful features request made yet', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser])
featuresService['completedSuccessfulFeaturesRetrieval'] = false
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan)
featuresService['completedSuccessfulFeaturesRetrieval'] = false
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan)
})
it('feature status for offline subscription', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser])
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
featuresService.hasOnlineSubscription = jest.fn().mockReturnValue(false)
featuresService['completedSuccessfulFeaturesRetrieval'] = true
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription)
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(
FeatureStatus.NoUserSubscription,
)
featuresService.hasOfflineRepo = jest.fn().mockReturnValue(true)
expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled)
expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan)
})
it('feature status for deprecated feature', async () => {
const featuresService = createService()
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
expect(featuresService.getFeatureStatus(FeatureIdentifier.DeprecatedFileSafe as FeatureIdentifier)).toBe(
FeatureStatus.NoUserSubscription,
)
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser])
expect(featuresService.getFeatureStatus(FeatureIdentifier.DeprecatedFileSafe as FeatureIdentifier)).toBe(
FeatureStatus.Entitled,
)
})
it('has paid subscription', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser])
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true)
expect(featuresService.hasPaidOnlineOrOfflineSubscription()).toBeFalsy
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser])
expect(featuresService.hasPaidOnlineOrOfflineSubscription()).toEqual(true)
})
it('has paid subscription should be true if offline repo and signed into third party server', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser])
featuresService.hasOfflineRepo = jest.fn().mockReturnValue(true)
sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false)
expect(featuresService.hasPaidOnlineOrOfflineSubscription()).toEqual(true)
})
})
describe('migrateFeatureRepoToUserSetting', () => {
it('should extract key from extension repo url and update user setting', async () => {
const extensionKey = '129b029707e3470c94a8477a437f9394'
const extensionRepoItem = new SNFeatureRepo({
uuid: '456',
content_type: ContentType.ExtensionRepo,
content: {
url: `https://extensions.standardnotes.org/${extensionKey}`,
},
} as never)
const featuresService = createService()
await featuresService.migrateFeatureRepoToUserSetting([extensionRepoItem])
expect(settingsService.updateSetting).toHaveBeenCalledWith(SettingName.ExtensionKey, extensionKey, true)
})
})
describe('downloadExternalFeature', () => {
it('should not allow if identifier matches native identifier', async () => {
apiService.downloadFeatureUrl = jest.fn().mockReturnValue({
data: {
identifier: 'org.standardnotes.bold-editor',
name: 'Bold Editor',
content_type: 'SN|Component',
area: 'editor-editor',
version: '1.0.0',
url: 'http://localhost:8005/',
},
})
const installUrl = 'http://example.com'
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
const featuresService = createService()
const result = await featuresService.downloadExternalFeature(installUrl)
expect(result).toBeUndefined()
})
it('should not allow if url matches native url', async () => {
apiService.downloadFeatureUrl = jest.fn().mockReturnValue({
data: {
identifier: 'org.foo.bar',
name: 'Bold Editor',
content_type: 'SN|Component',
area: 'editor-editor',
version: '1.0.0',
url: 'http://localhost:8005/org.standardnotes.bold-editor/index.html',
},
})
const installUrl = 'http://example.com'
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
const featuresService = createService()
const result = await featuresService.downloadExternalFeature(installUrl)
expect(result).toBeUndefined()
})
})
describe('sortRolesByHierarchy', () => {
it('should sort given roles according to role hierarchy', () => {
const featuresService = createService()
const sortedRoles = featuresService.rolesBySorting([RoleName.ProUser, RoleName.CoreUser, RoleName.PlusUser])
expect(sortedRoles).toStrictEqual([RoleName.CoreUser, RoleName.PlusUser, RoleName.ProUser])
})
})
describe('hasMinimumRole', () => {
it('should be false if core user checks for plus role', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser])
const hasPlusUserRole = featuresService.hasMinimumRole(RoleName.PlusUser)
expect(hasPlusUserRole).toBe(false)
})
it('should be false if plus user checks for pro role', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.PlusUser, RoleName.CoreUser])
const hasProUserRole = featuresService.hasMinimumRole(RoleName.ProUser)
expect(hasProUserRole).toBe(false)
})
it('should be true if pro user checks for core user', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.ProUser, RoleName.PlusUser])
const hasCoreUserRole = featuresService.hasMinimumRole(RoleName.CoreUser)
expect(hasCoreUserRole).toBe(true)
})
it('should be true if pro user checks for pro user', async () => {
const featuresService = createService()
await featuresService.updateRolesAndFetchFeatures('123', [RoleName.ProUser, RoleName.PlusUser])
const hasProUserRole = featuresService.hasMinimumRole(RoleName.ProUser)
expect(hasProUserRole).toBe(true)
})
})
})

View File

@@ -0,0 +1,718 @@
import { AccountEvent, UserService } from '../User/UserService'
import { SNApiService } from '../Api/ApiService'
import {
arraysEqual,
convertTimestampToMilliseconds,
removeFromArray,
Copy,
lastElement,
isString,
} from '@standardnotes/utils'
import { ClientDisplayableError, UserFeaturesResponse } from '@standardnotes/responses'
import { ContentType, RoleName } from '@standardnotes/common'
import { FeaturesClientInterface } from './ClientInterface'
import { FillItemContent, PayloadEmitSource } from '@standardnotes/models'
import { ItemManager } from '../Items/ItemManager'
import { LEGACY_PROD_EXT_ORIGIN, PROD_OFFLINE_FEATURES_URL } from '../../Hosts'
import { SettingName } from '@standardnotes/settings'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { SNSessionManager } from '@Lib/Services/Session/SessionManager'
import { SNSettingsService } from '../Settings'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { SNSyncService } from '../Sync/SyncService'
import { SNWebSocketsService, WebSocketsServiceEvent } from '../Api/WebsocketsService'
import { TRUSTED_CUSTOM_EXTENSIONS_HOSTS, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts'
import { UserRolesChangedEvent } from '@standardnotes/domain-events'
import { UuidString } from '@Lib/Types/UuidString'
import * as FeaturesImports from '@standardnotes/features'
import * as Messages from '@Lib/Services/Api/Messages'
import * as Models from '@standardnotes/models'
import * as Services from '@standardnotes/services'
import {
FeaturesEvent,
FeatureStatus,
OfflineSubscriptionEntitlements,
SetOfflineFeaturesFunctionResponse,
} from './Types'
import { DiagnosticInfo } from '@standardnotes/services'
type GetOfflineSubscriptionDetailsResponse = OfflineSubscriptionEntitlements | ClientDisplayableError
export class SNFeaturesService
extends Services.AbstractService<FeaturesEvent>
implements FeaturesClientInterface, Services.InternalEventHandlerInterface
{
private deinited = false
private roles: RoleName[] = []
private features: FeaturesImports.FeatureDescription[] = []
private enabledExperimentalFeatures: FeaturesImports.FeatureIdentifier[] = []
private removeWebSocketsServiceObserver: () => void
private removefeatureReposObserver: () => void
private removeSignInObserver: () => void
private needsInitialFeaturesUpdate = true
private completedSuccessfulFeaturesRetrieval = false
constructor(
private storageService: DiskStorageService,
private apiService: SNApiService,
private itemManager: ItemManager,
private webSocketsService: SNWebSocketsService,
private settingsService: SNSettingsService,
private userService: UserService,
private syncService: SNSyncService,
private alertService: Services.AlertService,
private sessionManager: SNSessionManager,
private crypto: PureCryptoInterface,
protected override internalEventBus: Services.InternalEventBusInterface,
) {
super(internalEventBus)
this.removeWebSocketsServiceObserver = webSocketsService.addEventObserver(async (eventName, data) => {
if (eventName === WebSocketsServiceEvent.UserRoleMessageReceived) {
const {
payload: { userUuid, currentRoles },
} = data as UserRolesChangedEvent
await this.updateRolesAndFetchFeatures(userUuid, currentRoles)
}
})
this.removefeatureReposObserver = this.itemManager.addObserver(
ContentType.ExtensionRepo,
async ({ changed, inserted, source }) => {
const sources = [
PayloadEmitSource.InitialObserverRegistrationPush,
PayloadEmitSource.LocalInserted,
PayloadEmitSource.LocalDatabaseLoaded,
PayloadEmitSource.RemoteRetrieved,
PayloadEmitSource.FileImport,
]
if (sources.includes(source)) {
const items = [...changed, ...inserted] as Models.SNFeatureRepo[]
if (this.sessionManager.isSignedIntoFirstPartyServer()) {
await this.migrateFeatureRepoToUserSetting(items)
} else {
await this.migrateFeatureRepoToOfflineEntitlements(items)
}
}
},
)
this.removeSignInObserver = this.userService.addEventObserver((eventName: AccountEvent) => {
if (eventName === AccountEvent.SignedInOrRegistered) {
const featureRepos = this.itemManager.getItems(ContentType.ExtensionRepo) as Models.SNFeatureRepo[]
if (!this.apiService.isThirdPartyHostUsed()) {
void this.migrateFeatureRepoToUserSetting(featureRepos)
}
}
})
}
async handleEvent(event: Services.InternalEventInterface): Promise<void> {
if (event.type === Services.ApiServiceEvent.MetaReceived) {
if (!this.syncService) {
this.log('[Features Service] Handling events interrupted. Sync service is not yet initialized.', event)
return
}
/**
* All user data must be downloaded before we map features. Otherwise, feature mapping
* may think a component doesn't exist and create a new one, when in reality the component
* already exists but hasn't been downloaded yet.
*/
if (!this.syncService.completedOnlineDownloadFirstSync) {
return
}
const { userUuid, userRoles } = event.payload as Services.MetaReceivedData
await this.updateRolesAndFetchFeatures(
userUuid,
userRoles.map((role) => role.name),
)
}
}
override async handleApplicationStage(stage: Services.ApplicationStage): Promise<void> {
await super.handleApplicationStage(stage)
if (stage === Services.ApplicationStage.FullSyncCompleted_13) {
if (!this.hasOnlineSubscription()) {
const offlineRepo = this.getOfflineRepo()
if (offlineRepo) {
void this.downloadOfflineFeatures(offlineRepo)
}
}
}
}
public enableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
const feature = this.getUserFeature(identifier)
if (!feature) {
throw Error('Attempting to enable a feature user does not have access to.')
}
this.enabledExperimentalFeatures.push(identifier)
void this.storageService.setValue(Services.StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
void this.mapRemoteNativeFeaturesToItems([feature])
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
}
public disableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
const feature = this.getUserFeature(identifier)
if (!feature) {
throw Error('Attempting to disable a feature user does not have access to.')
}
removeFromArray(this.enabledExperimentalFeatures, identifier)
void this.storageService.setValue(Services.StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
const component = this.itemManager
.getItems<Models.SNComponent | Models.SNTheme>([ContentType.Component, ContentType.Theme])
.find((component) => component.identifier === identifier)
if (!component) {
return
}
void this.itemManager.setItemToBeDeleted(component).then(() => {
void this.syncService.sync()
})
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
}
public toggleExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
if (this.isExperimentalFeatureEnabled(identifier)) {
this.disableExperimentalFeature(identifier)
} else {
this.enableExperimentalFeature(identifier)
}
}
public getExperimentalFeatures(): FeaturesImports.FeatureIdentifier[] {
return FeaturesImports.ExperimentalFeatures
}
public isExperimentalFeature(featureId: FeaturesImports.FeatureIdentifier): boolean {
return this.getExperimentalFeatures().includes(featureId)
}
public getEnabledExperimentalFeatures(): FeaturesImports.FeatureIdentifier[] {
return this.enabledExperimentalFeatures
}
public isExperimentalFeatureEnabled(featureId: FeaturesImports.FeatureIdentifier): boolean {
return this.enabledExperimentalFeatures.includes(featureId)
}
public async setOfflineFeaturesCode(code: string): Promise<SetOfflineFeaturesFunctionResponse> {
try {
const activationCodeWithoutSpaces = code.replace(/\s/g, '')
const decodedData = this.crypto.base64Decode(activationCodeWithoutSpaces)
const result = this.parseOfflineEntitlementsCode(decodedData)
if (result instanceof ClientDisplayableError) {
return result
}
const offlineRepo = (await this.itemManager.createItem(
ContentType.ExtensionRepo,
FillItemContent({
offlineFeaturesUrl: result.featuresUrl,
offlineKey: result.extensionKey,
migratedToOfflineEntitlements: true,
} as Models.FeatureRepoContent),
true,
)) as Models.SNFeatureRepo
void this.syncService.sync()
return this.downloadOfflineFeatures(offlineRepo)
} catch (err) {
return new ClientDisplayableError(Messages.API_MESSAGE_FAILED_OFFLINE_ACTIVATION)
}
}
private getOfflineRepo(): Models.SNFeatureRepo | undefined {
const repos = this.itemManager.getItems(ContentType.ExtensionRepo) as Models.SNFeatureRepo[]
return repos.filter((repo) => repo.migratedToOfflineEntitlements)[0]
}
public hasOfflineRepo(): boolean {
return this.getOfflineRepo() != undefined
}
public async deleteOfflineFeatureRepo(): Promise<void> {
const repo = this.getOfflineRepo()
if (repo) {
await this.itemManager.setItemToBeDeleted(repo)
void this.syncService.sync()
}
await this.storageService.removeValue(Services.StorageKey.UserFeatures)
}
private parseOfflineEntitlementsCode(code: string): GetOfflineSubscriptionDetailsResponse | ClientDisplayableError {
try {
const { featuresUrl, extensionKey } = JSON.parse(code)
return {
featuresUrl,
extensionKey,
}
} catch (error) {
return new ClientDisplayableError(Messages.API_MESSAGE_FAILED_OFFLINE_ACTIVATION)
}
}
private async downloadOfflineFeatures(
repo: Models.SNFeatureRepo,
): Promise<SetOfflineFeaturesFunctionResponse | ClientDisplayableError> {
const result = await this.apiService.downloadOfflineFeaturesFromRepo(repo)
if (result instanceof ClientDisplayableError) {
return result
}
await this.didDownloadFeatures(result.features)
return undefined
}
public async migrateFeatureRepoToUserSetting(featureRepos: Models.SNFeatureRepo[] = []): Promise<void> {
for (const item of featureRepos) {
if (item.migratedToUserSetting) {
continue
}
if (item.onlineUrl) {
const repoUrl: string = item.onlineUrl
const userKeyMatch = repoUrl.match(/\w{32,64}/)
if (userKeyMatch && userKeyMatch.length > 0) {
const userKey = userKeyMatch[0]
await this.settingsService.updateSetting(SettingName.ExtensionKey, userKey, true)
await this.itemManager.changeFeatureRepo(item, (m) => {
m.migratedToUserSetting = true
})
}
}
}
}
public async migrateFeatureRepoToOfflineEntitlements(featureRepos: Models.SNFeatureRepo[] = []): Promise<void> {
for (const item of featureRepos) {
if (item.migratedToOfflineEntitlements) {
continue
}
if (item.onlineUrl) {
const repoUrl = item.onlineUrl
const { origin } = new URL(repoUrl)
if (!origin.includes(LEGACY_PROD_EXT_ORIGIN)) {
continue
}
const userKeyMatch = repoUrl.match(/\w{32,64}/)
if (userKeyMatch && userKeyMatch.length > 0) {
const userKey = userKeyMatch[0]
const updatedRepo = await this.itemManager.changeFeatureRepo(item, (m) => {
m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL
m.offlineKey = userKey
m.migratedToOfflineEntitlements = true
})
await this.downloadOfflineFeatures(updatedRepo)
}
}
}
}
public initializeFromDisk(): void {
this.roles = this.storageService.getValue<RoleName[]>(Services.StorageKey.UserRoles, undefined, [])
this.features = this.storageService.getValue(Services.StorageKey.UserFeatures, undefined, [])
this.enabledExperimentalFeatures = this.storageService.getValue(
Services.StorageKey.ExperimentalFeatures,
undefined,
[],
)
}
public async updateRolesAndFetchFeatures(userUuid: UuidString, roles: RoleName[]): Promise<void> {
const userRolesChanged = this.haveRolesChanged(roles)
if (!userRolesChanged && !this.needsInitialFeaturesUpdate) {
return
}
this.needsInitialFeaturesUpdate = false
await this.setRoles(roles)
const shouldDownloadRoleBasedFeatures = !this.hasOfflineRepo()
if (shouldDownloadRoleBasedFeatures) {
const featuresResponse = await this.apiService.getUserFeatures(userUuid)
if (!featuresResponse.error && featuresResponse.data && !this.deinited) {
const features = (featuresResponse as UserFeaturesResponse).data.features
await this.didDownloadFeatures(features)
}
}
}
private async setRoles(roles: RoleName[]): Promise<void> {
this.roles = roles
if (!arraysEqual(this.roles, roles)) {
void this.notifyEvent(FeaturesEvent.UserRolesChanged)
}
await this.storageService.setValue(Services.StorageKey.UserRoles, this.roles)
}
public async didDownloadFeatures(features: FeaturesImports.FeatureDescription[]): Promise<void> {
features = features
.filter((feature) => !!FeaturesImports.FindNativeFeature(feature.identifier))
.map((feature) => this.mapRemoteNativeFeatureToStaticFeature(feature))
this.features = features
this.completedSuccessfulFeaturesRetrieval = true
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
void this.storageService.setValue(Services.StorageKey.UserFeatures, this.features)
await this.mapRemoteNativeFeaturesToItems(features)
}
public isThirdPartyFeature(identifier: string): boolean {
const isNativeFeature = !!FeaturesImports.FindNativeFeature(identifier as FeaturesImports.FeatureIdentifier)
return !isNativeFeature
}
private mapRemoteNativeFeatureToStaticFeature(
remoteFeature: FeaturesImports.FeatureDescription,
): FeaturesImports.FeatureDescription {
const remoteFields: (keyof FeaturesImports.FeatureDescription)[] = [
'expires_at',
'role_name',
'no_expire',
'permission_name',
]
const nativeFeature = FeaturesImports.FindNativeFeature(remoteFeature.identifier)
if (!nativeFeature) {
throw Error(`Attempting to map remote native to unfound static feature ${remoteFeature.identifier}`)
}
const nativeFeatureCopy = Copy(nativeFeature) as FeaturesImports.FeatureDescription
for (const field of remoteFields) {
nativeFeatureCopy[field] = remoteFeature[field] as never
}
if (nativeFeatureCopy.expires_at) {
nativeFeatureCopy.expires_at = convertTimestampToMilliseconds(nativeFeatureCopy.expires_at)
}
return nativeFeatureCopy
}
public getUserFeature(featureId: FeaturesImports.FeatureIdentifier): FeaturesImports.FeatureDescription | undefined {
return this.features.find((feature) => feature.identifier === featureId)
}
hasOnlineSubscription(): boolean {
const roles = this.roles
const unpaidRoles = [RoleName.CoreUser]
return roles.some((role) => !unpaidRoles.includes(role))
}
public hasPaidOnlineOrOfflineSubscription(): boolean {
return this.hasOnlineSubscription() || this.hasOfflineRepo()
}
public rolesBySorting(roles: RoleName[]): RoleName[] {
return Object.values(RoleName).filter((role) => roles.includes(role))
}
public hasMinimumRole(role: RoleName): boolean {
const sortedAllRoles = Object.values(RoleName)
const sortedUserRoles = this.rolesBySorting(this.roles)
const highestUserRoleIndex = sortedAllRoles.indexOf(lastElement(sortedUserRoles) as RoleName)
const indexOfRoleToCheck = sortedAllRoles.indexOf(role)
return indexOfRoleToCheck <= highestUserRoleIndex
}
public isFeatureDeprecated(featureId: FeaturesImports.FeatureIdentifier): boolean {
return FeaturesImports.FindNativeFeature(featureId)?.deprecated === true
}
public getFeatureStatus(featureId: FeaturesImports.FeatureIdentifier): FeatureStatus {
const isDeprecated = this.isFeatureDeprecated(featureId)
if (isDeprecated) {
if (this.hasPaidOnlineOrOfflineSubscription()) {
return FeatureStatus.Entitled
} else {
return FeatureStatus.NoUserSubscription
}
}
const isThirdParty = FeaturesImports.FindNativeFeature(featureId) == undefined
if (isThirdParty) {
const component = this.itemManager
.getDisplayableComponents()
.find((candidate) => candidate.identifier === featureId)
if (!component) {
return FeatureStatus.NoUserSubscription
}
if (component.isExpired) {
return FeatureStatus.InCurrentPlanButExpired
}
return FeatureStatus.Entitled
}
if (this.hasPaidOnlineOrOfflineSubscription()) {
if (!this.completedSuccessfulFeaturesRetrieval) {
const hasCachedFeatures = this.features.length > 0
const temporarilyAllowUntilServerUpdates = !hasCachedFeatures
if (temporarilyAllowUntilServerUpdates) {
return FeatureStatus.Entitled
}
}
} else {
return FeatureStatus.NoUserSubscription
}
const feature = this.getUserFeature(featureId)
if (!feature) {
return FeatureStatus.NotInCurrentPlan
}
const expired = feature.expires_at && new Date(feature.expires_at).getTime() < new Date().getTime()
if (expired) {
if (!this.roles.includes(feature.role_name as RoleName)) {
return FeatureStatus.NotInCurrentPlan
} else {
return FeatureStatus.InCurrentPlanButExpired
}
}
return FeatureStatus.Entitled
}
private haveRolesChanged(roles: RoleName[]): boolean {
return roles.some((role) => !this.roles.includes(role)) || this.roles.some((role) => !roles.includes(role))
}
private componentContentForNativeFeatureDescription(feature: FeaturesImports.FeatureDescription): Models.ItemContent {
const componentContent: Partial<Models.ComponentContent> = {
area: feature.area,
name: feature.name,
package_info: feature,
valid_until: new Date(feature.expires_at || 0),
}
return FillItemContent(componentContent)
}
private async mapRemoteNativeFeaturesToItems(features: FeaturesImports.FeatureDescription[]): Promise<void> {
const currentItems = this.itemManager.getItems<Models.SNComponent>([ContentType.Component, ContentType.Theme])
const itemsToDelete: Models.SNComponent[] = []
let hasChanges = false
for (const feature of features) {
const didChange = await this.mapNativeFeatureToItem(feature, currentItems, itemsToDelete)
if (didChange) {
hasChanges = true
}
}
await this.itemManager.setItemsToBeDeleted(itemsToDelete)
if (hasChanges) {
void this.syncService.sync()
}
}
private async mapNativeFeatureToItem(
feature: FeaturesImports.FeatureDescription,
currentItems: Models.SNComponent[],
itemsToDelete: Models.SNComponent[],
): Promise<boolean> {
if (!feature.content_type) {
return false
}
if (this.isExperimentalFeature(feature.identifier) && !this.isExperimentalFeatureEnabled(feature.identifier)) {
return false
}
let hasChanges = false
const now = new Date()
const expired = new Date(feature.expires_at || 0).getTime() < now.getTime()
const existingItem = currentItems.find((item) => {
if (item.content.package_info) {
const itemIdentifier = item.content.package_info.identifier
return itemIdentifier === feature.identifier
}
return false
})
if (feature.deprecated && !existingItem) {
return false
}
let resultingItem: Models.SNComponent | undefined = existingItem
if (existingItem) {
const featureExpiresAt = new Date(feature.expires_at || 0)
const hasChange =
JSON.stringify(feature) !== JSON.stringify(existingItem.package_info) ||
featureExpiresAt.getTime() !== existingItem.valid_until.getTime()
if (hasChange) {
resultingItem = await this.itemManager.changeComponent(existingItem, (mutator) => {
mutator.package_info = feature
mutator.valid_until = featureExpiresAt
})
hasChanges = true
} else {
resultingItem = existingItem
}
} else if (!expired || feature.content_type === ContentType.Component) {
resultingItem = (await this.itemManager.createItem(
feature.content_type,
this.componentContentForNativeFeatureDescription(feature),
true,
)) as Models.SNComponent
hasChanges = true
}
if (expired && resultingItem) {
if (feature.content_type !== ContentType.Component) {
itemsToDelete.push(resultingItem)
hasChanges = true
}
}
return hasChanges
}
public async downloadExternalFeature(urlOrCode: string): Promise<Models.SNComponent | undefined> {
let url = urlOrCode
try {
url = this.crypto.base64Decode(urlOrCode)
// eslint-disable-next-line no-empty
} catch (err) {}
try {
const trustedCustomExtensionsUrls = [...TRUSTED_FEATURE_HOSTS, ...TRUSTED_CUSTOM_EXTENSIONS_HOSTS]
const { host } = new URL(url)
if (!trustedCustomExtensionsUrls.includes(host)) {
const didConfirm = await this.alertService.confirm(
Messages.API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING,
'Install extension from an untrusted source?',
'Proceed to install',
Services.ButtonType.Danger,
'Cancel',
)
if (didConfirm) {
return this.performDownloadExternalFeature(url)
}
} else {
return this.performDownloadExternalFeature(url)
}
} catch (err) {
void this.alertService.alert(Messages.INVALID_EXTENSION_URL)
}
return undefined
}
private async performDownloadExternalFeature(url: string): Promise<Models.SNComponent | undefined> {
const response = await this.apiService.downloadFeatureUrl(url)
if (response.error) {
await this.alertService.alert(Messages.API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
return undefined
}
let rawFeature = response.data as FeaturesImports.ThirdPartyFeatureDescription
if (isString(rawFeature)) {
try {
rawFeature = JSON.parse(rawFeature)
// eslint-disable-next-line no-empty
} catch (error) {}
}
if (!rawFeature.content_type) {
return
}
const isValidContentType = [
ContentType.Component,
ContentType.Theme,
ContentType.ActionsExtension,
ContentType.ExtensionRepo,
].includes(rawFeature.content_type)
if (!isValidContentType) {
return
}
const nativeFeature = FeaturesImports.FindNativeFeature(rawFeature.identifier)
if (nativeFeature) {
await this.alertService.alert(Messages.API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
return
}
if (rawFeature.url) {
for (const nativeFeature of FeaturesImports.GetFeatures()) {
if (rawFeature.url.includes(nativeFeature.identifier)) {
await this.alertService.alert(Messages.API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
return
}
}
}
const content = FillItemContent({
area: rawFeature.area,
name: rawFeature.name,
package_info: rawFeature,
valid_until: new Date(rawFeature.expires_at || 0),
hosted_url: rawFeature.url,
} as Partial<Models.ComponentContent>)
const component = this.itemManager.createTemplateItem(rawFeature.content_type, content) as Models.SNComponent
return component
}
override deinit(): void {
super.deinit()
this.removeSignInObserver()
;(this.removeSignInObserver as unknown) = undefined
this.removeWebSocketsServiceObserver()
;(this.removeWebSocketsServiceObserver as unknown) = undefined
this.removefeatureReposObserver()
;(this.removefeatureReposObserver as unknown) = undefined
;(this.roles as unknown) = undefined
;(this.storageService as unknown) = undefined
;(this.apiService as unknown) = undefined
;(this.itemManager as unknown) = undefined
;(this.webSocketsService as unknown) = undefined
;(this.settingsService as unknown) = undefined
;(this.userService as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.sessionManager as unknown) = undefined
;(this.crypto as unknown) = undefined
this.deinited = true
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
features: {
roles: this.roles,
features: this.features,
enabledExperimentalFeatures: this.enabledExperimentalFeatures,
needsInitialFeaturesUpdate: this.needsInitialFeaturesUpdate,
completedSuccessfulFeaturesRetrieval: this.completedSuccessfulFeaturesRetrieval,
},
})
}
}

View File

@@ -0,0 +1,20 @@
import { ClientDisplayableError } from '@standardnotes/responses'
export type SetOfflineFeaturesFunctionResponse = ClientDisplayableError | undefined
export type OfflineSubscriptionEntitlements = {
featuresUrl: string
extensionKey: string
}
export enum FeaturesEvent {
UserRolesChanged = 'UserRolesChanged',
FeaturesUpdated = 'FeaturesUpdated',
}
export enum FeatureStatus {
NoUserSubscription = 'NoUserSubscription',
NotInCurrentPlan = 'NotInCurrentPlan',
InCurrentPlanButExpired = 'InCurrentPlanButExpired',
Entitled = 'Entitled',
}

View File

@@ -0,0 +1,3 @@
export * from './ClientInterface'
export * from './FeaturesService'
export * from './Types'